Add layer locked toggle icon and context menu entry to node graph (#3855)

* Add layer locked toggle icon and context menu entry to node graph

* Simplify logic
This commit is contained in:
Keavon Chambers 2026-03-03 22:30:04 -08:00 committed by GitHub
parent 54a02dedd5
commit 8a75c0c1e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 130 additions and 8 deletions

View File

@ -809,10 +809,27 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
let context_menu_data = if let Some(node_id) = clicked_id {
let currently_is_node = !network_interface.is_layer(&node_id, breadcrumb_network_path);
let can_be_layer = network_interface.is_eligible_to_be_layer(&node_id, breadcrumb_network_path);
// Determine which layers the Lock/Unlock action would affect:
// - If the right-clicked node is in the selection, it affects all selected layers
// - If the right-clicked node is not in the selection, it affects just the right-clicked node
let selected_nodes = network_interface.selected_nodes_in_nested_network(selection_network_path);
let is_clicked_selected = selected_nodes.as_ref().is_some_and(|selected| selected.selected_nodes().any(|id| *id == node_id));
let affected_layer_ids = if is_clicked_selected {
selected_nodes.map(|selected| selected.selected_nodes().copied().filter(|id| network_interface.is_layer(id, selection_network_path)).collect())
} else {
network_interface.is_layer(&node_id, selection_network_path).then(|| vec![node_id])
}
.unwrap_or_default();
let has_selected_layers = !affected_layer_ids.is_empty();
let all_selected_layers_locked = has_selected_layers && affected_layer_ids.iter().all(|id| network_interface.is_locked(id, selection_network_path));
ContextMenuData::ModifyNode {
can_be_layer,
currently_is_node,
node_id,
has_selected_layers,
all_selected_layers_locked,
}
} else {
ContextMenuData::CreateNode { compatible_type: None }
@ -896,6 +913,12 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
return;
}
// Toggle lock of clicked node and return
if let Some(clicked_lock) = network_interface.layer_click_target_from_click(click, network_interface::LayerClickTargetTypes::Lock, selection_network_path) {
responses.add(NodeGraphMessage::ToggleLocked { node_id: clicked_lock });
return;
}
// Alt-click sets the clicked node as previewed
if alt_click && let Some(clicked_node) = clicked_id {
self.preview_on_mouse_up = Some(clicked_node);
@ -1834,9 +1857,17 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
log::error!("Could not get selected nodes in NodeGraphMessage::ToggleSelectedLocked");
return;
};
let node_ids = selected_nodes.selected_nodes().cloned().collect::<Vec<_>>();
let node_ids = selected_nodes
.selected_nodes()
.filter(|node_id| network_interface.is_layer(node_id, selection_network_path))
.cloned()
.collect::<Vec<_>>();
// If any of the selected layers are locked, show them all. Otherwise, hide them all.
if node_ids.is_empty() {
return;
}
// If any of the selected layers are unlocked, lock them all. Otherwise, unlock them all.
let locked = !node_ids.iter().all(|node_id| network_interface.is_locked(node_id, selection_network_path));
responses.add(DocumentMessage::AddTransaction);

View File

@ -147,6 +147,10 @@ pub enum ContextMenuData {
can_be_layer: bool,
#[serde(rename = "currentlyIsNode")]
currently_is_node: bool,
#[serde(rename = "hasSelectedLayers")]
has_selected_layers: bool,
#[serde(rename = "allSelectedLayersLocked")]
all_selected_layers_locked: bool,
},
CreateNode {
#[serde(rename = "compatibleType")]

View File

@ -2108,9 +2108,10 @@ impl NodeNetworkInterface {
let grip_padding = 4.;
let grip_width = 8.;
let lock_icon_width = if self.is_locked(node_id, network_path) { GRID_SIZE as f64 } else { 0. };
let icon_overhang_width = GRID_SIZE as f64 / 2.;
let layer_width_pixels = left_thumbnail_padding + thumbnail_width + GAP_WIDTH + text_width + grip_padding + grip_width + icon_overhang_width;
let layer_width_pixels = left_thumbnail_padding + thumbnail_width + GAP_WIDTH + text_width + grip_padding + grip_width + lock_icon_width + icon_overhang_width;
let layer_width = ((layer_width_pixels / 24.).ceil() as u32).max(8);
let Some(node_metadata) = self.node_metadata_mut(node_id, network_path) else {
@ -2527,14 +2528,25 @@ impl NodeNetworkInterface {
});
let width = layer_width_cells * crate::consts::GRID_SIZE;
let height = 2 * crate::consts::GRID_SIZE;
let locked = self.is_locked(node_id, network_path);
// Update visibility button click target
let visibility_offset = node_top_left + DVec2::new(width as f64, 24.);
let subpath = Subpath::new_rounded_rectangle(DVec2::new(-12., -12.) + visibility_offset, DVec2::new(12., 12.) + visibility_offset, [3.; 4]);
let visibility_click_target = ClickTarget::new_with_subpath(subpath, 0.);
// Update grip button click target, which is positioned to the left of the left most icon
let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - (GRID_SIZE as f64) / 2., 24.);
// Update lock button click target, positioned one grid unit to the left of the visibility button (only when locked)
let lock_click_target = if locked {
let lock_offset = node_top_left + DVec2::new(width as f64 - GRID_SIZE as f64, 24.);
let subpath = Subpath::new_rounded_rectangle(DVec2::new(-12., -12.) + lock_offset, DVec2::new(12., 12.) + lock_offset, [3.; 4]);
Some(ClickTarget::new_with_subpath(subpath, 0.))
} else {
None
};
// Update grip button click target, which is positioned to the left of the leftmost icon
let icons_width = if locked { GRID_SIZE as f64 } else { 0. };
let grip_offset_right_edge = node_top_left + DVec2::new(width as f64 - (GRID_SIZE as f64) / 2. - icons_width, 24.);
let subpath = Subpath::new_rounded_rectangle(DVec2::new(-8., -12.) + grip_offset_right_edge, DVec2::new(0., 12.) + grip_offset_right_edge, [0.; 4]);
let grip_click_target = ClickTarget::new_with_subpath(subpath, 0.);
@ -2552,6 +2564,7 @@ impl NodeNetworkInterface {
port_click_targets,
node_type_metadata: NodeTypeClickTargets::Layer(LayerClickTargets {
visibility_click_target,
lock_click_target,
grip_click_target,
}),
}
@ -2749,10 +2762,17 @@ impl NodeNetworkInterface {
}
}
if let NodeTypeClickTargets::Layer(layer_metadata) = &node_click_targets.node_type_metadata {
// Visibility button (eye icon)
if let ClickTargetType::Subpath(subpath) = layer_metadata.visibility_click_target.target_type() {
icon_click_targets.push(subpath.to_bezpath().to_svg());
}
// Lock button (padlock icon), only when the layer is locked
if let Some(lock_click_target) = &layer_metadata.lock_click_target
&& let ClickTargetType::Subpath(subpath) = lock_click_target.target_type()
{
icon_click_targets.push(subpath.to_bezpath().to_svg());
}
// Drag grip (dotted symbol)
if let ClickTargetType::Subpath(subpath) = layer_metadata.grip_click_target.target_type() {
icon_click_targets.push(subpath.to_bezpath().to_svg());
}
@ -2882,6 +2902,7 @@ impl NodeNetworkInterface {
if let NodeTypeClickTargets::Layer(layer) = &transient_node_metadata.node_type_metadata {
match click_target_type {
LayerClickTargetTypes::Visibility => layer.visibility_click_target.intersect_point_no_stroke(point).then_some(*node_id),
LayerClickTargetTypes::Lock => layer.lock_click_target.as_ref().and_then(|target| target.intersect_point_no_stroke(point).then_some(*node_id)),
LayerClickTargetTypes::Grip => layer.grip_click_target.intersect_point_no_stroke(point).then_some(*node_id),
}
} else {
@ -4508,6 +4529,8 @@ impl NodeNetworkInterface {
node_metadata.persistent_metadata.locked = locked;
self.transaction_modified();
self.try_unload_layer_width(node_id, network_path);
self.unload_node_click_targets(node_id, network_path);
}
pub fn set_to_node_or_layer(&mut self, node_id: &NodeId, network_path: &[NodeId], is_layer: bool) {
@ -6419,6 +6442,8 @@ pub enum NodeTypeClickTargets {
pub struct LayerClickTargets {
/// Cache for all visibility buttons. Should be automatically updated when update_click_target is called
pub visibility_click_target: ClickTarget,
/// Cache for the lock icon button, only present when the layer is locked.
pub lock_click_target: Option<ClickTarget>,
/// Cache for the grip icon, which is next to the visibility button.
pub grip_click_target: ClickTarget,
// TODO: Store click target for the preview button, which will appear when the node is a selected/(hovered?) layer node
@ -6427,6 +6452,7 @@ pub struct LayerClickTargets {
pub enum LayerClickTargetTypes {
Visibility,
Lock,
Grip,
// Preview,
}

View File

@ -234,6 +234,22 @@
disabled={!$nodeGraph.contextMenuInformation.contextMenuData.data.canBeLayer}
flush={true}
/>
{#if $nodeGraph.contextMenuInformation.contextMenuData.data.hasSelectedLayers}
{@const allLocked = $nodeGraph.contextMenuInformation.contextMenuData.data.allSelectedLayersLocked}
{@const nodeId = $nodeGraph.contextMenuInformation.contextMenuData.data.nodeId}
<TextButton
label={allLocked ? "Unlock" : "Lock"}
action={() => {
if ($nodeGraph.selected.includes(nodeId)) {
editor.handle.toggleSelectedLocked();
} else {
editor.handle.toggleLayerLock(nodeId);
}
nodeGraph.closeContextMenu();
}}
flush={true}
/>
{/if}
</LayoutCol>
{/if}
</FloatingMenu>
@ -493,6 +509,7 @@
class:in-selected-network={$nodeGraph.inSelectedNetwork}
class:previewed={node.previewed}
class:disabled={!node.visible}
class:locked={node.locked}
style:--offset-left={node.position?.x || 0}
style:--offset-top={node.position?.y || 0}
style:--clip-path-id={`url(#${clipPathId})`}
@ -582,6 +599,19 @@
<TextLabel>{node.displayName}</TextLabel>
</div>
<div class="solo-drag-grip" data-tooltip-description="Drag only this layer without pushing others outside the stack"></div>
{#if node.locked}
<IconButton
class="lock"
data-lock-button
size={24}
icon="PadlockLocked"
hoverIcon="PadlockUnlocked"
action={() => {
/* Button is purely visual, clicking is handled in NodeGraphMessage::PointerDown */
}}
tooltipLabel="Unlock"
/>
{/if}
<IconButton
class="visibility"
data-visibility-button
@ -1231,6 +1261,10 @@
border-radius: 2px;
}
&.locked .solo-drag-grip {
right: calc(-12px + 24px + 24px);
}
.solo-drag-grip:hover,
&.selected .solo-drag-grip {
background-image: var(--icon-drag-grip);
@ -1241,15 +1275,19 @@
}
.visibility {
position: absolute;
right: -12px;
}
.lock {
right: 12px;
}
.input.connectors {
left: calc(-3px + var(--node-chain-area-left-extension) * 24px - 36px);
}
.solo-drag-grip,
.lock,
.visibility,
.input.connectors,
.input.connectors .connector {

View File

@ -178,9 +178,25 @@ export type FrontendClickTargets = {
readonly modifyImportExport: string[];
};
type ContextMenuDataCreateNode = {
type: "CreateNode";
data: {
compatibleType: string | undefined;
};
};
type ContextMenuDataModifyNode = {
type: "ModifyNode";
data: {
nodeId: bigint;
canBeLayer: boolean;
currentlyIsNode: boolean;
hasSelectedLayers: boolean;
allSelectedLayersLocked: boolean;
};
};
export type ContextMenuInformation = {
contextMenuCoordinates: XY;
contextMenuData: { type: "CreateNode"; data: { compatibleType: string | undefined } } | { type: "ModifyNode"; data: { canBeLayer: boolean; currentlyIsNode: boolean; nodeId: bigint } };
contextMenuData: ContextMenuDataCreateNode | ContextMenuDataModifyNode;
};
export class UpdateContextMenuInformation extends JsMessage {

View File

@ -761,6 +761,13 @@ impl EditorHandle {
self.dispatch(message);
}
/// Toggle lock state of all selected layers
#[wasm_bindgen(js_name = toggleSelectedLocked)]
pub fn toggle_selected_locked(&self) {
let message = NodeGraphMessage::ToggleSelectedLocked;
self.dispatch(message);
}
/// Creates a new document node in the node graph
#[wasm_bindgen(js_name = createNode)]
pub fn create_node(&self, node_type: JsValue, x: i32, y: i32) {