Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add alignment buttons popover option for alignment relative to artboard #1724

Closed
wants to merge 11 commits into from
677 changes: 340 additions & 337 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion editor/src/messages/portfolio/document/document_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use graphene_core::Color;

use glam::DAffine2;

use super::utility_types::misc::{OptionBoundsSnapping, OptionPointSnapping};
use super::utility_types::misc::{AlignMode, OptionBoundsSnapping, OptionPointSnapping};

#[impl_message(Message, PortfolioMessage, Document)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
Expand All @@ -35,6 +35,7 @@ pub enum DocumentMessage {
AlignSelectedLayers {
axis: AlignAxis,
aggregate: AlignAggregate,
align_mode: AlignMode,
},
BackupDocument {
network: NodeNetwork,
Expand Down
38 changes: 30 additions & 8 deletions editor/src/messages/portfolio/document/document_message_handler.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use super::utility_types::error::EditorError;
use super::utility_types::misc::{BoundingBoxSnapTarget, GeometrySnapTarget, OptionBoundsSnapping, OptionPointSnapping, SnappingOptions, SnappingState};
use super::utility_types::misc::{AlignMode, BoundingBoxSnapTarget, GeometrySnapTarget, OptionBoundsSnapping, OptionPointSnapping, SnappingOptions, SnappingState};
use super::utility_types::nodes::{CollapsedLayers, SelectedNodes};
use crate::application::{generate_uuid, GRAPHITE_GIT_COMMIT_HASH};
use crate::consts::{ASYMPTOTIC_EFFECT, DEFAULT_DOCUMENT_NAME, FILE_SAVE_SUFFIX, SCALE_EFFECT, SCROLLBAR_SPACING};
Expand Down Expand Up @@ -174,32 +174,54 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
responses.add(OverlaysMessage::Draw);
}
}
DocumentMessage::AlignSelectedLayers { axis, aggregate } => {
DocumentMessage::AlignSelectedLayers { axis, aggregate, align_mode } => {
self.backup(responses);

debug!("WE GOT: {:?}", align_mode);

let axis = match axis {
AlignAxis::X => DVec2::X,
AlignAxis::Y => DVec2::Y,
};
let Some(combined_box) = self.selected_visible_layers_bounding_box_viewport() else {

let mut layer_align_to = None;
let alignment_box = match align_mode {
AlignMode::Artboard { .. } => self.metadata().bounding_box_viewport(self.metadata().active_artboard()),
AlignMode::Layer(layer_aligned_to) => {
layer_align_to = Some(LayerNodeIdentifier::new_unchecked(NodeId(layer_aligned_to)));
self.metadata().bounding_box_viewport(layer_align_to.unwrap())
}
_ => self.selected_visible_layers_bounding_box_viewport(),
};
let Some(alignment_box) = alignment_box else {
return;
};

let aggregated = match aggregate {
AlignAggregate::Min => combined_box[0],
AlignAggregate::Max => combined_box[1],
AlignAggregate::Center => (combined_box[0] + combined_box[1]) / 2.,
AlignAggregate::Min => alignment_box[0],
AlignAggregate::Max => alignment_box[1],
AlignAggregate::Center => (alignment_box[0] + alignment_box[1]) / 2.,
};
for layer in self.selected_nodes.selected_unlocked_layers(self.metadata()) {
let Some(bbox) = self.metadata().bounding_box_viewport(layer) else {

let moved_layers = self.selected_nodes.selected_unlocked_layers(self.metadata()).filter(|layer| Some(layer) != layer_align_to.as_ref());

for layer in moved_layers {
let bbox = if let AlignMode::Artboard { shallow_align: true } = align_mode {
self.selected_visible_layers_bounding_box_viewport()
} else {
self.metadata().bounding_box_viewport(layer)
};
let Some(bbox) = bbox else {
continue;
};

let center = match aggregate {
AlignAggregate::Min => bbox[0],
AlignAggregate::Max => bbox[1],
_ => (bbox[0] + bbox[1]) / 2.,
};
let translation = (aggregated - center) * axis;

responses.add(GraphOperationMessage::TransformChange {
layer,
transform: DAffine2::from_translation(translation),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2628,10 +2628,11 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
DocumentNodeDefinition {
name: "Repeat",
category: "Vector",
implementation: DocumentNodeImplementation::proto("graphene_core::vector::RepeatNode<_, _>"),
implementation: DocumentNodeImplementation::proto("graphene_core::vector::RepeatNode<_, _, _>"),
inputs: vec![
DocumentInputType::value("Instance", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), true),
DocumentInputType::value("Direction", TaggedValue::DVec2((100., 0.).into()), false),
DocumentInputType::value("Direction", TaggedValue::DVec2((0., 0.).into()), false),
DocumentInputType::value("Angle", TaggedValue::F64(0.), false),
DocumentInputType::value("Count", TaggedValue::U32(10), false),
],
outputs: vec![DocumentOutputType::new("Vector", FrontendGraphDataType::Subpath)],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2289,9 +2289,10 @@ pub fn stroke_properties(document_node: &DocumentNode, node_id: NodeId, _context

pub fn repeat_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let direction = vec2_widget(document_node, node_id, 1, "Direction", "X", "Y", " px", None, add_blank_assist);
let count = number_widget(document_node, node_id, 2, "Count", NumberInput::default().min(1.), true);
let angle = number_widget(document_node, node_id, 2, "Angle", NumberInput::default().unit("°"), true);
let count = number_widget(document_node, node_id, 3, "Count", NumberInput::default().min(1.).is_integer(true), true);

vec![direction, LayoutGroup::Row { widgets: count }]
vec![direction, LayoutGroup::Row { widgets: angle }, LayoutGroup::Row { widgets: count }]
}

pub fn circular_repeat_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
Expand Down
10 changes: 10 additions & 0 deletions editor/src/messages/portfolio/document/utility_types/misc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ pub enum AlignAggregate {
Center,
}

#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
pub enum AlignMode {
#[default]
Selection,
Artboard {
shallow_align: bool,
},
Layer(u64),
}

#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum DocumentMode {
#[default]
Expand Down
170 changes: 147 additions & 23 deletions editor/src/messages/tool/tool_messages/select_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::messages::input_mapper::utility_types::input_mouse::ViewportPosition;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::overlays::utility_types::OverlayContext;
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, FlipAxis};
use crate::messages::portfolio::document::utility_types::misc::{AlignAggregate, AlignAxis, AlignMode, FlipAxis};
use crate::messages::portfolio::document::utility_types::transformation::Selected;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::is_layer_fed_by_node_of_name;
Expand Down Expand Up @@ -69,6 +69,9 @@ pub enum SelectToolMessage {
Overlays(OverlayContext),

// Tool-specific messages
SetAlignMode(AlignMode),
SetArtboardAlignMode(bool),
SetAlignToLayer(u64),
DragStart { add_to_selection: Key, select_deepest: Key },
DragStop { remove_from_selection: Key },
EditLayer,
Expand Down Expand Up @@ -115,7 +118,7 @@ impl SelectTool {
.widget_holder()
}

fn alignment_widgets(&self, disabled: bool) -> impl Iterator<Item = WidgetHolder> {
fn alignment_widgets(&self, disabled: bool, align_mode: AlignMode) -> impl Iterator<Item = WidgetHolder> {
[AlignAxis::X, AlignAxis::Y]
.into_iter()
.flat_map(|axis| [(axis, AlignAggregate::Min), (axis, AlignAggregate::Center), (axis, AlignAggregate::Max)])
Expand All @@ -130,7 +133,7 @@ impl SelectTool {
};
IconButton::new(icon, 24)
.tooltip(tooltip)
.on_update(move |_| DocumentMessage::AlignSelectedLayers { axis, aggregate }.into())
.on_update(move |_| DocumentMessage::AlignSelectedLayers { axis, aggregate, align_mode }.into())
.disabled(disabled)
.widget_holder()
})
Expand All @@ -154,10 +157,106 @@ impl SelectTool {
.widget_holder()
})
}
}

impl LayoutHolder for SelectTool {
fn layout(&self) -> Layout {
fn align_popover_menu_widgets(&self, document: &DocumentMessageHandler) -> WidgetHolder {
let selected_layers_ids = &self.tool_data.layers_dragging;
let selected_layers_names = selected_layers_ids.iter().map(|layer| {
let node = document.network().nodes.get(&layer.to_node()).unwrap();
if node.alias.is_empty() {
format!("{}", node.name.clone())
} else {
node.alias.clone()
}
});

let layer_entries: Vec<MenuListEntry> = selected_layers_ids
.iter()
.zip(selected_layers_names)
.map(|(layer, name)| {
let NodeId(node_id_value) = layer.to_node();
MenuListEntry::new(node_id_value.to_string())
.label(name)
.on_update(move |_| SelectToolMessage::SetAlignToLayer(node_id_value).into())
})
.collect();

let selected_layer_idx = layer_entries.iter().position(|entry| self.tool_data.align_to_layer_id.to_string() == entry.value).map(|i| i as u32);
let layer_entries = vec![layer_entries];

let shallow_align = self.tool_data.align_artboard_shallow;
let align_to_layer_id = self.tool_data.align_to_layer_id;
let radio_entries = [
(AlignMode::Selection, "Selection", "Align all selected layers individually to their combined bounding box"),
(AlignMode::Layer(align_to_layer_id), "Layer", "Align all other selected layers to a specific selected layer"),
(AlignMode::Artboard { shallow_align }, "Artboard", "Align each selected layer to its artboard"),
]
.into_iter()
.map(|(val, name, tooltip)| {
RadioEntryData::new(format!("{val:?}"))
.label(name)
.tooltip(tooltip)
.on_update(move |_| SelectToolMessage::SetAlignMode(val).into())
})
.collect();

let radio_selected_index = match self.tool_data.align_mode {
AlignMode::Selection => 0,
AlignMode::Layer(_) => 1,
AlignMode::Artboard { .. } => 2,
};

let align_mode = self.tool_data.align_mode;
let disabled = !matches!(align_mode, AlignMode::Artboard { .. });
let preserve_relative_positions_tooltip = "Align selected layers as a group instead of individually";

let mut popover_layout = vec![
LayoutGroup::Row {
widgets: vec![TextLabel::new("Align").bold(true).widget_holder()],
},
LayoutGroup::Row {
widgets: vec![RadioInput::new(radio_entries).selected_index(Some(radio_selected_index)).widget_holder()],
},
];

if matches!(align_mode, AlignMode::Layer(_)) {
popover_layout.push(LayoutGroup::Row {
widgets: vec![DropdownInput::new(layer_entries)
.selected_index(selected_layer_idx.unwrap_or(0 as u32).into())
.disabled(!matches!(align_mode, AlignMode::Layer(_)))
.widget_holder()],
});
}

if matches!(align_mode, AlignMode::Artboard { .. }) {
popover_layout.push(LayoutGroup::Row {
widgets: vec![
CheckboxInput::new(self.tool_data.align_artboard_shallow)
.disabled(disabled)
.tooltip(preserve_relative_positions_tooltip)
.on_update(move |input: &CheckboxInput| SelectToolMessage::SetArtboardAlignMode(input.checked).into())
.widget_holder(),
TextLabel::new("Preserve Relative Positions")
.disabled(disabled)
.tooltip(preserve_relative_positions_tooltip)
.widget_holder(),
],
});
}

PopoverButton::new().popover_layout(popover_layout).widget_holder()
}

fn should_refresh_align_popover(&mut self) -> bool {
let align_mode_changed = self.tool_data.align_mode_changed;
self.tool_data.align_mode_changed = false;
align_mode_changed
}

pub fn should_update_widgets(&mut self) -> bool {
self.tool_data.pivot.should_refresh_pivot_position() || self.should_refresh_align_popover()
}

fn layout(&self, document: &DocumentMessageHandler) -> Layout {
let mut widgets = Vec::new();

// Select mode (Deep/Shallow)
Expand All @@ -168,22 +267,13 @@ impl LayoutHolder for SelectTool {
widgets.push(self.pivot_widget(self.tool_data.selected_layers_count == 0));

// Align
let disabled = self.tool_data.selected_layers_count < 2;
let disabled = {
let align_to_artboard = matches!(self.tool_data.align_mode, AlignMode::Artboard { .. });
self.tool_data.selected_layers_count == 0 || (!align_to_artboard && self.tool_data.selected_layers_count < 2)
};
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
widgets.extend(self.alignment_widgets(disabled));
widgets.push(
PopoverButton::new()
.popover_layout(vec![
LayoutGroup::Row {
widgets: vec![TextLabel::new("Align").bold(true).widget_holder()],
},
LayoutGroup::Row {
widgets: vec![TextLabel::new("Coming soon").widget_holder()],
},
])
.disabled(disabled)
.widget_holder(),
);
widgets.extend(self.alignment_widgets(disabled, self.tool_data.align_mode));
widgets.push(self.align_popover_menu_widgets(document));

// Flip
let disabled = self.tool_data.selected_layers_count == 0;
Expand All @@ -198,6 +288,19 @@ impl LayoutHolder for SelectTool {

Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets }]))
}

fn send_layout(&self, responses: &mut VecDeque<Message>, layout_target: LayoutTarget, document: &DocumentMessageHandler) {
responses.add(LayoutMessage::SendLayout {
layout: self.layout(document),
layout_target,
});
}
}

impl LayoutHolder for SelectTool {
fn layout(&self) -> Layout {
Layout::WidgetLayout(WidgetLayout::default())
}
}

impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for SelectTool {
Expand All @@ -209,9 +312,9 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionHandlerData<'a>> for SelectT

self.fsm_state.process_event(message, &mut self.tool_data, tool_data, &(), responses, false);

if self.tool_data.pivot.should_refresh_pivot_position() || self.tool_data.selected_layers_changed {
if self.should_update_widgets() || self.tool_data.selected_layers_changed {
// Send the layout containing the updated pivot position (a bit ugly to do it here not in the fsm but that doesn't have SelectTool)
self.send_layout(responses, LayoutTarget::ToolOptions);
self.send_layout(responses, LayoutTarget::ToolOptions, &tool_data.document);
self.tool_data.selected_layers_changed = false;
}
}
Expand Down Expand Up @@ -277,6 +380,10 @@ struct SelectToolData {
selected_layers_changed: bool,
snap_candidates: Vec<SnapCandidatePoint>,
auto_panning: AutoPanning,
align_mode: AlignMode,
align_to_layer_id: u64,
align_artboard_shallow: bool,
align_mode_changed: bool,
}

impl SelectToolData {
Expand Down Expand Up @@ -395,6 +502,23 @@ impl Fsm for SelectToolFsmState {
return self;
};
match (self, event) {
(_, SelectToolMessage::SetAlignMode(new_align_mode)) => {
tool_data.align_mode = new_align_mode;
tool_data.align_mode_changed = true;
self
}
(_, SelectToolMessage::SetArtboardAlignMode(shallow_align)) => {
tool_data.align_mode = AlignMode::Artboard { shallow_align };
tool_data.align_artboard_shallow = shallow_align;
tool_data.align_mode_changed = true;
self
}
(_, SelectToolMessage::SetAlignToLayer(node_id_value)) => {
tool_data.align_mode = AlignMode::Layer(node_id_value);
tool_data.align_to_layer_id = node_id_value;
tool_data.align_mode_changed = true;
self
}
(_, SelectToolMessage::Overlays(mut overlay_context)) => {
tool_data.snap_manager.draw_overlays(SnapData::new(document, input), &mut overlay_context);

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/floating-menus/MenuList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@
{#each currentEntries(section, virtualScrollingEntryHeight, virtualScrollingStartIndex, virtualScrollingEndIndex, search) as entry, entryIndex (entryIndex + startIndex)}
<LayoutRow
class="row"
classes={{ open: isEntryOpen(entry), active: entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
classes={{ open: isEntryOpen(entry), active: entry.value === highlighted?.value && entry.label === highlighted?.label, disabled: Boolean(entry.disabled) }}
styles={{ height: virtualScrollingEntryHeight || "20px" }}
{tooltip}
on:click={() => !entry.disabled && onEntryClick(entry)}
Expand Down