diff --git a/editor/src/messages/portfolio/document/utility_types/transformation.rs b/editor/src/messages/portfolio/document/utility_types/transformation.rs index 6ccd9f83f0..6ce97b9c3c 100644 --- a/editor/src/messages/portfolio/document/utility_types/transformation.rs +++ b/editor/src/messages/portfolio/document/utility_types/transformation.rs @@ -508,8 +508,8 @@ impl<'a> Selected<'a> { tool_type: &'a ToolType, pen_handle: Option<&'a mut DVec2>, ) -> Self { - // If user is using the Select tool or Shape tool then use the original layer transforms - if (*tool_type == ToolType::Select || *tool_type == ToolType::Shape) && (*original_transforms == OriginalTransforms::Path(HashMap::new())) { + // For Select, Shape, and Artboard tools, switch to layer-based transforms if currently initialized as empty path transforms + if (*tool_type == ToolType::Select || *tool_type == ToolType::Shape || *tool_type == ToolType::Artboard) && (*original_transforms == OriginalTransforms::Path(HashMap::new())) { *original_transforms = OriginalTransforms::Layer(HashMap::new()); } @@ -630,6 +630,10 @@ impl<'a> Selected<'a> { // TODO: Cache the result of `shallowest_unique_layers` to avoid this heavy computation every frame of movement, see https://github.com/GraphiteEditor/Graphite/pull/481 for layer in self.network_interface.shallowest_unique_layers(&[]) { + // Artboards are resized via ResizeArtboard messages in the transform layer handler + if *self.tool_type == ToolType::Artboard && self.network_interface.is_artboard(&layer.to_node(), &[]) { + continue; + } match &mut self.original_transforms { OriginalTransforms::Layer(layer_transforms) => Self::transform_layer(self.network_interface.document_metadata(), layer, layer_transforms.get(&layer), transformation, self.responses), OriginalTransforms::Path(path_transforms) => { diff --git a/editor/src/messages/tool/tool_messages/artboard_tool.rs b/editor/src/messages/tool/tool_messages/artboard_tool.rs index fa06962f00..3d75eabf7e 100644 --- a/editor/src/messages/tool/tool_messages/artboard_tool.rs +++ b/editor/src/messages/tool/tool_messages/artboard_tool.rs @@ -587,6 +587,7 @@ impl Fsm for ArtboardToolFsmState { HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Draw Artboard")]), HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Move Artboard")]), HintGroup(vec![HintInfo::keys([Key::Backspace], "Delete Artboard")]), + HintGroup(vec![HintInfo::multi_keys([[Key::KeyG], [Key::KeyS]], "Grab/Scale Selected")]), ]), ArtboardToolFsmState::Dragging => HintData(vec![ HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()]), diff --git a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs index 5c784b556e..dd54e925fe 100644 --- a/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs +++ b/editor/src/messages/tool/transform_layer/transform_layer_message_handler.rs @@ -11,7 +11,7 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::tool_messages::select_tool; use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::utility_types::{ToolData, ToolType}; -use glam::{DAffine2, DVec2}; +use glam::{DAffine2, DVec2, IVec2}; use graphene_std::renderer::Quad; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::ManipulatorPointId; @@ -94,6 +94,8 @@ pub struct TransformLayerMessageHandler { // Path tool (ghost outlines showing pre-transform geometry) ghost_outline: Vec<(Vec, DAffine2)>, + + original_artboard_bounds: HashMap, } #[message_handler_data] @@ -111,15 +113,21 @@ impl MessageHandler> for let using_select_tool = tool_data.active_tool_type == ToolType::Select; let using_pen_tool = tool_data.active_tool_type == ToolType::Pen; let using_shape_tool = tool_data.active_tool_type == ToolType::Shape; + let using_artboard_tool = tool_data.active_tool_type == ToolType::Artboard; // TODO: Add support for transforming layer not in the document network - let selected_layers = document + let mut selected_layers = document .network_interface .selected_nodes() .selected_layers(document.metadata()) .filter(|&layer| document.network_interface.is_visible(&layer.to_node(), &[]) && !document.network_interface.is_locked(&layer.to_node(), &[])) .collect::>(); + // Ensure only artboard layers are transformed when using the Artboard tool + if using_artboard_tool { + selected_layers.retain(|layer| document.network_interface.is_artboard(&layer.to_node(), &[])); + } + let mut selected = Selected::new( &mut self.original_transforms, &mut self.pivot, @@ -135,6 +143,9 @@ impl MessageHandler> for let mut begin_operation = |operation: TransformOperation, typing: &mut Typing, mouse_position: &mut DVec2, start_mouse: &mut DVec2, transform: &mut DAffine2| { if operation != TransformOperation::None { selected.revert_operation(); + if using_artboard_tool { + Self::revert_artboards_to_original_bounds(&self.original_artboard_bounds, selected.responses); + } typing.clear(); } @@ -199,6 +210,8 @@ impl MessageHandler> for selected.responses.add(DocumentMessage::StartTransaction); }; + let mut update_artboard_transform = false; + match message { // Overlays TransformLayerMessage::Overlays { context: mut overlay_context } => { @@ -312,6 +325,7 @@ impl MessageHandler> for if final_transform { self.transform_operation = TransformOperation::None; self.operation_count = 0; + self.original_artboard_bounds.clear(); } if using_pen_tool { @@ -390,13 +404,33 @@ impl MessageHandler> for }); } TransformLayerMessage::BeginGRS { operation: transform_type } => { + // Artboards don't support rotation yet + if using_artboard_tool && transform_type == TransformType::Rotate { + return; + } + if using_artboard_tool && selected_layers.is_empty() { + return; + } + let chain_operation = self.transform_operation != TransformOperation::None; + // Prepare artboard bounds + if using_artboard_tool && !chain_operation { + self.original_artboard_bounds.clear(); + for &layer in &selected_layers { + if !document.network_interface.is_artboard(&layer.to_node(), &[]) { + continue; + } + if let Some(bounds) = document.metadata().bounding_box_document(layer) { + self.original_artboard_bounds.insert(layer, bounds); + } + } + } let selected_points: Vec<&ManipulatorPointId> = shape_editor.selected_points().collect(); let selected_segments = shape_editor.selected_segments().collect::>(); if using_path_tool { Self::set_ghost_outline(&mut self.ghost_outline, shape_editor, document); if (selected_points.is_empty() && selected_segments.is_empty()) - || (!using_path_tool && !using_select_tool && !using_pen_tool && !using_shape_tool) + || (!using_path_tool && !using_select_tool && !using_pen_tool && !using_shape_tool && !using_artboard_tool) || selected_layers.is_empty() || (transform_type.equivalent_to(self.transform_operation) && !self.was_grabbing) { @@ -463,7 +497,6 @@ impl MessageHandler> for self.state.is_transforming_in_local_space = false; self.operation_count += 1; - let chain_operation = self.transform_operation != TransformOperation::None; if chain_operation { responses.add(TransformLayerMessage::ApplyTransformOperation { final_transform: false }); } else { @@ -497,6 +530,7 @@ impl MessageHandler> for responses.add(ToolMessage::UpdateHints); } else { selected.original_transforms.clear(); + self.original_artboard_bounds.clear(); self.typing.clear(); self.transform_operation = TransformOperation::None; @@ -513,10 +547,12 @@ impl MessageHandler> for TransformLayerMessage::ConstrainX => { self.state.is_transforming_in_local_space = self.transform_operation.constrain_axis(Axis::X, &mut selected, &self.state, document); self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); + update_artboard_transform = true; } TransformLayerMessage::ConstrainY => { self.state.is_transforming_in_local_space = self.transform_operation.constrain_axis(Axis::Y, &mut selected, &self.state, document); self.transform_operation.grs_typed(self.typing.evaluate(), &mut selected, &self.state, document); + update_artboard_transform = true; } TransformLayerMessage::PointerMove { slow_key, increments_key } => { self.slow = input.keyboard.get(slow_key as usize); @@ -579,6 +615,7 @@ impl MessageHandler> for } }; } + update_artboard_transform = self.transform_operation != TransformOperation::None; self.mouse_position = input.mouse.position; } @@ -592,27 +629,35 @@ impl MessageHandler> for self.typing.type_negate(); } self.transform_operation.grs_typed(self.typing.type_backspace(), &mut selected, &self.state, document); + update_artboard_transform = true; } TransformLayerMessage::TypeDecimalPoint => { if self.transform_operation.can_begin_typing() { - self.transform_operation.grs_typed(self.typing.type_decimal_point(), &mut selected, &self.state, document) + self.transform_operation.grs_typed(self.typing.type_decimal_point(), &mut selected, &self.state, document); + update_artboard_transform = true; } } TransformLayerMessage::TypeDigit { digit } => { if self.transform_operation.can_begin_typing() { - self.transform_operation.grs_typed(self.typing.type_number(digit), &mut selected, &self.state, document) + self.transform_operation.grs_typed(self.typing.type_number(digit), &mut selected, &self.state, document); + update_artboard_transform = true; } } TransformLayerMessage::TypeNegate => { if self.typing.digits.is_empty() { self.transform_operation.negate(&mut selected, &self.state, document); } - self.transform_operation.grs_typed(self.typing.type_negate(), &mut selected, &self.state, document) + self.transform_operation.grs_typed(self.typing.type_negate(), &mut selected, &self.state, document); + update_artboard_transform = true; } TransformLayerMessage::SetPivotGizmo { pivot_gizmo } => { self.pivot_gizmo = pivot_gizmo; } } + + if using_artboard_tool && update_artboard_transform { + Self::apply_artboard_bounds_transform(self.transform_operation, &self.state, &self.original_artboard_bounds, document, responses); + } } fn actions(&self) -> ActionList { @@ -642,6 +687,49 @@ impl MessageHandler> for } impl TransformLayerMessageHandler { + fn apply_artboard_bounds_transform( + transform_operation: TransformOperation, + state: &TransformationState, + original_artboard_bounds: &HashMap, + document: &DocumentMessageHandler, + responses: &mut VecDeque, + ) { + if original_artboard_bounds.is_empty() || transform_operation == TransformOperation::None { + return; + } + + // Build the transform chain + let inner = match transform_operation { + TransformOperation::Grabbing(translation) => DAffine2::from_translation(translation.to_dvec(state, document)), + TransformOperation::Scaling(scale) => DAffine2::from_scale(scale.to_dvec(state.is_rounded_to_intervals)), + _ => DAffine2::IDENTITY, + }; + let normalized_transform = state.local_to_viewport_transform(); + let local_viewport_transform = normalized_transform * inner * normalized_transform.inverse(); + let pivot_translation = DAffine2::from_translation(state.pivot_viewport(document)); + let viewport_transform = pivot_translation * local_viewport_transform * pivot_translation.inverse(); + let document_to_viewport = document.metadata().document_to_viewport; + let document_transform = document_to_viewport.inverse() * viewport_transform * document_to_viewport; + + // Apply transform to each artboard and send resize messages + for (&layer, &original_bounds) in original_artboard_bounds { + let new_top_left = document_transform.transform_point2(original_bounds[0]); + let new_bottom_right = document_transform.transform_point2(original_bounds[1]); + let location = new_top_left.min(new_bottom_right).round().as_ivec2(); + let dimensions = (new_bottom_right - new_top_left).abs().round().as_ivec2().max(IVec2::ONE); + responses.add(GraphOperationMessage::ResizeArtboard { layer, location, dimensions }); + } + } + + // Reset artboards to their original bounds + fn revert_artboards_to_original_bounds(original_artboard_bounds: &HashMap, responses: &mut VecDeque) { + for (&layer, &original_bounds) in original_artboard_bounds { + let location = original_bounds[0].min(original_bounds[1]).round().as_ivec2(); + let dimensions = (original_bounds[1] - original_bounds[0]).abs().round().as_ivec2().max(IVec2::ONE); + responses.add(GraphOperationMessage::ResizeArtboard { layer, location, dimensions }); + } + } + pub fn is_transforming(&self) -> bool { self.transform_operation != TransformOperation::None } @@ -1288,4 +1376,89 @@ mod test_transform_layer { let final_child_transform = get_layer_transform(&mut editor, child_layer_id).await.unwrap(); assert!(!final_child_transform.abs_diff_eq(original_child_transform, 1e-5), "Child layer inside transformed group should change"); } + + #[tokio::test] + async fn test_artboard_gs_operations() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + // Create an artboard using the Artboard tool (mirroring existing artboard tests) + editor.drag_tool(ToolType::Artboard, 0., 0., 100., 100., ModifierKeys::empty()).await; + editor.handle_message(ToolMessage::ActivateTool { tool_type: ToolType::Artboard }).await; + let artboard = { + let document = editor.active_document(); + document.metadata().all_layers().next().unwrap() + }; + editor.handle_message(NodeGraphMessage::SelectedNodesSet { nodes: vec![artboard.to_node()] }).await; + + // Test 1: Grab operation + let document = editor.active_document(); + let original_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + editor.handle_message(TransformLayerMessage::BeginGrab).await; + editor.move_mouse(50., 50., ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let document = editor.active_document(); + let grab_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let moved = (grab_bounds[0] - original_bounds[0]).length() > 1.; + assert!(moved, "Artboard should move after grab operation"); + + // Test 2: Scale operation + let scale_original_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let original_size = (scale_original_bounds[1] - scale_original_bounds[0]).length(); + + // Move the mouse to the same side of the pivot that scaling will continue from + editor.move_mouse(150., 150., ModifierKeys::empty(), MouseKeys::NONE).await; + editor.handle_message(TransformLayerMessage::BeginScale).await; + editor.move_mouse(200., 200., ModifierKeys::empty(), MouseKeys::NONE).await; + editor + .handle_message(TransformLayerMessage::PointerMove { + slow_key: Key::Shift, + increments_key: Key::Control, + }) + .await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let document = editor.active_document(); + let scale_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let final_size = (scale_bounds[1] - scale_bounds[0]).length(); + assert!(final_size > original_size, "Artboard should be larger after scale operation"); + + // Test 3: Typed scale input + let typed_original_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let typed_original_width = typed_original_bounds[1].x - typed_original_bounds[0].x; + + editor.handle_message(TransformLayerMessage::BeginScale).await; + editor.handle_message(TransformLayerMessage::TypeDigit { digit: 2 }).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let document = editor.active_document(); + let typed_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let typed_final_width = typed_bounds[1].x - typed_bounds[0].x; + let scale_factor = typed_final_width / typed_original_width; + assert!((scale_factor - 2.).abs() < 0.1, "Artboard should scale by 2x with typed input"); + + // Test 4: Grab with X constraint + let constrain_original_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let constrain_original_x = constrain_original_bounds[0].x; + let constrain_original_y = constrain_original_bounds[0].y; + + editor.handle_message(TransformLayerMessage::BeginGrab).await; + editor.move_mouse(50., 50., ModifierKeys::empty(), MouseKeys::NONE).await; + editor.handle_message(TransformLayerMessage::ConstrainX).await; + editor.handle_message(TransformLayerMessage::ApplyTransformOperation { final_transform: true }).await; + + let document = editor.active_document(); + let constrain_bounds = document.metadata().bounding_box_document(artboard).unwrap(); + let constrain_final_x = constrain_bounds[0].x; + let constrain_final_y = constrain_bounds[0].y; + assert!(constrain_final_x != constrain_original_x, "Artboard X should change with X constraint"); + assert!((constrain_final_y - constrain_original_y).abs() < 1., "Artboard Y should stay roughly the same with X constraint"); + } }