From bc1f63ccc65ca236f9b942215e68d6a9934570a7 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 8 Jun 2026 21:08:16 -0700 Subject: [PATCH] Add Alt+drag layer duplication to the Layers panel (#4216) * Add Alt+drag layer duplication to the Layers panel * Code review --- .../portfolio/document/document_message.rs | 4 + .../document/document_message_handler.rs | 125 ++++++++++++++---- frontend/src/components/panels/Layers.svelte | 10 +- frontend/wrapper/src/editor_wrapper.rs | 12 ++ 4 files changed, 123 insertions(+), 28 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 0adf37244a..22ef7c6385 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -60,6 +60,10 @@ pub enum DocumentMessage { context: OverlayContext, }, DuplicateSelectedLayers, + DuplicateSelectedLayersTo { + parent: LayerNodeIdentifier, + insert_index: usize, + }, EnterNestedNetwork { node_id: NodeId, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index fed421a143..7ffceb9f5a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -449,37 +449,88 @@ impl MessageHandler> for DocumentMes DocumentMessageHandler::get_calculated_insert_index(self.metadata(), &SelectedNodes(vec![layer.to_node()]), parent) }); - for layer in layers.into_iter().rev() { - let Some(parent) = layer.parent(self.metadata()) else { continue }; + for original_layer in layers.into_iter().rev() { + let Some(parent) = original_layer.parent(self.metadata()) else { continue }; + let insert_index = DocumentMessageHandler::get_calculated_insert_index(self.metadata(), &SelectedNodes(vec![original_layer.to_node()]), parent); - // Copy the layer - let mut copy_ids = HashMap::new(); - let node_id = layer.to_node(); - copy_ids.insert(node_id, NodeId(0)); - - self.network_interface - .upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::LayerChildrenUpstreamFlow) - .enumerate() - .for_each(|(index, node_id)| { - copy_ids.insert(node_id, NodeId((index + 1) as u64)); - }); - - let nodes = self.network_interface.copy_nodes(©_ids, &[]).collect::>(); - - let insert_index = DocumentMessageHandler::get_calculated_insert_index(self.metadata(), &SelectedNodes(vec![layer.to_node()]), parent); - - let new_ids: HashMap<_, _> = nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect(); - - let layer_id = *new_ids.get(&NodeId(0)).expect("Node Id 0 should be a layer"); - let layer = LayerNodeIdentifier::new_unchecked(layer_id); - new_dragging.push(layer); - responses.add(NodeGraphMessage::AddNodes { nodes, new_ids }); - responses.add(NodeGraphMessage::MoveLayerToStack { layer, parent, insert_index }); + let Some(new_layer) = self.duplicate_layer(original_layer, responses) else { continue }; + new_dragging.push(new_layer); + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: new_layer, + parent, + insert_index, + }); } let nodes = new_dragging.iter().map(|layer| layer.to_node()).collect(); responses.add(NodeGraphMessage::SelectedNodesSet { nodes }); responses.add(NodeGraphMessage::RunDocumentGraph); } + DocumentMessage::DuplicateSelectedLayersTo { parent, insert_index } => { + if !self.selection_network_path.is_empty() { + log::error!("Duplicating selected layers is only supported for the document network"); + return; + } + + // Mirror the placement constraints enforced when moving layers so a copy can't land somewhere a move couldn't + let any_artboards = self + .network_interface + .selected_nodes() + .selected_layers(self.metadata()) + .any(|layer| self.network_interface.is_artboard(&layer.to_node(), &self.selection_network_path)); + if any_artboards && parent != LayerNodeIdentifier::ROOT_PARENT { + return; + } + + let selected_any_non_artboards = self + .network_interface + .selected_nodes() + .selected_layers(self.metadata()) + .any(|layer| !self.network_interface.is_artboard(&layer.to_node(), &self.selection_network_path)); + let top_level_artboards = LayerNodeIdentifier::ROOT_PARENT + .children(self.metadata()) + .any(|layer| self.network_interface.is_artboard(&layer.to_node(), &self.selection_network_path)); + if selected_any_non_artboards && parent == LayerNodeIdentifier::ROOT_PARENT && top_level_artboards { + return; + } + + let layers_to_duplicate = self.network_interface.shallowest_unique_layers_sorted(&self.selection_network_path); + if layers_to_duplicate.is_empty() { + return; + } + + responses.add(DocumentMessage::AddTransaction); + + let mut new_layers = Vec::new(); + for layer in layers_to_duplicate { + let Some(new_layer) = self.duplicate_layer(layer, responses) else { continue }; + + // Insert each copy one slot below the previous so the duplicates keep their original top-to-bottom order + let placement_index = insert_index + new_layers.len(); + new_layers.push(new_layer); + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: new_layer, + parent, + insert_index: placement_index, + }); + + // Compensate the local transform so a copy dropped into a differently-transformed parent stays put in world space + if layer.parent(self.metadata()) != Some(parent) { + let layer_world_transform = self.network_interface.document_metadata().transform_to_viewport(layer); + let undo_parent_transform = self.network_interface.document_metadata().transform_to_viewport(parent).inverse(); + + responses.add(GraphOperationMessage::TransformSet { + layer: new_layer, + transform: undo_parent_transform * layer_world_transform, + transform_in: TransformIn::Local, + skip_rerender: false, + }); + } + } + + let nodes = new_layers.iter().map(|layer| layer.to_node()).collect(); + responses.add(NodeGraphMessage::SelectedNodesSet { nodes }); + responses.add(NodeGraphMessage::RunDocumentGraph); + } DocumentMessage::EnterNestedNetwork { node_id } => { self.breadcrumb_network_path.push(node_id); self.selection_network_path.clone_from(&self.breadcrumb_network_path); @@ -2218,6 +2269,30 @@ impl DocumentMessageHandler { .unwrap_or(0) } + /// Copies `layer` together with its full upstream node chain, queueing the new nodes with freshly minted IDs. + /// Returns the new layer's identifier; the caller places it into the stack with a `MoveLayerToStack` response. + fn duplicate_layer(&mut self, layer: LayerNodeIdentifier, responses: &mut VecDeque) -> Option { + let mut copy_ids = HashMap::new(); + copy_ids.insert(layer.to_node(), NodeId(0)); + self.network_interface + .upstream_flow_back_from_nodes(vec![layer.to_node()], &[], FlowType::LayerChildrenUpstreamFlow) + .enumerate() + .for_each(|(index, node_id)| { + copy_ids.insert(node_id, NodeId((index + 1) as u64)); + }); + + let nodes = self.network_interface.copy_nodes(©_ids, &[]).collect::>(); + let new_ids: HashMap<_, _> = nodes.iter().map(|(id, _)| (*id, NodeId::new())).collect(); + + let Some(&new_layer_id) = new_ids.get(&NodeId(0)) else { + log::error!("Could not duplicate layer because its root node copy is missing"); + return None; + }; + responses.add(NodeGraphMessage::AddNodes { nodes, new_ids }); + + Some(LayerNodeIdentifier::new_unchecked(new_layer_id)) + } + pub fn group_layers( responses: &mut VecDeque, insert_index: usize, diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 30c396a81c..5b5a393dcc 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -349,6 +349,7 @@ if (distance > DRAG_THRESHOLD) { internalDragState.active = true; dragInPanel = true; + layerToClipUponClick = undefined; const layer = internalDragState.listing.entry; if (!$nodeGraph.selected.includes(layer.id)) { @@ -381,7 +382,7 @@ } } - function draggingPointerUp() { + function draggingPointerUp(e: PointerEvent) { if (internalDragState?.active && dragDropTarget) { // Ensure the dragged layer is part of the selection, matching the move-in-tree behavior if (!$nodeGraph.selected.includes(internalDragState.layerId)) selectLayer(internalDragState.listing, false, false); @@ -393,9 +394,12 @@ } else if (internalDragState?.active && draggingData) { const { select, insertParentId, insertIndex } = draggingData; - // Commit the move + // Ensure the dragged layer is part of the selection before committing select?.(); - editor.moveLayerInTree(insertParentId, insertIndex); + + // Holding Alt drops a duplicate of the selection at the target instead of moving the originals + if (e.altKey) editor.duplicateLayerInTree(insertParentId, insertIndex); + else editor.moveLayerInTree(insertParentId, insertIndex); // Prevent the subsequent click event from processing justFinishedDrag = true; diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index ad83b8f563..451b08c07e 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -756,6 +756,18 @@ impl EditorWrapper { self.dispatch(message); } + /// Duplicate the selected layers, placing the copies within the given folder at the given index. + /// If the folder is `None`, they are inserted into the document root. + /// If the insert index is `None`, they are inserted at the start of the folder. + #[wasm_bindgen(js_name = duplicateLayerInTree)] + pub fn duplicate_layer_in_tree(&self, insert_parent_id: Option, insert_index: Option) { + let message = DocumentMessage::DuplicateSelectedLayersTo { + parent: insert_parent_id.map(NodeId).map(LayerNodeIdentifier::new_unchecked).unwrap_or_default(), + insert_index: insert_index.unwrap_or_default(), + }; + self.dispatch(message); + } + /// Set the name for the layer #[wasm_bindgen(js_name = setLayerName)] pub fn set_layer_name(&self, id: u64, name: String) {