diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index babe195b5f..dcd528c9e4 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -197,7 +197,7 @@ pub enum DocumentMessage { undo_count: usize, }, ToggleLayerExpansion { - id: NodeId, + instance_path: Vec, recursive: bool, }, ToggleSelectedVisibility, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 68b79a8d7f..f093a90999 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -42,6 +42,7 @@ use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; use graphene_std::vector::misc::dvec2_to_point; use graphene_std::vector::style::RenderMode; use kurbo::{Affine, BezPath, Line, PathSeg}; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -84,8 +85,8 @@ pub struct DocumentMessageHandler { // // Contains the NodeNetwork and acts an an interface to manipulate the NodeNetwork with custom setters in order to keep NetworkMetadata in sync pub network_interface: NodeNetworkInterface, - /// List of the [`LayerNodeIdentifier`]s that are currently collapsed by the user in the Layers panel. - /// Collapsed means that the expansion arrow isn't set to show the children of these layers. + /// Tracks which layer instances are collapsed in the Layers panel, keyed by instance path. + #[serde(deserialize_with = "deserialize_collapsed_layers", default)] pub collapsed: CollapsedLayers, /// The full Git commit hash of the Graphite repository that was used to build the editor. /// We save this to provide a hint about which version of the editor was used to create the document. @@ -317,7 +318,7 @@ impl MessageHandler> for DocumentMes DocumentMessage::ClearLayersPanel => { // Send an empty layer list if layers_panel_open { - let layer_structure = Self::default().build_layer_structure(LayerNodeIdentifier::ROOT_PARENT); + let layer_structure = Self::default().build_layer_structure(); responses.add(FrontendMessage::UpdateDocumentLayerStructure { layer_structure }); } @@ -380,7 +381,7 @@ impl MessageHandler> for DocumentMes DocumentMessage::DocumentStructureChanged => { if layers_panel_open { self.network_interface.load_structure(); - let layer_structure = self.build_layer_structure(LayerNodeIdentifier::ROOT_PARENT); + let layer_structure = self.build_layer_structure(); self.update_layers_panel_control_bar_widgets(layers_panel_open, responses); self.update_layers_panel_bottom_bar_widgets(layers_panel_open, responses); @@ -1167,25 +1168,27 @@ impl MessageHandler> for DocumentMes responses.add(OverlaysMessage::Draw); responses.add(PortfolioMessage::UpdateOpenDocumentsList); } - DocumentMessage::ToggleLayerExpansion { id, recursive } => { - let layer = LayerNodeIdentifier::new(id, &self.network_interface); - let metadata = self.metadata(); - - let is_collapsed = self.collapsed.0.contains(&layer); + DocumentMessage::ToggleLayerExpansion { instance_path, recursive } => { + let is_collapsed = self.collapsed.0.contains(&instance_path); if is_collapsed { if recursive { - let children: HashSet<_> = layer.descendants(metadata).collect(); - self.collapsed.0.retain(|collapsed_layer| !children.contains(collapsed_layer) && collapsed_layer != &layer); + // Remove this path and all descendant paths (paths that start with this one) + self.collapsed.0.retain(|path| !path.starts_with(&instance_path)); } else { - self.collapsed.0.retain(|collapsed_layer| collapsed_layer != &layer); + self.collapsed.0.retain(|path| *path != instance_path); } } else { if recursive { - let children_to_add: Vec<_> = layer.descendants(metadata).filter(|child| !self.collapsed.0.contains(child)).collect(); - self.collapsed.0.extend(children_to_add); + // Collapse all expanded descendant instances by collecting their paths from the structure tree + let descendant_paths = self.collect_descendant_instance_paths(&instance_path); + for path in descendant_paths { + if !self.collapsed.0.contains(&path) { + self.collapsed.0.push(path); + } + } } - self.collapsed.0.push(layer); + self.collapsed.0.push(instance_path); } responses.add(NodeGraphMessage::SendGraph); @@ -1740,22 +1743,218 @@ impl DocumentMessageHandler { Ok(document_message_handler) } - /// Recursively builds the layer structure tree for a folder. - fn build_layer_structure(&self, folder: LayerNodeIdentifier) -> Vec { - folder - .children(self.metadata()) - .map(|layer_node| { - let children = if layer_node.has_children(self.metadata()) && !self.collapsed.0.contains(&layer_node) { - self.build_layer_structure(layer_node) - } else { - Vec::new() - }; - LayerStructureEntry { - layer_id: layer_node.to_node(), - children, + /// Builds the layer structure tree by traversing the node graph directly. + /// Unlike the canonical `structure` field of [`DocumentMetadata`] (which stores single-parent relationships), this allows + /// the same layer to appear under multiple parents when the graph feeds the same child content into separate parent layers. + fn build_layer_structure(&self) -> Vec { + let network = &self.network_interface; + + let Some(root_node) = network.root_node(&[]) else { return Vec::new() }; + let Some(first_root_layer_id) = network + .upstream_flow_back_from_nodes(vec![root_node.node_id], &[], FlowType::PrimaryFlow) + .find(|node_id| network.is_layer(node_id, &[])) + else { + return Vec::new(); + }; + + let selected_layers: HashSet = network.selected_nodes().selected_layers(self.metadata()).map(LayerNodeIdentifier::to_node).collect(); + + let ancestors = HashSet::new(); + let instance_path = Vec::new(); + let mut root_entries = Vec::new(); + + // The first root layer is the topmost entry + root_entries.push(self.build_layer_entry(first_root_layer_id, &ancestors, &selected_layers, &instance_path)); + + // Layers in the primary flow (input[0] chain) from the first root layer are root-level siblings + let mut root_ancestors = HashSet::new(); + root_ancestors.insert(first_root_layer_id); + + for sibling_id in network.upstream_flow_back_from_nodes(vec![first_root_layer_id], &[], FlowType::PrimaryFlow).skip(1) { + if network.is_layer(&sibling_id, &[]) && !root_ancestors.contains(&sibling_id) { + root_entries.push(self.build_layer_entry(sibling_id, &root_ancestors, &selected_layers, &instance_path)); + } + } + + root_entries + } + + /// Builds a single `LayerStructureEntry` for the given layer, including its `children_present` flag, + /// `descendant_selected` flag, and (if expanded) its children collected from the graph. + fn build_layer_entry(&self, layer_id: NodeId, ancestors: &HashSet, selected_layers: &HashSet, parent_instance_path: &[NodeId]) -> LayerStructureEntry { + let mut instance_path = parent_instance_path.to_vec(); + instance_path.push(layer_id); + + let mut child_ancestors = ancestors.clone(); + child_ancestors.insert(layer_id); + + let children_present = self.has_layer_children_in_graph(layer_id, &child_ancestors); + + let collapsed = self.collapsed.0.contains(&instance_path); + + let children = if children_present && !collapsed { + self.collect_layer_children(layer_id, &child_ancestors, selected_layers, &instance_path) + } else { + Vec::new() + }; + + // Compute whether any descendant is selected (checking expanded children and, if collapsed, via graph traversal) + let descendant_selected = if !children.is_empty() { + children.iter().any(|child| child.descendant_selected || selected_layers.contains(&child.layer_id)) + } else if children_present { + // Layer is collapsed but has children, so check via graph traversal + self.has_selected_descendant_in_graph(layer_id, &child_ancestors, selected_layers) + } else { + false + }; + + LayerStructureEntry { + layer_id, + children, + children_present, + descendant_selected, + } + } + + /// Checks whether a layer has any child layers reachable via horizontal flow in the graph. + fn has_layer_children_in_graph(&self, layer_id: NodeId, child_ancestors: &HashSet) -> bool { + let network = &self.network_interface; + + network + .upstream_flow_back_from_nodes(vec![layer_id], &[], FlowType::HorizontalFlow) + .skip(1) + .any(|id| network.is_layer(&id, &[]) && !child_ancestors.contains(&id)) + } + + /// Checks whether any descendant layer in the graph (via horizontal + primary flow) is selected. + /// Used when a layer is collapsed to determine if the ancestor-of-selected indicator should show. + fn has_selected_descendant_in_graph(&self, layer_id: NodeId, ancestors: &HashSet, selected_layers: &HashSet) -> bool { + let network = &self.network_interface; + + // Find child layers via horizontal flow + let mut stack: Vec = network + .upstream_flow_back_from_nodes(vec![layer_id], &[], FlowType::HorizontalFlow) + .skip(1) + .filter(|node_id| network.is_layer(node_id, &[]) && !ancestors.contains(node_id)) + .collect(); + + let mut visited = ancestors.clone(); + + // Iteratively explore all descendant layers via a depth-first traversal + while let Some(current_id) = stack.pop() { + // Skip already-visited layers to avoid infinite loops from graph cycles + if !visited.insert(current_id) { + continue; + } + + // Found a selected descendant, the ancestor indicator should be shown + if selected_layers.contains(¤t_id) { + return true; + } + + // Check this layer's children via horizontal flow + for node_id in network.upstream_flow_back_from_nodes(vec![current_id], &[], FlowType::HorizontalFlow).skip(1) { + if network.is_layer(&node_id, &[]) && !visited.contains(&node_id) { + stack.push(node_id); } - }) - .collect() + } + + // Check stacked siblings via primary flow + for node_id in network.upstream_flow_back_from_nodes(vec![current_id], &[], FlowType::PrimaryFlow).skip(1) { + if network.is_layer(&node_id, &[]) && !visited.contains(&node_id) { + stack.push(node_id); + } + } + } + + false + } + + /// Collects the child entries for a given layer by traversing its horizontal and primary flows. + /// The horizontal flow (a layer's secondary input chain) finds nested content layers, and the + /// primary flow from those (their stack's top output) finds stacked siblings at the same depth. + /// `ancestors` contains layer IDs in the current path from root, used for cycle prevention. + fn collect_layer_children(&self, layer_id: NodeId, ancestors: &HashSet, selected_layers: &HashSet, instance_path: &[NodeId]) -> Vec { + let network = &self.network_interface; + + // Find the first nested layer via horizontal flow (content inside this layer) + let Some(nested_id) = network + .upstream_flow_back_from_nodes(vec![layer_id], &[], FlowType::HorizontalFlow) + .skip(1) + .find(|id| network.is_layer(id, &[])) + else { + return Vec::new(); + }; + + // Cycle detected, this layer is already an ancestor in the current branch + if ancestors.contains(&nested_id) { + return Vec::new(); + } + + // The nested layer is the first child at this depth level + let mut children = vec![self.build_layer_entry(nested_id, ancestors, selected_layers, instance_path)]; + + // Primary flow from the nested layer finds stacked siblings (more children of this layer) + for sibling_id in network.upstream_flow_back_from_nodes(vec![nested_id], &[], FlowType::PrimaryFlow).skip(1) { + if network.is_layer(&sibling_id, &[]) && !ancestors.contains(&sibling_id) { + children.push(self.build_layer_entry(sibling_id, ancestors, selected_layers, instance_path)); + } + } + + children + } + + /// Collects instance paths for all descendant layers of the given instance path by traversing the graph. + /// Used for recursive collapse to find all expandable descendants. + fn collect_descendant_instance_paths(&self, instance_path: &[NodeId]) -> Vec> { + let Some(&layer_id) = instance_path.last() else { return Vec::new() }; + let network = &self.network_interface; + + let mut paths = Vec::new(); + let mut stack: Vec<(NodeId, Vec)> = Vec::new(); + + // Seed with child layers via horizontal flow + for node_id in network.upstream_flow_back_from_nodes(vec![layer_id], &[], FlowType::HorizontalFlow).skip(1) { + if network.is_layer(&node_id, &[]) { + let mut child_path = instance_path.to_vec(); + child_path.push(node_id); + stack.push((node_id, child_path)); + } + } + + let mut visited = HashSet::new(); + + // Depth-first traversal collecting all unique descendant instance paths + while let Some((current_id, current_path)) = stack.pop() { + // Skip paths we've already visited to prevent cycles + if !visited.insert(current_path.clone()) { + continue; + } + + // Record this descendant's instance path for collapsing + paths.push(current_path.clone()); + + // Add nested content layers found via horizontal flow + for node_id in network.upstream_flow_back_from_nodes(vec![current_id], &[], FlowType::HorizontalFlow).skip(1) { + if network.is_layer(&node_id, &[]) { + let mut child_path = current_path.clone(); + child_path.push(node_id); + stack.push((node_id, child_path)); + } + } + + // Add stacked sibling layers found via primary flow + for node_id in network.upstream_flow_back_from_nodes(vec![current_id], &[], FlowType::PrimaryFlow).skip(1) { + if network.is_layer(&node_id, &[]) { + // Siblings share the same parent path (everything up to the last element of current_path) + let mut sibling_path = current_path[..current_path.len() - 1].to_vec(); + sibling_path.push(node_id); + stack.push((node_id, sibling_path)); + } + } + } + + paths } pub fn undo_with_history(&mut self, viewport: &ViewportMessageHandler, responses: &mut VecDeque) { @@ -3221,6 +3420,16 @@ impl Iterator for ClickXRayIter<'_> { } } +/// Deserializes `CollapsedLayers` with backwards compatibility for the old format +/// (flat list of layer node IDs) by consuming the entire value first, then attempting +/// to interpret it as the new format. Falls back to an empty default for old documents. +fn deserialize_collapsed_layers<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + use serde::Deserialize; + // Buffer the entire value to avoid leaving the deserializer in a bad state on type mismatch + let value = serde_json::Value::deserialize(deserializer)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + #[cfg(test)] mod document_message_handler_tests { use super::*; diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs index b7c21321bb..0d8a4239d1 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs @@ -189,7 +189,7 @@ impl<'a> MessageHandler> for NodeG send: Box::new(NodeGraphMessage::SelectedNodesUpdated.into()), }); network_interface.load_structure(); - collapsed.0.retain(|&layer| network_interface.document_metadata().layer_exists(layer)); + collapsed.0.retain(|path| path.iter().all(|&node_id| network_interface.document_network().nodes.contains_key(&node_id))); } NodeGraphMessage::SelectedNodesUpdated => { let selected_layers = network_interface.selected_nodes().selected_layers(network_interface.document_metadata()).collect::>(); @@ -2047,7 +2047,7 @@ impl<'a> MessageHandler> for NodeG } NodeGraphMessage::UpdateLayerPanel => { - Self::update_layer_panel(network_interface, selection_network_path, collapsed, layers_panel_open, responses); + Self::update_layer_panel(network_interface, selection_network_path, layers_panel_open, responses); } NodeGraphMessage::UpdateEdges => { // Update the import/export UI edges whenever the PTZ changes or the bounding box of all nodes changes @@ -2684,7 +2684,7 @@ impl NodeGraphMessageHandler { Some(NodeGraphErrorDiagnostic { position, error }) } - fn update_layer_panel(network_interface: &NodeNetworkInterface, selection_network_path: &[NodeId], collapsed: &CollapsedLayers, layers_panel_open: bool, responses: &mut VecDeque) { + fn update_layer_panel(network_interface: &NodeNetworkInterface, selection_network_path: &[NodeId], layers_panel_open: bool, responses: &mut VecDeque) { if !layers_panel_open { return; } @@ -2695,14 +2695,8 @@ impl NodeGraphMessageHandler { .map(|layer| layer.to_node()) .collect::>(); - let mut ancestors_of_selected = HashSet::new(); let mut descendants_of_selected = HashSet::new(); for selected_layer in &selected_layers { - for ancestor in LayerNodeIdentifier::new(*selected_layer, network_interface).ancestors(network_interface.document_metadata()) { - if ancestor != LayerNodeIdentifier::ROOT_PARENT && ancestor.to_node() != *selected_layer { - ancestors_of_selected.insert(ancestor.to_node()); - } - } for descendant in LayerNodeIdentifier::new(*selected_layer, network_interface).descendants(network_interface.document_metadata()) { descendants_of_selected.insert(descendant.to_node()); } @@ -2727,22 +2721,6 @@ impl NodeGraphMessageHandler { })) ); - let parents_visible = layer.ancestors(network_interface.document_metadata()).filter(|&ancestor| ancestor != layer).all(|layer| { - if layer != LayerNodeIdentifier::ROOT_PARENT { - network_interface.document_node(&layer.to_node(), &[]).map(|node| node.visible).unwrap_or_default() - } else { - true - } - }); - - let parents_unlocked: bool = layer.ancestors(network_interface.document_metadata()).filter(|&ancestor| ancestor != layer).all(|layer| { - if layer != LayerNodeIdentifier::ROOT_PARENT { - !network_interface.is_locked(&layer.to_node(), &[]) - } else { - true - } - }); - let clippable = layer.can_be_clipped(network_interface.document_metadata()); let data = LayerPanelEntry { @@ -2752,18 +2730,9 @@ impl NodeGraphMessageHandler { alias: network_interface.display_name(&node_id, &[]), in_selected_network: selection_network_path.is_empty(), children_allowed, - children_present: layer.has_children(network_interface.document_metadata()), - expanded: layer.has_children(network_interface.document_metadata()) && !collapsed.0.contains(&layer), - depth: layer.ancestors(network_interface.document_metadata()).count() as u32 - 1, visible: network_interface.is_visible(&node_id, &[]), - parents_visible, unlocked: !network_interface.is_locked(&node_id, &[]), - parents_unlocked, - parent_id: layer - .parent(network_interface.document_metadata()) - .and_then(|parent| if parent != LayerNodeIdentifier::ROOT_PARENT { Some(parent.to_node()) } else { None }), selected: selected_layers.contains(&node_id), - ancestor_of_selected: ancestors_of_selected.contains(&node_id), descendant_of_selected: descendants_of_selected.contains(&node_id), clipped: get_clip_mode(layer, network_interface).unwrap_or(false) && clippable, clippable, diff --git a/editor/src/messages/portfolio/document/utility_types/nodes.rs b/editor/src/messages/portfolio/document/utility_types/nodes.rs index bfa94ee2e5..2655196184 100644 --- a/editor/src/messages/portfolio/document/utility_types/nodes.rs +++ b/editor/src/messages/portfolio/document/utility_types/nodes.rs @@ -10,9 +10,17 @@ use graph_craft::document::{NodeId, NodeNetwork}; #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct LayerStructureEntry { + /// The node ID of the layer this entry represents. #[serde(rename = "layerId")] pub layer_id: NodeId, + /// The expanded child entries nested within this layer. Empty when the layer is collapsed or has no children. pub children: Vec, + /// Whether this layer has children reachable in the graph, even when they are omitted from `children` because the layer is collapsed. + #[serde(rename = "childrenPresent")] + pub children_present: bool, + /// Whether any descendant layer in the graph is selected, including through collapsed subtrees not listed in `children`. + #[serde(rename = "descendantSelected")] + pub descendant_selected: bool, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -28,21 +36,9 @@ pub struct LayerPanelEntry { pub in_selected_network: bool, #[serde(rename = "childrenAllowed")] pub children_allowed: bool, - #[serde(rename = "childrenPresent")] - pub children_present: bool, - pub expanded: bool, - pub depth: u32, pub visible: bool, - #[serde(rename = "parentsVisible")] - pub parents_visible: bool, pub unlocked: bool, - #[serde(rename = "parentsUnlocked")] - pub parents_unlocked: bool, - #[serde(rename = "parentId")] - pub parent_id: Option, pub selected: bool, - #[serde(rename = "ancestorOfSelected")] - pub ancestor_of_selected: bool, #[serde(rename = "descendantOfSelected")] pub descendant_of_selected: bool, pub clipped: bool, @@ -163,4 +159,7 @@ impl SelectedNodes { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct CollapsedLayers(pub Vec); +/// Tracks which layer instances are collapsed in the Layers panel. Each entry is an "instance path": +/// the sequence of ancestor node IDs from the root down to the collapsed layer. This allows the same +/// layer appearing under multiple parents to have independent expand/collapse state per instance. +pub struct CollapsedLayers(pub Vec>); diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte index 091067e0b4..ae0dc92a9c 100644 --- a/frontend/src/components/panels/Layers.svelte +++ b/frontend/src/components/panels/Layers.svelte @@ -20,6 +20,14 @@ bottomLayer: boolean; editingName: boolean; entry: LayerPanelEntry; + depth: number; + parentId: bigint | undefined; + childrenPresent: boolean; + expanded: boolean; + ancestorOfSelected: boolean; + parentsVisible: boolean; + parentsUnlocked: boolean; + instancePath: bigint[]; }; type DraggingData = { @@ -28,6 +36,7 @@ insertDepth: number; insertIndex: number | undefined; highlightFolder: boolean; + highlightFolderIndex: number | undefined; markerHeight: number; }; @@ -131,10 +140,10 @@ editor.toggleLayerLock(id); } - function handleExpandArrowClickWithModifiers(e: MouseEvent, id: bigint) { + function handleExpandArrowClickWithModifiers(e: MouseEvent, instancePath: bigint[]) { const accel = operatingSystem() === "Mac" ? e.metaKey : e.ctrlKey; const collapseRecursive = e.altKey || accel; - editor.toggleLayerExpansion(id, collapseRecursive); + editor.toggleLayerExpansion(BigUint64Array.from(instancePath), collapseRecursive); e.stopPropagation(); } @@ -271,6 +280,7 @@ // Whether you are inserting into a folder and should show the folder outline let highlightFolder = false; + let highlightFolderIndex: number | undefined = undefined; let markerHeight = 0; const layerPanel = document.querySelector("[data-layer-panel]"); // Selects the element with the data-layer-panel attribute @@ -279,40 +289,41 @@ Array.from(treeChildren).forEach((treeChild) => { const indexAttribute = treeChild.getAttribute("data-index"); if (!indexAttribute) return; - const { folderIndex, entry: layer } = layers[parseInt(indexAttribute, 10)]; + const listing = layers[parseInt(indexAttribute, 10)]; const rect = treeChild.getBoundingClientRect(); if (rect.top > clientY || rect.bottom < clientY) { return; } const pointerPercentage = (clientY - rect.top) / rect.height; - if (layer.childrenAllowed) { + if (listing.entry.childrenAllowed || listing.childrenPresent) { if (pointerPercentage < 0.25) { - insertParentId = layer.parentId; - insertDepth = layer.depth - 1; - insertIndex = folderIndex; + insertParentId = listing.parentId; + insertDepth = listing.depth - 1; + insertIndex = listing.folderIndex; markerHeight = rect.top - layerPanelTop; - } else if (pointerPercentage < 0.75 || (layer.childrenPresent && layer.expanded)) { - insertParentId = layer.id; - insertDepth = layer.depth; + } else if (pointerPercentage < 0.75 || (listing.childrenPresent && listing.expanded)) { + insertParentId = listing.entry.id; + insertDepth = listing.depth; insertIndex = 0; highlightFolder = true; + highlightFolderIndex = parseInt(indexAttribute, 10); } else { - insertParentId = layer.parentId; - insertDepth = layer.depth - 1; - insertIndex = folderIndex + 1; + insertParentId = listing.parentId; + insertDepth = listing.depth - 1; + insertIndex = listing.folderIndex + 1; markerHeight = rect.bottom - layerPanelTop; } } else { if (pointerPercentage < 0.5) { - insertParentId = layer.parentId; - insertDepth = layer.depth - 1; - insertIndex = folderIndex; + insertParentId = listing.parentId; + insertDepth = listing.depth - 1; + insertIndex = listing.folderIndex; markerHeight = rect.top - layerPanelTop; } else { - insertParentId = layer.parentId; - insertDepth = layer.depth - 1; - insertIndex = folderIndex + 1; + insertParentId = listing.parentId; + insertDepth = listing.depth - 1; + insertIndex = listing.folderIndex + 1; markerHeight = rect.bottom - layerPanelTop; } } @@ -320,7 +331,7 @@ // Dragging to the empty space below all layers let lastLayer = treeChildren[treeChildren.length - 1]; if (lastLayer.getBoundingClientRect().bottom < clientY) { - const numberRootLayers = layers.filter((layer) => layer.entry.depth === 1).length; + const numberRootLayers = layers.filter((listing) => listing.depth === 1).length; insertParentId = undefined; insertDepth = 0; insertIndex = numberRootLayers; @@ -334,6 +345,7 @@ insertDepth, insertIndex, highlightFolder, + highlightFolderIndex, markerHeight, }; } @@ -493,42 +505,57 @@ } function rebuildLayerHierarchy(layerStructure: LayerStructureEntry[]) { - const layerWithNameBeingEdited = layers.find((layer: LayerListingInfo) => layer.editingName); - const layerIdWithNameBeingEdited = layerWithNameBeingEdited?.entry.id; + // Track the editing state by flat list index, not layer ID, since a layer can appear at multiple positions + const editingIndex = layers.findIndex((layer: LayerListingInfo) => layer.editingName); // Clear the layer hierarchy before rebuilding it layers = []; // Build the new layer hierarchy - const recurse = (children: LayerStructureEntry[]) => { + const recurse = (children: LayerStructureEntry[], depth: number, parentId: bigint | undefined, parentPath: bigint[], parentsVisible: boolean, parentsUnlocked: boolean) => { children.forEach((item, index) => { + const instancePath = [...parentPath, item.layerId]; const mapping = layerCache.get(String(item.layerId)); + if (mapping) { mapping.id = item.layerId; layers.push({ folderIndex: index, bottomLayer: index === children.length - 1, entry: mapping, - editingName: layerIdWithNameBeingEdited === item.layerId, + editingName: editingIndex === layers.length, + depth, + parentId, + childrenPresent: item.childrenPresent, + expanded: item.childrenPresent && item.children.length > 0, + ancestorOfSelected: item.descendantSelected, + parentsVisible, + parentsUnlocked, + instancePath, }); } - // Call self recursively if there are any children - if (item.children.length >= 1) recurse(item.children); + // Call self recursively, propagating this layer's visibility/lock state to its children + const childParentsVisible = parentsVisible && (mapping?.visible ?? true); + const childParentsUnlocked = parentsUnlocked && (mapping?.unlocked ?? true); + if (item.children.length >= 1) recurse(item.children, depth + 1, item.layerId, instancePath, childParentsVisible, childParentsUnlocked); }); }; - recurse(layerStructure); + recurse(layerStructure, 1, undefined, [], true, true); layers = layers; } function updateLayerInTree(targetId: bigint, targetLayer: LayerPanelEntry) { layerCache.set(String(targetId), targetLayer); - const layer = layers.find((layer: LayerListingInfo) => layer.entry.id === targetId); - if (layer) { - layer.entry = targetLayer; - layers = layers; - } + let changed = false; + layers.forEach((layer) => { + if (layer.entry.id === targetId) { + layer.entry = targetLayer; + changed = true; + } + }); + if (changed) layers = layers; } @@ -556,29 +583,29 @@ class="layer" classes={{ selected, - "ancestor-of-selected": listing.entry.ancestorOfSelected, + "ancestor-of-selected": listing.ancestorOfSelected, "descendant-of-selected": listing.entry.descendantOfSelected, "selected-but-not-in-selected-network": selected && !listing.entry.inSelectedNetwork, - "insert-folder": (draggingData?.highlightFolder || false) && draggingData?.insertParentId === listing.entry.id, + "insert-folder": (draggingData?.highlightFolder || false) && draggingData?.highlightFolderIndex === index, }} - styles={{ "--layer-indent-levels": `${listing.entry.depth - 1}` }} + styles={{ "--layer-indent-levels": `${listing.depth - 1}` }} data-layer data-index={index} on:pointerdown={(e) => layerPointerDown(e, listing)} on:click={(e) => selectLayerWithModifiers(e, listing)} > - {#if listing.entry.childrenAllowed} + {#if listing.entry.childrenAllowed || listing.childrenPresent} {:else} @@ -628,27 +655,27 @@ on:change={(e) => onEditLayerNameChange(listing, e)} /> - {#if !listing.entry.unlocked || !listing.entry.parentsUnlocked} + {#if !listing.entry.unlocked || !listing.parentsUnlocked} (toggleLayerLock(listing.entry.id), e?.stopPropagation())} size={24} icon={listing.entry.unlocked ? "PadlockUnlocked" : "PadlockLocked"} hoverIcon={listing.entry.unlocked ? "PadlockLocked" : "PadlockUnlocked"} tooltipLabel={listing.entry.unlocked ? "Lock" : "Unlock"} - tooltipDescription={!listing.entry.parentsUnlocked ? "A parent of this layer is locked and that status is being inherited." : ""} + tooltipDescription={!listing.parentsUnlocked ? "A parent of this layer is locked and that status is being inherited." : ""} /> {/if} (toggleNodeVisibilityLayerPanel(listing.entry.id), e?.stopPropagation())} size={24} icon={listing.entry.visible ? "EyeVisible" : "EyeHidden"} hoverIcon={listing.entry.visible ? "EyeHide" : "EyeShow"} tooltipLabel={listing.entry.visible ? "Hide" : "Show"} - tooltipDescription={!listing.entry.parentsVisible ? "A parent of this layer is hidden and that status is being inherited." : ""} + tooltipDescription={!listing.parentsVisible ? "A parent of this layer is hidden and that status is being inherited." : ""} /> {/each} @@ -737,9 +764,13 @@ background: rgba(var(--color-4-dimgray-rgb), 0.5); } - &.insert-folder { - outline: 3px solid var(--color-e-nearwhite); - outline-offset: -3px; + &.insert-folder::after { + content: ""; + position: absolute; + inset: 0; + border: 3px solid var(--color-e-nearwhite); + border-radius: 2px; + pointer-events: none; } .expand-arrow { diff --git a/frontend/wrapper/src/editor_wrapper.rs b/frontend/wrapper/src/editor_wrapper.rs index 2b256b7543..13fdca90de 100644 --- a/frontend/wrapper/src/editor_wrapper.rs +++ b/frontend/wrapper/src/editor_wrapper.rs @@ -858,9 +858,9 @@ impl EditorWrapper { /// Toggle expansions state of a layer from the layer list #[wasm_bindgen(js_name = toggleLayerExpansion)] - pub fn toggle_layer_expansion(&self, id: u64, recursive: bool) { - let id = NodeId(id); - let message = DocumentMessage::ToggleLayerExpansion { id, recursive }; + pub fn toggle_layer_expansion(&self, instance_path: &[u64], recursive: bool) { + let instance_path = instance_path.iter().map(|&id| NodeId(id)).collect(); + let message = DocumentMessage::ToggleLayerExpansion { instance_path, recursive }; self.dispatch(message); }