Add Alt+drag layer duplication to the Layers panel (#4216)

* Add Alt+drag layer duplication to the Layers panel

* Code review
This commit is contained in:
Keavon Chambers
2026-06-08 21:08:16 -07:00
committed by GitHub
parent 04bca4a877
commit bc1f63ccc6
4 changed files with 123 additions and 28 deletions

View File

@@ -60,6 +60,10 @@ pub enum DocumentMessage {
context: OverlayContext,
},
DuplicateSelectedLayers,
DuplicateSelectedLayersTo {
parent: LayerNodeIdentifier,
insert_index: usize,
},
EnterNestedNetwork {
node_id: NodeId,
},

View File

@@ -449,37 +449,88 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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(&copy_ids, &[]).collect::<Vec<(NodeId, NodeTemplate)>>();
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<Message>) -> Option<LayerNodeIdentifier> {
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(&copy_ids, &[]).collect::<Vec<(NodeId, NodeTemplate)>>();
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<Message>,
insert_index: usize,

View File

@@ -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;

View File

@@ -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<u64>, insert_index: Option<usize>) {
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) {