From 1648e33c1fd42afe0cbebd02cc0fbbebb48dcf4d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 1 Jul 2026 03:39:52 -0700 Subject: [PATCH] Implement dragging to reorder Properties panel node sections (#4299) * Implement dragging to reorder Properties panel node sections * Code review --- .../layout/utility_types/layout_widget.rs | 15 ++ .../portfolio/document/document_message.rs | 4 + .../document/document_message_handler.rs | 33 ++++ .../document/node_graph/node_graph_message.rs | 8 + .../node_graph/node_graph_message_handler.rs | 45 +++-- .../utility_types/network_interface.rs | 131 +++++++++++++ .../src/components/panels/Properties.svelte | 180 +++++++++++++++++- .../components/widgets/WidgetSection.svelte | 7 +- frontend/wrapper/src/editor_wrapper.rs | 9 + 9 files changed, 410 insertions(+), 22 deletions(-) diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs index f2fa6cd700..65c56d5402 100644 --- a/editor/src/messages/layout/utility_types/layout_widget.rs +++ b/editor/src/messages/layout/utility_types/layout_widget.rs @@ -370,14 +370,23 @@ pub struct WidgetTable { pub unstyled: bool, } +/// A collapsible Properties panel section for a single node: a header with its name and pin, delete, and visibility controls, above its input parameter widgets. #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct WidgetSection { + /// The node's name shown in the section header: its given alias with the implementation name in parentheses, or just the implementation name. pub name: String, + /// The node definition's description, shown as the header's tooltip (empty when it has none). pub description: String, + /// Whether the node is visible rather than hidden (shown as the eye icon). pub visible: bool, + /// Whether the node is pinned, keeping its section shown in the Properties panel when nothing is selected (shown as the pin icon). pub pinned: bool, + /// Whether this section can be dragged to reorder it within its layer's chain of nodes (true for a layer chain's nodes, but not its terminal layer node). + pub draggable: bool, + /// The ID of the node whose properties this section displays. pub id: u64, + /// The node's properties content, rendered as the section's body when expanded. pub layout: Layout, } @@ -411,6 +420,7 @@ impl LayoutGroup { description: description.into(), visible, pinned, + draggable: false, id, layout, }) @@ -507,6 +517,7 @@ impl Diffable for LayoutGroup { description: current_description, visible: current_visible, pinned: current_pinned, + draggable: current_draggable, id: current_id, layout: current_layout, }), @@ -515,6 +526,7 @@ impl Diffable for LayoutGroup { description: new_description, visible: new_visible, pinned: new_pinned, + draggable: new_draggable, id: new_id, layout: new_layout, }), @@ -526,6 +538,7 @@ impl Diffable for LayoutGroup { || *current_description != new_description || *current_visible != new_visible || *current_pinned != new_pinned + || *current_draggable != new_draggable || *current_id != new_id { // Update self to reflect new changes @@ -533,6 +546,7 @@ impl Diffable for LayoutGroup { current_description.clone_from(&new_description); *current_visible = new_visible; *current_pinned = new_pinned; + *current_draggable = new_draggable; *current_id = new_id; current_layout.clone_from(&new_layout); @@ -542,6 +556,7 @@ impl Diffable for LayoutGroup { description: new_description, visible: new_visible, pinned: new_pinned, + draggable: new_draggable, id: new_id, layout: new_layout, }) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index e43494d283..170cd54570 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -103,6 +103,10 @@ pub enum DocumentMessage { parent: LayerNodeIdentifier, insert_index: usize, }, + ReorderPropertiesSection { + node_id: NodeId, + insert_index: usize, + }, MoveSelectedLayersToGroup { parent: LayerNodeIdentifier, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 98f6c90062..c6fb9b3c6e 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -726,6 +726,39 @@ impl MessageHandler> for DocumentMes DocumentMessage::MoveSelectedLayersTo { parent, insert_index } => { self.move_selected_layers_to(parent, insert_index, responses); } + DocumentMessage::ReorderPropertiesSection { node_id, insert_index } => { + // The Properties panel shows draggable sections in two cases, disambiguated by the current selection: + // a single selected layer (reorder within its node chain) or no selection (reorder the pinned nodes). + let selected_nodes = self.network_interface.selected_nodes_in_nested_network(&self.selection_network_path); + let Some(selected_nodes) = selected_nodes else { return }; + + let (mut layers, mut nodes) = (Vec::new(), Vec::new()); + + for selected in selected_nodes.selected_nodes() { + if self.network_interface.is_layer(selected, &self.selection_network_path) { + layers.push(*selected); + } else { + nodes.push(*selected); + } + } + + layers.sort(); + layers.dedup(); + + if layers.len() == 1 { + // Reorder a node within the selected layer's chain by rewiring the graph + responses.add(DocumentMessage::AddTransaction); + responses.add(NodeGraphMessage::ReorderChainNode { node_id, insert_index }); + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(NodeGraphMessage::SendGraph); + responses.add(PropertiesPanelMessage::Refresh); + } else if layers.is_empty() && nodes.is_empty() { + // Reorder a pinned node, which is purely a Properties panel display order (no graph rerender needed) + responses.add(DocumentMessage::AddTransaction); + responses.add(NodeGraphMessage::ReorderPinnedNode { node_id, insert_index }); + responses.add(PropertiesPanelMessage::Refresh); + } + } DocumentMessage::MoveSelectedLayersToGroup { parent } => { // Group all shallowest unique selected layers in order let all_layers_to_group_sorted = self.network_interface.shallowest_unique_layers_sorted(&self.selection_network_path); diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs index 3b7aa266ba..011065fd78 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message.rs @@ -94,6 +94,14 @@ pub enum NodeGraphMessage { node_id: NodeId, parent: LayerNodeIdentifier, }, + ReorderChainNode { + node_id: NodeId, + insert_index: usize, + }, + ReorderPinnedNode { + node_id: NodeId, + insert_index: usize, + }, SetChainPosition { node_id: NodeId, }, 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 f2a5211e11..a8edb3fa55 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 @@ -186,6 +186,7 @@ impl<'a> MessageHandler> for NodeG on: EventMessage::SelectionChanged, send: Box::new(NodeGraphMessage::SelectedNodesUpdated.into()), }); + network_interface.load_structure(); collapsed.0.retain(|path| path.iter().all(|&node_id| network_interface.document_network().nodes.contains_key(&node_id))); } @@ -753,6 +754,12 @@ impl<'a> MessageHandler> for NodeG NodeGraphMessage::MoveNodeToChainStart { node_id, parent } => { network_interface.move_node_to_chain_start(&node_id, parent, selection_network_path, false); } + NodeGraphMessage::ReorderChainNode { node_id, insert_index } => { + network_interface.reorder_chain_node(node_id, insert_index, selection_network_path); + } + NodeGraphMessage::ReorderPinnedNode { node_id, insert_index } => { + network_interface.reorder_pinned_node(node_id, insert_index, selection_network_path); + } NodeGraphMessage::SetChainPosition { node_id } => { network_interface.set_chain_position(&node_id, selection_network_path); } @@ -2525,25 +2532,18 @@ impl NodeGraphMessageHandler { Separator::new(SeparatorStyle::Related).widget_instance(), ])]; - let Some(network) = context.network_interface.nested_network(context.selection_network_path) else { - warn!("No network in collate_properties"); - return Vec::new(); - }; - // And if no nodes are selected, show properties for all pinned nodes - let pinned_node_properties = network - .nodes - .keys() - .cloned() - .collect::>() - .iter() - .filter_map(|node_id| { - if context.network_interface.is_pinned(node_id, context.selection_network_path) { - Some(node_properties::generate_node_properties(*node_id, context)) - } else { - None - } - }) + // And if no nodes are selected, show properties for all pinned nodes (in the user's saved order, each draggable to reorder) + let mut pinned_node_properties = context + .network_interface + .ordered_pinned_nodes(context.selection_network_path) + .into_iter() + .map(|node_id| node_properties::generate_node_properties(node_id, context)) .collect::>(); + for pinned_section in pinned_node_properties.iter_mut() { + if let LayoutGroup::Section(section) = pinned_section { + section.draggable = true; + } + } properties.extend(pinned_node_properties); properties @@ -2605,7 +2605,7 @@ impl NodeGraphMessageHandler { ])]; // Iterate through all the upstream nodes, but stop when we reach another layer (since that's a point where we switch from horizontal to vertical flow) - let node_properties = context + let mut node_properties = context .network_interface .upstream_flow_back_from_nodes(vec![layer], context.selection_network_path, network_interface::FlowType::HorizontalFlow) .enumerate() @@ -2622,6 +2622,13 @@ impl NodeGraphMessageHandler { .map(|node_id| node_properties::generate_node_properties(node_id, context)) .collect::>(); + // Mark each node in the layer's chain (but not the layer node itself, which is first) as draggable so its section can be reordered within the chain from the Properties panel + for chain_node_section in node_properties.iter_mut().skip(1) { + if let LayoutGroup::Section(section) = chain_node_section { + section.draggable = true; + } + } + layer_properties.extend(node_properties); layer_properties } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 4f1adc60c2..12a139c0c3 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -1054,6 +1054,35 @@ impl NodeNetworkInterface { node_metadata.persistent_metadata.pinned } + /// The given network's pinned nodes in display order: pinning appends, dragging rearranges, and any not yet recorded go last. + pub fn ordered_pinned_nodes(&self, network_path: &[NodeId]) -> Vec { + let Some(network) = self.nested_network(network_path) else { return Vec::new() }; + + let order = self + .network_metadata(network_path) + .map(|network_metadata| network_metadata.persistent_metadata.pinned_node_order.clone()) + .unwrap_or_default(); + + // Follow the saved order, keeping only nodes that still exist and are still pinned + let mut pinned_nodes = order + .iter() + .copied() + .filter(|node_id| network.nodes.contains_key(node_id) && self.is_pinned(node_id, network_path)) + .collect::>(); + + // Append any pinned nodes missing from the saved order at the end + let mut unordered = network + .nodes + .keys() + .copied() + .filter(|node_id| self.is_pinned(node_id, network_path) && !order.contains(node_id)) + .collect::>(); + unordered.sort(); + pinned_nodes.extend(unordered); + + pinned_nodes + } + pub fn is_visible(&self, node_id: &NodeId, network_path: &[NodeId]) -> bool { let Some(node) = self.document_node(node_id, network_path) else { log::error!("Could not get node in is_visible"); @@ -4542,6 +4571,15 @@ impl NodeNetworkInterface { self.set_chain_position(&previous_chain_node, network_path); } } + + // Prune this network's pinned display order down to the nodes that still exist, dropping any that were actually removed + let surviving_nodes = self.nested_network(network_path).map(|network| network.nodes.keys().copied().collect::>()); + if let Some(surviving_nodes) = surviving_nodes + && let Some(network_metadata) = self.network_metadata_mut(network_path) + { + network_metadata.persistent_metadata.pinned_node_order.retain(|node_id| surviving_nodes.contains(node_id)); + } + self.unload_all_nodes_bounding_box(network_path); // Instead of unloaded all node click targets, just unload the nodes upstream from the deleted nodes. unload_upstream_node_click_targets will not work since the nodes have been deleted. self.unload_all_nodes_click_targets(network_path); @@ -4724,6 +4762,43 @@ impl NodeNetworkInterface { }; node_metadata.persistent_metadata.pinned = pinned; + + // Track the node in this network's pinned display order: append when newly pinned, prune when unpinned + if let Some(network_metadata) = self.network_metadata_mut(network_path) { + let order = &mut network_metadata.persistent_metadata.pinned_node_order; + if pinned { + if !order.contains(node_id) { + order.push(*node_id); + } + } else { + order.retain(|id| id != node_id); + } + } + + self.transaction_modified(); + } + + /// Reorders a pinned node within its network's Properties panel display order so it ends up at `insert_index` among the + /// pinned nodes (0 being the topmost). Rebuilds the order from the list as currently shown, which also drops stale entries. + pub fn reorder_pinned_node(&mut self, node_id: NodeId, insert_index: usize, network_path: &[NodeId]) { + let shown = self.ordered_pinned_nodes(network_path); + + let Some(from) = shown.iter().position(|id| *id == node_id) else { return }; + let to = (if insert_index > from { insert_index - 1 } else { insert_index }).min(shown.len().saturating_sub(1)); + if to == from { + return; + } + + let mut new_order = shown; + let moved = new_order.remove(from); + new_order.insert(to, moved); + + let Some(network_metadata) = self.network_metadata_mut(network_path) else { + log::error!("Could not get network_metadata in reorder_pinned_node"); + return; + }; + network_metadata.persistent_metadata.pinned_node_order = new_order; + self.transaction_modified(); } @@ -6002,6 +6077,59 @@ impl NodeNetworkInterface { self.force_set_upstream_to_chain(node_id, network_path); } } + + /// Reorders a node within its layer's horizontal chain so it ends up at `insert_index` among the chain's nodes, + /// where index 0 is the node closest to the layer. The connection feeding the top (most-upstream end) of the chain + /// is preserved, as are each node's other (non-primary) inputs. + pub fn reorder_chain_node(&mut self, node_id: NodeId, insert_index: usize, network_path: &[NodeId]) { + let Some(layer) = self.downstream_layer_for_chain_node(&node_id, network_path) else { + log::error!("Could not find downstream layer for chain node {node_id} in reorder_chain_node"); + return; + }; + + // The nodes in the layer's chain, ordered from closest-to-layer outward, stopping at the next layer + let chain = self + .upstream_flow_back_from_nodes(vec![layer], network_path, FlowType::HorizontalFlow) + .skip(1) + .take_while(|upstream_id| !self.is_layer(upstream_id, network_path)) + .collect::>(); + + let Some(from) = chain.iter().position(|id| *id == node_id) else { + log::error!("Node {node_id} is not part of its layer's chain in reorder_chain_node"); + return; + }; + + // The drop gap is measured against the chain that still includes the dragged node, so shift it down by one if the node is being removed from before the gap + let to = (if insert_index > from { insert_index - 1 } else { insert_index }).min(chain.len() - 1); + if to == from { + return; + } + + let mut new_order = chain.clone(); + new_order.remove(from); + new_order.insert(to, node_id); + + // Preserve whatever feeds the most-upstream chain node (a value, an import, or an upstream layer) so it stays at the top + let Some(tail_input) = self.input_from_connector(&InputConnector::node(*chain.last().unwrap(), 0), network_path).cloned() else { + log::error!("Could not get the upstream input of the chain in reorder_chain_node"); + return; + }; + + // Disconnect the existing internal chain wiring first so the rewiring below can't transiently form a cycle + for &chain_node in &chain { + self.disconnect_input(&InputConnector::node(chain_node, 0), network_path); + } + + // Rewire in the new order: layer's secondary input -> new_order[0] -> ... -> new_order[last] -> preserved tail input + self.set_input(&InputConnector::node(layer, 1), NodeInput::node(new_order[0], 0), network_path); + for pair in new_order.windows(2) { + self.set_input(&InputConnector::node(pair[0], 0), NodeInput::node(pair[1], 0), network_path); + } + self.set_input(&InputConnector::node(*new_order.last().unwrap(), 0), tail_input, network_path); + + // Re-establish chain positioning for the reordered nodes + self.force_set_upstream_to_chain(&new_order[0], network_path); + } } #[derive(PartialEq)] @@ -6346,6 +6474,9 @@ pub struct NodeNetworkPersistentMetadata { /// Node metadata must exist for every document node in the network #[serde(serialize_with = "graphene_std::vector::serialize_hashmap", deserialize_with = "graphene_std::vector::deserialize_hashmap")] pub node_metadata: HashMap, + /// The display order of pinned nodes in the Properties panel (shown when nothing is selected in this network), keyed by node ID. + #[serde(default)] + pub pinned_node_order: Vec, /// Cached metadata for each node, which is calculated when adding a node to node_metadata /// Indicates whether the network is currently rendered with a particular node that is previewed, and if so, which connection should be restored when the preview ends. pub previewing: Previewing, diff --git a/frontend/src/components/panels/Properties.svelte b/frontend/src/components/panels/Properties.svelte index 4ae6fd75e3..a0c7160fa4 100644 --- a/frontend/src/components/panels/Properties.svelte +++ b/frontend/src/components/panels/Properties.svelte @@ -1,12 +1,171 @@ - + + {#if dragging && insertMarkerTop !== undefined} +
+ {/if}
@@ -17,6 +176,13 @@ .sections { flex: 1 1 100%; + position: relative; + + // While dragging a section, disable pointer events (which inherit down to the sections) so the drop doesn't toggle a section's expansion, and prevent text selection + &.dragging { + user-select: none; + pointer-events: none; + } // Used as a placeholder for empty assist widgets .separator.section.horizontal { @@ -27,6 +193,18 @@ width: 0; } } + + .insert-mark { + position: absolute; + left: 4px; + right: 4px; + background: var(--color-e-nearwhite); + height: 5px; + // The marker's `top` is the center of the gap, so shift up by half its height to straddle that line + transform: translateY(-50%); + z-index: 1; + pointer-events: none; + } } .text-button { diff --git a/frontend/src/components/widgets/WidgetSection.svelte b/frontend/src/components/widgets/WidgetSection.svelte index ab435dd8b5..7f9e0d58a6 100644 --- a/frontend/src/components/widgets/WidgetSection.svelte +++ b/frontend/src/components/widgets/WidgetSection.svelte @@ -15,12 +15,15 @@ let expanded = true; + // A reorderable section is a Properties panel node section the user can drag to reorder (a layer chain's node, or a pinned node) + $: reorderable = layoutTarget === "PropertiesPanel" && widgetData.draggable; + const editor = getContext("editor"); - -