Add support for double-clicking to rename layers in the graph view (#4071)

* Add support for double-clicking to rename layers in the graph view

* Fix text double-click area ending 10px too early from the right

* Fix clicking out and intermittent cursor clicking in text field
This commit is contained in:
Keavon Chambers 2026-04-28 17:30:25 -07:00 committed by GitHub
parent 6b11b47753
commit fc7348d08a
6 changed files with 173 additions and 26 deletions

View File

@ -1,5 +1,6 @@
// GRAPH
pub const GRID_SIZE: u32 = 24;
pub const HALF_GRID_SIZE: u32 = GRID_SIZE / 2;
pub const EXPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72;
pub const EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP: u32 = 120;
pub const IMPORTS_TO_TOP_EDGE_PIXEL_GAP: u32 = 72;

View File

@ -139,6 +139,11 @@ pub enum FrontendMessage {
preferences: PreferencesMessageHandler,
},
TriggerTextCommit,
/// Asks the frontend to enter inline-rename mode for a layer in the graph view.
TriggerEditLayerNameInGraph {
#[serde(rename = "nodeId")]
node_id: NodeId,
},
TriggerVisitLink {
url: String,
},

View File

@ -155,6 +155,11 @@ pub enum NodeGraphMessage {
input_connector: InputConnector,
input: NodeInput,
},
/// Asks the frontend to enter inline-rename mode for the given layer's display name in the graph view.
/// Triggered by double-clicking the layer's name area.
BeginEditLayerName {
node_id: NodeId,
},
SetDisplayName {
node_id: NodeId,
/// The path to the network containing `node_id`. Empty for nodes at the root document network.

View File

@ -405,10 +405,18 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
{
return;
};
// Double-clicking the layer's name area triggers inline rename instead of drilling into the subgraph.
if let Some(layer_id) = network_interface.layer_click_target_from_click(ipp.mouse.position, network_interface::LayerClickTargetTypes::Name, selection_network_path) {
responses.add(NodeGraphMessage::BeginEditLayerName { node_id: layer_id });
return;
}
if let Some(DocumentNodeImplementation::Network(_)) = network_interface.implementation(&node_id, selection_network_path) {
responses.add(DocumentMessage::EnterNestedNetwork { node_id });
}
}
NodeGraphMessage::BeginEditLayerName { node_id } => {
responses.add(FrontendMessage::TriggerEditLayerNameInGraph { node_id });
}
NodeGraphMessage::ExposeInput {
input_connector,
set_to_exposed,

View File

@ -6,7 +6,8 @@ use super::document_metadata::{DocumentMetadata, LayerNodeIdentifier, NodeRelati
use super::misc::PTZ;
use super::nodes::SelectedNodes;
use crate::consts::{
EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP, EXPORTS_TO_TOP_EDGE_PIXEL_GAP, GRID_SIZE, IMPORTS_TO_LEFT_EDGE_PIXEL_GAP, IMPORTS_TO_TOP_EDGE_PIXEL_GAP, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, STACK_VERTICAL_GAP,
EXPORTS_TO_RIGHT_EDGE_PIXEL_GAP, EXPORTS_TO_TOP_EDGE_PIXEL_GAP, GRID_SIZE, HALF_GRID_SIZE, IMPORTS_TO_LEFT_EDGE_PIXEL_GAP, IMPORTS_TO_TOP_EDGE_PIXEL_GAP, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH,
STACK_VERTICAL_GAP,
};
use crate::messages::portfolio::document::graph_operation::utility_types::ModifyInputsContext;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type};
@ -2503,7 +2504,7 @@ impl NodeNetworkInterface {
return;
};
let node_top_left = node_position.as_dvec2() * 24.;
let node_top_left = node_position.as_dvec2() * GRID_SIZE as f64;
let mut port_click_targets = Ports::new();
let document_node_click_targets = if !node_metadata.persistent_metadata.is_layer() {
// Create input/output click targets
@ -2528,9 +2529,10 @@ impl NodeNetworkInterface {
port_click_targets.insert_node_output(output_index, node_top_left);
}
let height = input_row_count.max(number_of_outputs).max(1) as u32 * crate::consts::GRID_SIZE;
let width = 5 * crate::consts::GRID_SIZE;
let node_click_target_top_left = node_top_left + DVec2::new(0., 12.);
let height = input_row_count.max(number_of_outputs).max(1) as u32 * GRID_SIZE;
let width = 5 * GRID_SIZE;
// Offset down by half a grid so the click target sits below the top connector strip.
let node_click_target_top_left = node_top_left + DVec2::new(0., HALF_GRID_SIZE as f64);
let node_click_target_bottom_right = node_click_target_top_left + DVec2::new(width as f64, height as f64);
let radius = 3.;
@ -2554,37 +2556,90 @@ impl NodeNetworkInterface {
log::error!("Could not get layer width in load_node_click_targets");
0
});
let width = layer_width_cells * crate::consts::GRID_SIZE;
let height = 2 * crate::consts::GRID_SIZE;
let width = layer_width_cells * GRID_SIZE;
let height = 2 * GRID_SIZE;
let locked = self.is_locked(node_id, network_path);
// The layer is `2 * GRID_SIZE` tall, so its vertical center sits one grid unit below `node_top_left.y`.
// Visibility/lock buttons fill a 1-grid-cell square (so half-extents of HALF_GRID_SIZE each side of center).
const LAYER_VERTICAL_CENTER: f64 = GRID_SIZE as f64;
const ICON_HALF_EXTENT: f64 = HALF_GRID_SIZE as f64;
// 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_offset = node_top_left + DVec2::new(width as f64, LAYER_VERTICAL_CENTER);
let subpath = Subpath::new_rounded_rectangle(
DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + visibility_offset,
DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + visibility_offset,
[3.; 4],
);
let visibility_click_target = ClickTarget::new_with_subpath(subpath, 0.);
// 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]);
let lock_offset = node_top_left + DVec2::new(width as f64 - GRID_SIZE as f64, LAYER_VERTICAL_CENTER);
let subpath = Subpath::new_rounded_rectangle(
DVec2::new(-ICON_HALF_EXTENT, -ICON_HALF_EXTENT) + lock_offset,
DVec2::new(ICON_HALF_EXTENT, ICON_HALF_EXTENT) + 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
// Update grip button click target, which is positioned to the left of the leftmost icon.
// The grip is 8px wide but spans the full layer-vertical-center band.
const GRIP_WIDTH: f64 = 8.;
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_offset_right_edge = node_top_left + DVec2::new(width as f64 - ICON_HALF_EXTENT - icons_width, LAYER_VERTICAL_CENTER);
let subpath = Subpath::new_rounded_rectangle(
DVec2::new(-GRIP_WIDTH, -ICON_HALF_EXTENT) + grip_offset_right_edge,
DVec2::new(0., ICON_HALF_EXTENT) + grip_offset_right_edge,
[0.; 4],
);
let grip_click_target = ClickTarget::new_with_subpath(subpath, 0.);
// Update display-name text click target, used to detect double-click rename. Sized to the text bounds
// (not the surrounding `.details` area) so the rest of the layer still drills into the subgraph on double-click.
/// `.layer` margin-left (= 12), for chain layers the negative margin-left and positive padding-left cancel out, keeping content at this same offset
const LAYER_LEFT_MARGIN: f64 = HALF_GRID_SIZE as f64;
/// `.thumbnail` (70px) + its 1px side margins (= 72)
const THUMBNAIL_BLOCK_WIDTH: f64 = 3. * GRID_SIZE as f64;
/// `.details` margin-left
const DETAILS_LEFT_MARGIN: f64 = 8.;
const NAME_LEFT_OFFSET: f64 = LAYER_LEFT_MARGIN + THUMBNAIL_BLOCK_WIDTH + DETAILS_LEFT_MARGIN;
/// Distance from layer's right edge to visibility's left edge (= 12)
const VISIBILITY_INSET_FROM_LAYER_RIGHT: f64 = HALF_GRID_SIZE as f64;
const FONT_SIZE: f64 = 14.;
let display_name = self.display_name(node_id, network_path);
let name_click_target = if display_name.is_empty() {
None
} else {
let name_left = node_top_left.x + NAME_LEFT_OFFSET;
let icons_reserve = VISIBILITY_INSET_FROM_LAYER_RIGHT + icons_width + GRIP_WIDTH;
let name_right_max = node_top_left.x + width as f64 - icons_reserve;
let text_w = crate::messages::portfolio::document::overlays::utility_functions::text_width(&display_name, FONT_SIZE);
let name_right = (name_left + text_w).min(name_right_max);
if name_right > name_left {
// The 1-grid-tall name strip is centered vertically in the 2-grid-tall layer.
let name_top = node_top_left.y + HALF_GRID_SIZE as f64;
let name_bottom = node_top_left.y + GRID_SIZE as f64 + HALF_GRID_SIZE as f64;
let subpath = Subpath::new_rounded_rectangle(DVec2::new(name_left, name_top), DVec2::new(name_right, name_bottom), [3.; 4]);
Some(ClickTarget::new_with_subpath(subpath, 0.))
} else {
None
}
};
// Create layer click target, which is contains the layer and the chain background
let chain_width_grid_spaces = self.chain_width(node_id, network_path);
let node_bottom_right = node_top_left + DVec2::new(width as f64, height as f64);
let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * crate::consts::GRID_SIZE) as f64, 0.);
let radius = 10.;
let subpath = Subpath::new_rounded_rectangle(chain_top_left, node_bottom_right, [radius; 4]);
let chain_top_left = node_top_left - DVec2::new((chain_width_grid_spaces * GRID_SIZE) as f64, 0.);
const CORNER_RADIUS: f64 = 10.;
let subpath = Subpath::new_rounded_rectangle(chain_top_left, node_bottom_right, [CORNER_RADIUS; 4]);
let node_click_target = ClickTarget::new_with_subpath(subpath, 0.);
DocumentNodeClickTargets {
@ -2594,6 +2649,7 @@ impl NodeNetworkInterface {
visibility_click_target,
lock_click_target,
grip_click_target,
name_click_target,
}),
}
};
@ -2932,6 +2988,7 @@ impl NodeNetworkInterface {
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),
LayerClickTargetTypes::Name => layer.name_click_target.as_ref().and_then(|target| target.intersect_point_no_stroke(point).then_some(*node_id)),
}
} else {
None
@ -6602,6 +6659,10 @@ pub struct LayerClickTargets {
pub lock_click_target: Option<ClickTarget>,
/// Cache for the grip icon, which is next to the visibility button.
pub grip_click_target: ClickTarget,
/// Cache for the layer's display-name text bounds. Used to detect double-click rename and
/// to skip the drill-into-subgraph behavior when the click lands on the name itself.
/// `None` for layers whose display name is empty.
pub name_click_target: Option<ClickTarget>,
// TODO: Store click target for the preview button, which will appear when the node is a selected/(hovered?) layer node
// preview_click_target: ClickTarget,
}
@ -6610,6 +6671,7 @@ pub enum LayerClickTargetTypes {
Visibility,
Lock,
Grip,
Name,
// Preview,
}

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { getContext } from "svelte";
import { getContext, onDestroy, onMount, tick } from "svelte";
import { cubicInOut } from "svelte/easing";
import { fade } from "svelte/transition";
import NodeCatalog from "/src/components/floating-menus/NodeCatalog.svelte";
@ -11,6 +11,7 @@
import type { DocumentStore } from "/src/stores/document";
import type { NodeGraphStore } from "/src/stores/node-graph";
import { closeContextMenu } from "/src/stores/node-graph";
import type { SubscriptionsRouter } from "/src/subscriptions-router";
import type { EditorWrapper, FrontendGraphInput, FrontendGraphOutput, FrontendNode } from "/wrapper/pkg/graphite_wasm_wrapper";
const GRID_COLLAPSE_SPACING = 10;
@ -20,6 +21,7 @@
const editor = getContext<EditorWrapper>("editor");
const nodeGraph = getContext<NodeGraphStore>("nodeGraph");
const documentState = getContext<DocumentStore>("document");
const subscriptions = getContext<SubscriptionsRouter>("subscriptions");
let graph: HTMLDivElement | undefined;
@ -35,6 +37,7 @@
let editingNameImportIndex: number | undefined = undefined;
let editingNameExportIndex: number | undefined = undefined;
let editingNameNodeId: bigint | undefined = undefined;
let editingNameText = "";
function exportsToEdgeTextInputWidth() {
@ -67,13 +70,10 @@
editingNameExportIndex = index;
}
function focusInput(currentName: string) {
async function focusInput(currentName: string) {
editingNameText = currentName;
setTimeout(() => {
if (inputElement) {
inputElement.focus();
}
}, 0);
await tick();
inputElement?.focus();
}
function setEditingImportName(event: Event) {
@ -94,6 +94,33 @@
}
}
function commitEditingNodeName(event: Event) {
if (editingNameNodeId === undefined || !(event.target instanceof HTMLInputElement)) return;
editor.setLayerName(editingNameNodeId, event.target.value);
editingNameNodeId = undefined;
}
onMount(() => {
// Backend dispatches this when the user double-clicks a layer's name area
subscriptions.subscribeFrontendMessage("TriggerEditLayerNameInGraph", async (data) => {
const node = $nodeGraph.nodes.get(data.nodeId);
if (!node) return;
editingNameText = node.displayName;
editingNameNodeId = data.nodeId;
await tick();
inputElement?.focus();
inputElement?.select();
});
});
onDestroy(() => {
subscriptions.unsubscribeFrontendMessage("TriggerEditLayerNameInGraph");
});
function calculateGridSpacing(scale: number): number {
const dense = scale * GRID_SIZE;
let sparse = dense;
@ -595,8 +622,30 @@
</div>
{/if}
<div class="details">
<!-- TODO: Allow the user to edit the name, just like in the Layers panel -->
<TextLabel>{node.displayName}</TextLabel>
{#if editingNameNodeId === node.id}
<input
class="layer-name-input"
type="text"
bind:this={inputElement}
bind:value={editingNameText}
on:pointerdown|stopPropagation
on:dblclick|stopPropagation
on:blur={commitEditingNodeName}
on:keydown={(e) => {
// Stop propagation when we handle the key ourselves so the global keyboard forwarder (`shouldRedirectKeyboardEventToBackend`) doesn't also dispatch them.
// Its Escape carve-out would otherwise close the graph view, and Enter could trigger unrelated bindings.
if (e.key === "Enter") {
commitEditingNodeName(e);
e.stopPropagation();
} else if (e.key === "Escape") {
editingNameNodeId = undefined;
e.stopPropagation();
}
}}
/>
{:else}
<TextLabel>{node.displayName}</TextLabel>
{/if}
</div>
<div class="solo-drag-grip" data-tooltip-description="Drag only this layer without pushing others outside the stack"></div>
{#if node.locked}
@ -1245,12 +1294,29 @@
}
.details {
display: flex;
align-items: center;
margin: 0 8px;
.text-label {
white-space: nowrap;
line-height: 48px;
}
.layer-name-input {
color: inherit;
background: var(--color-1-nearblack);
border: none;
outline: none;
margin: 0 -4px;
padding: 0 4px;
height: 24px;
border-radius: 2px;
field-sizing: content;
// Stack above the absolutely-positioned grip/lock/visibility siblings, which can otherwise overlap the input's right edge and hijack clicks there.
position: relative;
z-index: 1;
}
}
.solo-drag-grip {