mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-16 06:38:03 +08:00
Implement dragging to reorder Properties panel node sections (#4299)
* Implement dragging to reorder Properties panel node sections * Code review
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -103,6 +103,10 @@ pub enum DocumentMessage {
|
||||
parent: LayerNodeIdentifier,
|
||||
insert_index: usize,
|
||||
},
|
||||
ReorderPropertiesSection {
|
||||
node_id: NodeId,
|
||||
insert_index: usize,
|
||||
},
|
||||
MoveSelectedLayersToGroup {
|
||||
parent: LayerNodeIdentifier,
|
||||
},
|
||||
|
||||
@@ -726,6 +726,39 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> 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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -186,6 +186,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> 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<NodeGraphMessage, NodeGraphMessageContext<'a>> 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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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<NodeId> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
// 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::<Vec<_>>();
|
||||
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::<HashSet<_>>());
|
||||
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::<Vec<_>>();
|
||||
|
||||
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<NodeId, DocumentNodeMetadata>,
|
||||
/// 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<NodeId>,
|
||||
/// 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,
|
||||
|
||||
@@ -1,12 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount, onDestroy } from "svelte";
|
||||
import LayoutCol from "/src/components/layout/LayoutCol.svelte";
|
||||
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
|
||||
import { propertiesPanelLayout } from "/src/stores/portfolio";
|
||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||
|
||||
const editor = getContext<EditorWrapper>("editor");
|
||||
|
||||
let sectionsCol: LayoutCol | undefined;
|
||||
|
||||
// Interactive dragging to reorder Properties panel node sections (a selected layer's chain nodes, or pinned nodes)
|
||||
type DragState = { nodeId: bigint; startX: number; startY: number; active: boolean };
|
||||
let dragState: DragState | undefined = undefined;
|
||||
let dragging = false;
|
||||
let fromIndex: number | undefined = undefined;
|
||||
let insertIndex: number | undefined = undefined;
|
||||
let insertMarkerTop: number | undefined = undefined;
|
||||
let justFinishedDrag = false; // Used to suppress the click event that follows a drag release (which would otherwise toggle a section)
|
||||
|
||||
onMount(() => {
|
||||
addEventListener("pointermove", draggingPointerMove);
|
||||
addEventListener("pointerup", draggingPointerUp);
|
||||
addEventListener("keydown", draggingKeyDown);
|
||||
addEventListener("mousedown", draggingMouseDown);
|
||||
// Capture phase so this runs before a section header's own click handler
|
||||
addEventListener("click", suppressClickAfterDrag, true);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEventListener("pointermove", draggingPointerMove);
|
||||
removeEventListener("pointerup", draggingPointerUp);
|
||||
removeEventListener("keydown", draggingKeyDown);
|
||||
removeEventListener("mousedown", draggingMouseDown);
|
||||
removeEventListener("click", suppressClickAfterDrag, true);
|
||||
});
|
||||
|
||||
function suppressClickAfterDrag(e: MouseEvent) {
|
||||
if (!justFinishedDrag) return;
|
||||
justFinishedDrag = false;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function reorderableSections(): Element[] {
|
||||
const container = sectionsCol?.div();
|
||||
if (!container) return [];
|
||||
return Array.from(container.querySelectorAll("[data-properties-reorderable-section]"));
|
||||
}
|
||||
|
||||
function sectionPointerDown(e: PointerEvent) {
|
||||
// Only left click drags
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const target = e.target instanceof Element ? e.target : undefined;
|
||||
if (!target) return;
|
||||
|
||||
// The drag handle is the section header
|
||||
const handle = target.closest("[data-properties-reorder-handle]");
|
||||
if (!handle) return;
|
||||
|
||||
// Don't begin a drag when pressing one of the header's own buttons (pin/delete/visibility); only the header itself grabs
|
||||
if (target.closest("button") !== handle) return;
|
||||
|
||||
const section = target.closest("[data-properties-reorderable-section]");
|
||||
const nodeIdAttribute = section?.getAttribute("data-node-id");
|
||||
if (!section || !nodeIdAttribute) return;
|
||||
|
||||
dragState = { nodeId: BigInt(nodeIdAttribute), startX: e.clientX, startY: e.clientY, active: false };
|
||||
}
|
||||
|
||||
function draggingPointerMove(e: PointerEvent) {
|
||||
if (!dragState) return;
|
||||
|
||||
// Wait until the cursor has moved beyond the threshold before treating it as a drag (so a click still toggles the section)
|
||||
if (!dragState.active) {
|
||||
const distance = Math.hypot(e.clientX - dragState.startX, e.clientY - dragState.startY);
|
||||
const DRAG_THRESHOLD = 5;
|
||||
if (distance <= DRAG_THRESHOLD) return;
|
||||
|
||||
dragState.active = true;
|
||||
dragging = true;
|
||||
fromIndex = reorderableSections().findIndex((section) => section.getAttribute("data-node-id") === String(dragState?.nodeId));
|
||||
}
|
||||
|
||||
calculateInsertIndex(e.clientY);
|
||||
}
|
||||
|
||||
function calculateInsertIndex(clientY: number) {
|
||||
const container = sectionsCol?.div();
|
||||
const sections = reorderableSections();
|
||||
if (!container || sections.length === 0) {
|
||||
insertIndex = undefined;
|
||||
insertMarkerTop = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const scrollTop = container.scrollTop;
|
||||
// Convert a viewport Y to a position within the (scrollable) container's content, so the marker tracks the content when scrolled
|
||||
const toContentOffset = (viewportY: number) => viewportY - containerRect.top + scrollTop;
|
||||
|
||||
// The insertion index is the number of sections whose vertical midpoint sits above the cursor. The index flips only when
|
||||
// the cursor crosses a section's midpoint, so each gap maps to exactly one index (and gaps between sections are handled).
|
||||
let index = 0;
|
||||
for (let i = 0; i < sections.length; i += 1) {
|
||||
const rect = sections[i].getBoundingClientRect();
|
||||
if (clientY < (rect.top + rect.bottom) / 2) break;
|
||||
index = i + 1;
|
||||
}
|
||||
|
||||
// Position the marker purely from the gap index so it has one fixed spot per gap, rather than snapping between adjacent
|
||||
// sections' edges as the cursor crosses their shared boundary.
|
||||
let markerViewportY;
|
||||
if (index <= 0) {
|
||||
markerViewportY = sections[0].getBoundingClientRect().top - 2;
|
||||
} else if (index >= sections.length) {
|
||||
markerViewportY = sections[sections.length - 1].getBoundingClientRect().bottom + 2;
|
||||
} else {
|
||||
markerViewportY = (sections[index - 1].getBoundingClientRect().bottom + sections[index].getBoundingClientRect().top) / 2;
|
||||
}
|
||||
|
||||
insertIndex = index;
|
||||
insertMarkerTop = toContentOffset(markerViewportY);
|
||||
}
|
||||
|
||||
function draggingPointerUp() {
|
||||
if (dragState?.active) {
|
||||
// Suppress the click that the browser fires after the drag release, so it doesn't toggle the dropped section
|
||||
justFinishedDrag = true;
|
||||
|
||||
// Skip drops that don't actually move the node (into its own slot), or where the dragged section vanished from the DOM mid-drag (fromIndex of -1)
|
||||
if (insertIndex !== undefined && fromIndex !== undefined && fromIndex !== -1 && insertIndex !== fromIndex && insertIndex !== fromIndex + 1) {
|
||||
editor.reorderPropertiesSection(dragState.nodeId, insertIndex);
|
||||
}
|
||||
}
|
||||
|
||||
abortDrag();
|
||||
}
|
||||
|
||||
function draggingKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && dragState?.active) {
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function draggingMouseDown(e: MouseEvent) {
|
||||
// Abort an in-progress drag if the user presses the right mouse button
|
||||
if (e.button === 2 && dragState?.active) {
|
||||
abortDrag();
|
||||
}
|
||||
}
|
||||
|
||||
function abortDrag() {
|
||||
dragState = undefined;
|
||||
dragging = false;
|
||||
fromIndex = undefined;
|
||||
insertIndex = undefined;
|
||||
insertMarkerTop = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LayoutCol class="properties">
|
||||
<LayoutCol class="sections" scrollableY={true}>
|
||||
<LayoutCol class="sections" classes={{ dragging }} scrollableY={true} bind:this={sectionsCol} on:pointerdown={sectionPointerDown}>
|
||||
<WidgetLayout layout={$propertiesPanelLayout} layoutTarget="PropertiesPanel" />
|
||||
{#if dragging && insertMarkerTop !== undefined}
|
||||
<div class="insert-mark" style:top={`${insertMarkerTop}px`}></div>
|
||||
{/if}
|
||||
</LayoutCol>
|
||||
</LayoutCol>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<EditorWrapper>("editor");
|
||||
</script>
|
||||
|
||||
<!-- TODO: Implement collapsable sections with properties system -->
|
||||
<LayoutCol class={`widget-section ${className}`.trim()} {classes}>
|
||||
<button class="header" class:expanded on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
|
||||
<LayoutCol class={`widget-section ${className}`.trim()} {classes} data-properties-reorderable-section={reorderable ? "" : undefined} data-node-id={reorderable ? String(widgetData.id) : undefined}>
|
||||
<button class="header" class:expanded data-properties-reorder-handle={reorderable ? "" : undefined} on:click|stopPropagation={() => (expanded = !expanded)} tabindex="0">
|
||||
<div class="expand-arrow"></div>
|
||||
<TextLabel tooltipLabel={widgetData.name} tooltipDescription={widgetData.description} bold={true}>{widgetData.name}</TextLabel>
|
||||
<IconButton
|
||||
|
||||
@@ -756,6 +756,15 @@ impl EditorWrapper {
|
||||
self.dispatch(message);
|
||||
}
|
||||
|
||||
/// Reorder a draggable Properties panel section to the given index among its peers.
|
||||
#[wasm_bindgen(js_name = reorderPropertiesSection)]
|
||||
pub fn reorder_properties_section(&self, node_id: u64, insert_index: usize) {
|
||||
self.dispatch(DocumentMessage::ReorderPropertiesSection {
|
||||
node_id: NodeId(node_id),
|
||||
insert_index,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
Reference in New Issue
Block a user