From 43b3da0bf6e50cb43f4c48a5f88333924b2c8e9d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 13:40:40 +0200 Subject: [PATCH 01/11] feat: v1 of new tool system (cherry picked from commit f470be8be175844217982234fdea3cc37236e368) --- src/ui/skeleton_edit_tool_messages.ts | 13 +- src/ui/skeleton_edit_tools.css | 33 + src/ui/skeleton_edit_tools.spec.ts | 285 ++--- src/ui/skeleton_edit_tools.ts | 1555 +++++++++++++------------ src/ui/skeleton_tab.ts | 22 +- 5 files changed, 947 insertions(+), 961 deletions(-) diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index b26cf19749..8622ed554c 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -41,10 +41,17 @@ export const SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE = "Move node or append to selected node"; export const SPATIAL_SKELETON_MERGE_BANNER_MESSAGE = "Select 2 nodes to merge"; export const SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE = - "Select 2nd node from a different skeleton to merge with"; + "Select 2nd node from a different skeleton to merge with · release m to exit"; export const SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE = "Select 1 node to split"; export const SPATIAL_SKELETON_MOVING_NODE_MESSAGE = "Moving node"; +export const SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE = + "Click node to select · drag to move · hold m to merge · s to split · hold n to create · ctrl+click to add"; +export const SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE = + "Node selected · drag to move · ctrl+click to add child · hold m to merge · s to split · hold n to create"; +export const SPATIAL_SKELETON_CREATE_BANNER_MESSAGE = + "Click to place a new skeleton · release n to exit"; + export function formatSpatialSkeletonToolPoint( point: SpatialSkeletonToolPointInfo, ) { @@ -100,8 +107,8 @@ export function getSpatialSkeletonEditBannerMessage( selectedPoint: SpatialSkeletonToolPointInfo | undefined, ) { return selectedPoint === undefined - ? SPATIAL_SKELETON_EDIT_BANNER_MESSAGE - : SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE; + ? SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE + : SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE; } export function getSpatialSkeletonMergeBannerMessage( diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index 5858f92b15..fde9702ba1 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -45,3 +45,36 @@ .neuroglancer-skeleton-tool-status-point-field-value { color: inherit; } + +/* Per-mode cursor indicators — driven by data-skeleton-edit-mode on the panel element */ + +/* Shift held: left-click will translate (perspective) or rotate (slice) via NG's + default shift+mousedown0 binding, not grab a skeleton node. */ +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="shift"] { + cursor: move; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="add"] { + cursor: url("data:image/svg+xml,+") 16 16, crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { + cursor: url("data:image/svg+xml,M") 16 16, crosshair; +} + +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { + cursor: url("data:image/svg+xml,N") 16 16, crosshair; +} + +/* Transient press-state cursors — driven by data-skeleton-press-mode on the panel element */ +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="rotate"] { + cursor: move; +} + +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="pan"] { + cursor: grab; +} + +.neuroglancer-rendered-data-panel[data-skeleton-press-mode="move"] { + cursor: grabbing; +} diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 698b9c0596..4c60a6c762 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -46,11 +46,9 @@ if (!("WebGL2RenderingContext" in globalThis)) { const { setSpatialSkeletonModesToLinesAndPoints, SkeletonRenderMode } = await import("#src/skeleton/frontend.js"); -const { SpatialSkeletonEditModeTool } = await import( +const { SpatialSkeletonEditTool } = await import( "#src/ui/skeleton_edit_tools.js" ); -const { SpatialSkeletonMergeModeTool, SpatialSkeletonSplitModeTool } = - await import("#src/ui/skeleton_edit_tools.js"); function makeVisibleSegmentsState(initialVisibleSegments: bigint[] = []) { return { @@ -155,26 +153,6 @@ function makeChangedSignal() { }; } -function makeManualChangedSignal() { - const listeners: Array<() => void> = []; - return { - add: vi.fn((listener: () => void) => { - listeners.push(listener); - return () => { - const index = listeners.indexOf(listener); - if (index !== -1) { - listeners.splice(index, 1); - } - }; - }), - dispatch() { - for (const listener of listeners.slice()) { - listener(); - } - }, - }; -} - function makeModeWatchable(value = false) { return { value }; } @@ -492,7 +470,7 @@ describe("spatial_skeleton_edit_tool", () => { it("blocks appending a child to a selected true-end node", () => { const getAddNodeBlockedReason = ( - SpatialSkeletonEditModeTool.prototype as any + SpatialSkeletonEditTool.prototype as any ).getAddNodeBlockedReason as ( this: any, skeletonLayer: any, @@ -516,7 +494,7 @@ describe("spatial_skeleton_edit_tool", () => { }, }, getSelectedParentNodeForAdd: ( - SpatialSkeletonEditModeTool.prototype as any + SpatialSkeletonEditTool.prototype as any ).getSelectedParentNodeForAdd, }; @@ -648,10 +626,10 @@ describe("spatial_skeleton_edit_tool", () => { ).toHaveBeenCalledWith([firstNode.position, secondNode.position]); }); - it("clears the merge anchor when the clear-selection action runs in merge mode", () => { + it("clears the merge anchor when the clear-selection action runs with an active merge anchor", () => { suppressStatusMessages(); const bindClearSelectionAction = ( - SpatialSkeletonEditModeTool.prototype as any + SpatialSkeletonEditTool.prototype as any ).bindClearSelectionAction as (this: any, activation: any) => void; const clearSpatialSkeletonNodeSelection = vi.fn(); const clearSpatialSkeletonMergeAnchor = vi.fn(); @@ -703,13 +681,13 @@ describe("spatial_skeleton_edit_tool", () => { expect(unpin).not.toHaveBeenCalled(); }); - it("uses an existing selected node as the merge anchor when merge mode activates", () => { + it("enters merge mode from the hovered node when the merge action fires", () => { suppressStatusMessages(); - const selectedNode = { + const hoveredNode = { nodeId: 101, segmentId: 11, position: new Float32Array([1, 2, 3]), - sourceState: testSourceState("selected-before"), + sourceState: testSourceState("hovered"), }; const mergeAnchorNodeId = { value: undefined as number | undefined, @@ -724,12 +702,22 @@ describe("spatial_skeleton_edit_tool", () => { mergeAnchorNodeId.value = undefined; return true; }); - const clearSpatialSkeletonNodeSelection = vi.fn(); const skeletonLayer = { getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, + nodeId === hoveredNode.nodeId ? hoveredNode : undefined, ), }; + const mouseState = { + pickedRenderLayer: undefined, + pickedSpatialSkeleton: { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, + position: hoveredNode.position, + sourceState: hoveredNode.sourceState, + }, + updateUnconditionally: vi.fn(() => true), + active: true, + }; const layer = { displayState: { ...makeSkeletonRenderingOptions(), @@ -737,241 +725,204 @@ describe("spatial_skeleton_edit_tool", () => { value: makeVisibleSegmentsState([11n]), }, }, + spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { - value: selectedNode, + value: undefined, changed: makeChangedSignal(), }, spatialSkeletonState: { mergeAnchorNodeId, getCachedNode: vi.fn(), + commandHistory: new SpatialSkeletonCommandHistory(), + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState: { - pickedRenderLayer: undefined, - updateUnconditionally: vi.fn(() => true), - active: true, - }, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), + selectSegment: vi.fn(), selectSpatialSkeletonNode, setSpatialSkeletonMergeAnchor, clearSpatialSkeletonMergeAnchor, - clearSpatialSkeletonNodeSelection, + clearSpatialSkeletonNodeSelection: vi.fn(), layersChanged: makeChangedSignal(), }; - const { activation, dispose } = makeToolActivation(); - const tool = Object.assign( - Object.create(SpatialSkeletonMergeModeTool.prototype), - { layer }, - ); + const { activation, actions, dispose } = makeToolActivation(); + const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); try { - SpatialSkeletonMergeModeTool.prototype.activate.call( - tool, - activation as any, - ); + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); + + // Fire the merge action (simulates pressing "m" while hovering node 101). + actions.get("spatial-skeleton-enter-merge")?.({}); expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( - selectedNode.nodeId, + hoveredNode.nodeId, true, - selectedNode, + expect.objectContaining({ nodeId: hoveredNode.nodeId }), ); expect(setSpatialSkeletonMergeAnchor).toHaveBeenCalledWith( - selectedNode.nodeId, + hoveredNode.nodeId, ); - expect(clearSpatialSkeletonNodeSelection).not.toHaveBeenCalled(); + expect(layer.spatialSkeletonMergeMode.value).toBe(true); } finally { dispose(); } }); - it("clears the merge anchor when a pick clears the selected node", () => { + it("executes a split on the hovered node when the split action fires", () => { suppressStatusMessages(); - const selectedNode = { - nodeId: 101, + const hoveredNode = { + nodeId: 77, segmentId: 11, - position: new Float32Array([1, 2, 3]), - sourceState: testSourceState("selected-before"), - }; - const selectedNodeChanged = makeManualChangedSignal(); - const mergeAnchorNodeId = { - value: undefined as number | undefined, - changed: makeChangedSignal(), + position: new Float32Array([7, 8, 9]), + sourceState: testSourceState("hovered"), }; - const selectSegment = vi.fn(); - const setSpatialSkeletonMergeAnchor = vi.fn((nodeId: number) => { - mergeAnchorNodeId.value = nodeId; - return true; - }); - const clearSpatialSkeletonMergeAnchor = vi.fn(() => { - mergeAnchorNodeId.value = undefined; - return true; - }); + const splitExecute = vi.fn(async () => {}); + const splitSkeletonsCommand = makeCommandFactory( + SpatialSkeletonActions.splitSkeletons, + splitExecute, + ); const skeletonLayer = { + source: makeCommandSkeletonSource({ splitSkeletonsCommand }), getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, + nodeId === hoveredNode.nodeId ? hoveredNode : undefined, ), }; const mouseState = { pickedRenderLayer: undefined, - pickedSpatialSkeleton: { segmentId: 17 }, + pickedSpatialSkeleton: { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, + position: hoveredNode.position, + sourceState: hoveredNode.sourceState, + }, updateUnconditionally: vi.fn(() => true), active: true, }; + const selectSegment = vi.fn(); + const selectSpatialSkeletonNode = vi.fn(); const layer = { displayState: { ...makeSkeletonRenderingOptions(), segmentationGroupState: { - value: makeVisibleSegmentsState([11n, 17n]), + value: makeVisibleSegmentsState([11n]), }, }, + spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { - value: selectedNode as typeof selectedNode | undefined, - changed: selectedNodeChanged, + value: undefined, + changed: makeChangedSignal(), }, spatialSkeletonState: { - mergeAnchorNodeId, + commandHistory: new SpatialSkeletonCommandHistory(), getCachedNode: vi.fn(), + mergeAnchorNodeId: { value: undefined, changed: makeChangedSignal() }, + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), selectSegment, - selectSpatialSkeletonNode: vi.fn(), - setSpatialSkeletonMergeAnchor, - clearSpatialSkeletonMergeAnchor, - clearSpatialSkeletonNodeSelection: vi.fn(), + selectSpatialSkeletonNode, layersChanged: makeChangedSignal(), }; const { activation, actions, dispose } = makeToolActivation(); - const tool = Object.assign( - Object.create(SpatialSkeletonMergeModeTool.prototype), - { layer }, - ); + const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); try { - SpatialSkeletonMergeModeTool.prototype.activate.call( - tool, - activation as any, + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); + + // Fire the split action (simulates pressing "s" while hovering node 77). + actions.get("spatial-skeleton-split")?.({}); + + expect(selectSegment).toHaveBeenCalledWith(11n, true); + expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( + hoveredNode.nodeId, + true, + expect.objectContaining({ nodeId: hoveredNode.nodeId }), ); - clearSpatialSkeletonMergeAnchor.mockClear(); - - actions.get("spatial-skeleton-pick-node")?.({ - detail: { - button: 2, - ctrlKey: true, - shiftKey: false, - altKey: false, - metaKey: false, - }, + expect(splitSkeletonsCommand.createCommand).toHaveBeenCalledWith(layer, { + nodeId: hoveredNode.nodeId, + segmentId: hoveredNode.segmentId, }); - layer.selectedSpatialSkeletonNodeInfo.value = undefined; - selectedNodeChanged.dispatch(); - - expect(selectSegment).toHaveBeenCalledWith(17n, true); - expect(clearSpatialSkeletonMergeAnchor).toHaveBeenCalledTimes(1); - expect(mergeAnchorNodeId.value).toBeUndefined(); + expect(splitExecute).toHaveBeenCalledTimes(1); } finally { dispose(); } }); - it("splits the existing selected node immediately when split mode activates", () => { + it("errors when ctrl+click has no selected parent node", () => { suppressStatusMessages(); - const selectedNode = { - nodeId: 77, - segmentId: 11, - position: new Float32Array([7, 8, 9]), - sourceState: testSourceState("selected-before"), - }; - const splitExecute = vi.fn(async () => {}); - const splitSkeletonsCommand = makeCommandFactory( - SpatialSkeletonActions.splitSkeletons, - splitExecute, - ); const skeletonLayer = { - source: makeCommandSkeletonSource({ splitSkeletonsCommand }), - getNode: vi.fn((nodeId: number) => - nodeId === selectedNode.nodeId ? selectedNode : undefined, - ), + getNode: vi.fn(), + }; + const mouseState = { + pickedRenderLayer: undefined, + pickedSpatialSkeleton: undefined, + updateUnconditionally: vi.fn(() => true), + active: true, + unsnappedPosition: new Float32Array([1, 2, 3]), }; - const selectSegment = vi.fn(); - const selectSpatialSkeletonNode = vi.fn(); const layer = { displayState: { ...makeSkeletonRenderingOptions(), segmentationGroupState: { - value: makeVisibleSegmentsState([11n]), + value: makeVisibleSegmentsState(), }, }, - spatialSkeletonSplitMode: makeModeWatchable(), - selectedSpatialSkeletonNodeInfo: { value: selectedNode }, + spatialSkeletonEditMode: makeModeWatchable(), + spatialSkeletonMergeMode: makeModeWatchable(), + selectedSpatialSkeletonNodeInfo: { + value: undefined, // No node selected. + changed: makeChangedSignal(), + }, spatialSkeletonState: { commandHistory: new SpatialSkeletonCommandHistory(), getCachedNode: vi.fn(), + mergeAnchorNodeId: { value: undefined, changed: makeChangedSignal() }, + clearPendingNodePositions: vi.fn(), }, manager: { root: { - layerSelectedValues: { - mouseState: { - pickedRenderLayer: undefined, - updateUnconditionally: vi.fn(() => true), - active: true, - }, - }, - selectionState: { - value: undefined, - }, + layerSelectedValues: { mouseState }, + selectionState: { value: undefined, changed: makeChangedSignal() }, + display: { panels: [] }, }, }, getSpatiallyIndexedSkeletonLayer: () => skeletonLayer, getSpatialSkeletonActionsDisabledReason: vi.fn(() => undefined), - selectSegment, - selectSpatialSkeletonNode, + selectSegment: vi.fn(), + selectSpatialSkeletonNode: vi.fn(), layersChanged: makeChangedSignal(), }; - const { activation, dispose } = makeToolActivation(); - const tool = Object.assign( - Object.create(SpatialSkeletonSplitModeTool.prototype), - { layer }, - ); + const { activation, actions, dispose } = makeToolActivation(); + const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); try { - SpatialSkeletonSplitModeTool.prototype.activate.call( - tool, - activation as any, - ); + SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); - expect(selectSegment).toHaveBeenCalledWith(11n, true); - expect(selectSpatialSkeletonNode).toHaveBeenCalledWith( - selectedNode.nodeId, - true, - selectedNode, - ); - expect(splitSkeletonsCommand.createCommand).toHaveBeenCalledWith(layer, { - nodeId: selectedNode.nodeId, - segmentId: selectedNode.segmentId, + actions.get("spatial-skeleton-add-node")?.({ + stopPropagation: vi.fn(), + detail: { preventDefault: vi.fn() }, }); - expect(splitExecute).toHaveBeenCalledTimes(1); + + expect(StatusMessage.showTemporaryMessage).toHaveBeenCalledWith( + expect.stringContaining("Select a node first"), + ); } finally { dispose(); } diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index a0dbe861c8..416571387a 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -21,6 +21,7 @@ import { getSegmentIdFromLayerSelectionValue, hasSpatialSkeletonNodeSelection, } from "#src/layer/segmentation/selection.js"; +import { PerspectivePanel } from "#src/perspective_view/panel.js"; import { getChunkPositionFromCombinedGlobalLocalPositions } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; @@ -48,12 +49,11 @@ import { import { StatusMessage } from "#src/status.js"; import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; import { - SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, - getSpatialSkeletonEditBannerMessage, - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, + SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - getSpatialSkeletonToolPointStatusFields, SPATIAL_SKELETON_MOVING_NODE_MESSAGE, + getSpatialSkeletonEditBannerMessage, + getSpatialSkeletonToolPointStatusFields, } from "#src/ui/skeleton_edit_tool_messages.js"; import type { ToolActivation } from "#src/ui/tool.js"; import { @@ -68,13 +68,30 @@ import { vec3 } from "#src/util/geom.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; export const SPATIAL_SKELETON_EDIT_MODE_TOOL_ID = "spatialSkeletonEditMode"; -export const SPATIAL_SKELETON_MERGE_MODE_TOOL_ID = "spatialSkeletonMergeMode"; -export const SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID = "spatialSkeletonSplitMode"; -const SKELETON_EDIT_STATUS_INPUT_EVENT_MAP = EventActionMap.fromObject({ - // Only expose the primary edit actions in the auto-generated subtitle. +// Internal mode enum — only Default, Merge, Create are sustained states. +// Move and Select are both handled in Default; Split is a one-shot keydown with no sustained mode. +const enum SkeletonEditMode { + Default = 0, + Merge = 1, + Create = 2, +} + +// mousedown0 is intentionally absent — it is handled via capture-phase DOM +// listeners registered in activate() so that off-node clicks pass through to +// NG's own rotate/pan EventActionMap actions (which would be shadowed if we +// bound mousedown0 here at POSITIVE_INFINITY priority). +// +// mousedown1 (middle) is bound here for perspective-panel rotation. Slice +// panels intercept middle mouse in the capture listener below and call +// translateByViewportPixels directly, consuming the event before +// MouseEventBinder can dispatch this action. +const SKELETON_UNIFIED_INPUT_EVENT_MAP = EventActionMap.fromObject({ + "at:mousedown1": "rotate-via-mouse-drag", "at:control+mousedown0": "spatial-skeleton-add-node", - "at:alt+mousedown0": "spatial-skeleton-move-node", + "at:keym": "spatial-skeleton-enter-merge", + "at:keys": "spatial-skeleton-split", + "at:keyn": "spatial-skeleton-enter-create", "at:control+mousedown2": { action: "spatial-skeleton-pin-node", stopPropagation: true, @@ -87,6 +104,7 @@ const SKELETON_EDIT_STATUS_INPUT_EVENT_MAP = EventActionMap.fromObject({ }, }); +// Bound via the raw inputEventMapBinder so it stays out of the auto-generated subtitle. const SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ "at:shift+control+mousedown2": { action: "spatial-skeleton-clear-node-selection", @@ -95,15 +113,7 @@ const SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ }, }); -const SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP = EventActionMap.fromObject({ - "at:control+mousedown2": { - action: "spatial-skeleton-pick-node", - stopPropagation: true, - preventDefault: true, - }, -}); - -const DRAG_START_DISTANCE_PX = 4; +const DRAG_START_DISTANCE_PX = 2; function waitForNextAnimationFrame() { return new Promise((resolve) => { @@ -413,58 +423,9 @@ abstract class SpatialSkeletonToolBase extends LayerTool modeWatchable.value = false; }); } - - protected registerAutoCancelOnDisabled( - activation: ToolActivation, - requiredActions: Parameters< - SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] - >[0], - onReady?: () => void, - ) { - const handleStateChanged = () => { - const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( - requiredActions, - { - ignoreCommandBusy: true, - }, - ); - if (disabledReason === undefined) { - onReady?.(); - return; - } - StatusMessage.showTemporaryMessage(disabledReason); - activation.cancel(); - }; - activation.registerDisposer( - this.layer.layersChanged.add(handleStateChanged), - ); - } - - protected cancelActivationIfPreconditionsFail( - activation: ToolActivation, - requiredAction: Parameters< - SegmentationUserLayer["getSpatialSkeletonActionsDisabledReason"] - >[0], - ): boolean { - const reason = - this.layer.getSpatialSkeletonActionsDisabledReason(requiredAction); - if (reason !== undefined) { - StatusMessage.showTemporaryMessage(reason); - queueMicrotask(() => activation.cancel()); - return false; - } - if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - queueMicrotask(() => activation.cancel()); - return false; - } - return true; - } } -export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { +export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { toJSON() { return SPATIAL_SKELETON_EDIT_MODE_TOOL_ID; } @@ -479,13 +440,6 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { private readonly dragGlobalAnchorPosition = vec3.create(); private readonly dragGlobalPosition = vec3.create(); - // TODO (skm): really we can't handle a rank change right now - // and heavily assume rank 3. This is likely mostly fine - // but need to test a little more how it works if embedded in - // higher dim spaces or alongside images with a t dim / channel dim - // can also possibly remove this and just set tempChunkPosition - // to be vec3 instead of Float32Array - // will verify and clean up private handleRankChanged(rank: number) { if (rank === this.curChunkRank) return; this.curChunkRank = rank; @@ -555,119 +509,254 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { return undefined; } - private getRenderedDataPanelForEvent( - event: MouseEvent, - ): RenderedDataPanel | undefined { - const display = this.layer.manager.root.display; - const target = event.target; - if (target instanceof Node) { - for (const panel of display.panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - if (panel.element.contains(target)) { - return panel; - } - } - } - const clientX = event.clientX; - const clientY = event.clientY; - for (const panel of display.panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - const rect = panel.element.getBoundingClientRect(); - if ( - clientX >= rect.left && - clientX <= rect.right && - clientY >= rect.top && - clientY <= rect.bottom - ) { - return panel; - } - } - return undefined; - } - activate(activation: ToolActivation) { + const tool = this; const { layer } = this; const rawInputEventMapBinder = activation.inputEventMapBinder; const { body, header } = makeToolActivationStatusMessageWithHeader(activation); header.textContent = "Skeleton edit"; + + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + renderSpatialSkeletonToolStatus(body, { message: disabledReason }); + queueMicrotask(() => activation.cancel()); + return; + } + if (tool.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + const msg = "No spatially indexed skeleton source is currently loaded."; + StatusMessage.showTemporaryMessage(msg); + renderSpatialSkeletonToolStatus(body, { message: msg }); + queueMicrotask(() => activation.cancel()); + return; + } + + // Keep editMode on for the entire tool lifetime — drives the lines+points render mode. + tool.activateModeWatchable(activation, layer.spatialSkeletonEditMode); + activation.bindInputEventMap(SKELETON_UNIFIED_INPUT_EVENT_MAP); + rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); + tool.bindPinnedSelectionAction(activation, { showNodeSelectionMessage: false }); + tool.bindClearSelectionAction(activation); + + let currentMode: SkeletonEditMode = SkeletonEditMode.Default; + let dragInProgress = false; + let pending = false; + let createPlacedThisHold = false; + // One-shot guards: prevent repeated fires while a key is held down. + let mergeKeyHeld = false; + let splitKeyHeld = false; + // Modifier-held state drives cursor indicators and blocks node actions. + let ctrlHeld = false; + let shiftHeld = false; let statusOverride: string | undefined; - let cachedNodeSummary: - | ReturnType - | undefined; - const clearCachedNodeSummary = () => { - cachedNodeSummary = undefined; + let statusPoint: SpatialSkeletonToolPointInfo | undefined; + + // --- Cursor helpers --- + + const setModeAttribute = (mode: string | undefined) => { + const { display } = layer.manager.root; + for (const panel of display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + if (mode === undefined) { + delete panel.element.dataset.skeletonEditMode; + } else { + panel.element.dataset.skeletonEditMode = mode; + } + } + }; + + // Recomputes the correct data-skeleton-edit-mode attribute from current + // mode + held modifiers so callers don't have to care about that interaction. + // Priority: sustained tool modes > ctrl (add) > shift (translate hint). + const updateModeAttribute = () => { + if (currentMode === SkeletonEditMode.Merge) { + setModeAttribute("merge"); + } else if (currentMode === SkeletonEditMode.Create) { + setModeAttribute("create"); + } else if (ctrlHeld) { + setModeAttribute("add"); + } else if (shiftHeld) { + setModeAttribute("shift"); + } else { + setModeAttribute(undefined); + } }; + + activation.registerDisposer(() => setModeAttribute(undefined)); + + // --- Status rendering --- + const renderStatus = () => { - const selectedPoint = - cachedNodeSummary ?? this.getSelectedSpatialSkeletonNodeSummary(); + if (statusOverride !== undefined) { + renderSpatialSkeletonToolStatus(body, { + message: statusOverride, + point: statusPoint, + }); + return; + } + if (currentMode === SkeletonEditMode.Merge) { + const anchorNodeId = + layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId !== undefined) { + const cachedNode = + tool.getActiveSpatiallyIndexedSkeletonLayer()?.getNode( + anchorNodeId, + ) ?? layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const point: SpatialSkeletonToolPointInfo = { + nodeId: anchorNodeId, + segmentId: cachedNode?.segmentId, + position: cachedNode?.position, + }; + if ( + cachedNode?.segmentId !== undefined && + !tool.isSpatialSkeletonSegmentVisible(cachedNode.segmentId) + ) { + renderSpatialSkeletonToolStatus(body, { + message: + "Make this segment visible, then select a 2nd node to merge with · release m to exit", + point, + }); + } else { + renderSpatialSkeletonToolStatus(body, { + message: SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, + point, + }); + } + } else { + renderSpatialSkeletonToolStatus(body, { + message: "Select a node to merge with · release m to exit", + }); + } + return; + } + if (currentMode === SkeletonEditMode.Create) { + renderSpatialSkeletonToolStatus(body, { + message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, + }); + return; + } + // Default mode + const selectedPoint = tool.getSelectedSpatialSkeletonNodeSummary(); renderSpatialSkeletonToolStatus(body, { - message: - statusOverride ?? getSpatialSkeletonEditBannerMessage(selectedPoint), + message: getSpatialSkeletonEditBannerMessage(selectedPoint), point: selectedPoint, }); }; - const setStatus = (nextStatus: string | undefined) => { - statusOverride = nextStatus; + + const setStatus = ( + message: string | undefined, + point?: SpatialSkeletonToolPointInfo, + ) => { + statusOverride = message; + statusPoint = point; renderStatus(); }; - const setReadyStatus = () => { - setStatus(undefined); + + const clearStatus = () => setStatus(undefined, undefined); + + // --- Mode transitions --- + + const enterMerge = (anchorNode: { + nodeId: number; + segmentId?: number; + position?: SpatialSkeletonVector; + sourceState?: SpatialSkeletonSourceState; + }) => { + if (anchorNode.segmentId !== undefined) { + tool.pinSegmentByNumber(anchorNode.segmentId); + } + layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); + layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); + layer.spatialSkeletonMergeMode.value = true; + currentMode = SkeletonEditMode.Merge; + updateModeAttribute(); + renderStatus(); }; - const disableWithMessage = (message: string) => { - setStatus(message); - StatusMessage.showTemporaryMessage(message); - queueMicrotask(() => activation.cancel()); + const exitMerge = () => { + if (currentMode !== SkeletonEditMode.Merge) return; + layer.clearSpatialSkeletonMergeAnchor(); + layer.spatialSkeletonMergeMode.value = false; + currentMode = SkeletonEditMode.Default; + updateModeAttribute(); + clearStatus(); }; - const getEditSupportDisabledReason = () => - layer.getSpatialSkeletonActionsDisabledReason( - [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], - { - ignoreCommandBusy: true, - requireVisibleChunks: false, - }, - ); - const getEditMutationDisabledReason = () => - layer.getSpatialSkeletonActionsDisabledReason([ - SpatialSkeletonActions.addNodes, - SpatialSkeletonActions.moveNodes, - ]); - const updateInteractionStatus = () => { - const reason = getEditMutationDisabledReason(); - if (reason === undefined) { - setReadyStatus(); - return undefined; - } - const message = `${reason} Node selection is still available.`; - setStatus(message); - return reason; + const enterCreate = () => { + currentMode = SkeletonEditMode.Create; + createPlacedThisHold = false; + updateModeAttribute(); + renderStatus(); }; - const disabledReason = getEditSupportDisabledReason(); - if (disabledReason !== undefined) { - disableWithMessage(disabledReason); - return; - } - if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { - disableWithMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } + const exitCreate = () => { + if (currentMode !== SkeletonEditMode.Create) return; + currentMode = SkeletonEditMode.Default; + createPlacedThisHold = false; + updateModeAttribute(); + clearStatus(); + }; - this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); - activation.bindInputEventMap(SKELETON_EDIT_STATUS_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindPinnedSelectionAction(activation, { - showNodeSelectionMessage: false, - }); - this.bindClearSelectionAction(activation); - updateInteractionStatus(); activation.registerDisposer(() => { + layer.spatialSkeletonMergeMode.value = false; layer.spatialSkeletonState.clearPendingNodePositions(); }); + + // Sync ctrlHeld/shiftHeld from the logical modifier flags on any event that + // carries them. This mirrors what NG's EventActionMap does via + // getEventModifierMask, so OS-level modifier rebindings are transparent — + // we never inspect specific key codes for ctrl or shift. + const syncModifiers = (event: { ctrlKey: boolean; shiftKey: boolean }) => { + const isCtrl = event.ctrlKey; + const isShift = event.shiftKey; + if (ctrlHeld === isCtrl && shiftHeld === isShift) return; + ctrlHeld = isCtrl; + shiftHeld = isShift; + updateModeAttribute(); + }; + + // Release held-key modes on keyup or when the window loses focus. + const onKeyDown = (event: KeyboardEvent) => { + syncModifiers(event); + }; + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === "KeyM") { + mergeKeyHeld = false; + exitMerge(); + } + if (event.code === "KeyN") exitCreate(); + if (event.code === "KeyS") splitKeyHeld = false; + syncModifiers(event); + }; + // mousemove catches modifiers pressed/released while keyboard focus is + // outside the panel (e.g. a text input elsewhere in the UI). + const onMouseMove = (event: MouseEvent) => { + syncModifiers(event); + }; + const onBlur = () => { + mergeKeyHeld = false; + splitKeyHeld = false; + ctrlHeld = false; + shiftHeld = false; + exitMerge(); + exitCreate(); + updateModeAttribute(); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("blur", onBlur); + activation.registerDisposer(() => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("blur", onBlur); + }); + activation.registerDisposer( layer.selectedSpatialSkeletonNodeInfo.changed.add(renderStatus), ); @@ -675,709 +764,645 @@ export class SpatialSkeletonEditModeTool extends SpatialSkeletonToolBase { layer.manager.root.selectionState.changed.add(renderStatus), ); activation.registerDisposer( - layer.spatialSkeletonState.commandHistory.isBusy.changed.add( - updateInteractionStatus, + layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(renderStatus), + ); + activation.registerDisposer( + layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( + renderStatus, ), ); activation.registerDisposer( layer.layersChanged.add(() => { - const supportReason = getEditSupportDisabledReason(); - if (supportReason !== undefined) { - StatusMessage.showTemporaryMessage(supportReason); - activation.cancel(); - return; - } - const reason = updateInteractionStatus(); + const reason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); if (reason !== undefined) { StatusMessage.showTemporaryMessage(reason); - return; + activation.cancel(); } - setReadyStatus(); }), ); + renderStatus(); + + // --- Mode key action handlers --- + activation.bindAction( - "spatial-skeleton-add-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.addNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { + "spatial-skeleton-enter-merge", + (_event: ActionEvent) => { + // mergeKeyHeld prevents the error toast from repeating on key-repeat + // events while m is held without a hovered node. + if (mergeKeyHeld || dragInProgress || pending || currentMode !== SkeletonEditMode.Default) return; + mergeKeyHeld = true; + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) return; + const pickedNode = + tool.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", + "Hover over a skeleton node to enter merge mode.", ); return; } - const selectedParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - const addNodeBlockedReason = this.getAddNodeBlockedReason( - skeletonLayer, - selectedParentNodeId, - ); - if (addNodeBlockedReason !== undefined) { - StatusMessage.showTemporaryMessage(addNodeBlockedReason); - return; - } - if (selectedParentNodeId === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); - return; - } - } - const clickStartPosition = - this.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickStartPosition === undefined) { + if (!tool.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { StatusMessage.showTemporaryMessage( - "Unable to resolve add-node position for this click.", + `Make skeleton ${pickedNode.segmentId} visible before merging.`, ); return; } - let dragDistanceSquared = 0; - startRelativeMouseDrag( - event.detail, - (_event, deltaX, deltaY) => { - dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; - }, - (_finishEvent) => { - const thresholdSquared = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - // Block adding nodes if the mouse release position - // is too far from the click position - if (dragDistanceSquared > thresholdSquared) { - setReadyStatus(); - return; - } - const selectedParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - const addNodeBlockedReason = this.getAddNodeBlockedReason( - skeletonLayer, - selectedParentNodeId, - ); - if (addNodeBlockedReason !== undefined) { - setReadyStatus(); - StatusMessage.showTemporaryMessage(addNodeBlockedReason); - return; - } - const selectedParentNode = this.getSelectedParentNodeForAdd( - skeletonLayer, - selectedParentNodeId, - ); - const targetSkeletonId = - selectedParentNode === undefined - ? 0 - : selectedParentNode.segmentId; - const clickPositionInModelSpace = - this.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickPositionInModelSpace === undefined) return; - void (async () => { - try { - await executeSpatialSkeletonAddNode(layer, { - skeletonId: targetSkeletonId, - parentNodeId: selectedParentNodeId, - positionInModelSpace: new Float32Array( - clickPositionInModelSpace, - ), - }); - } catch (error) { - showSpatialSkeletonActionError("create node", error); - return; - } - setReadyStatus(); - })(); - }, + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + enterMerge(pickedNode); }, ); activation.bindAction( - "spatial-skeleton-move-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); + "spatial-skeleton-enter-create", + (_event: ActionEvent) => { + if (dragInProgress || pending || currentMode !== SkeletonEditMode.Default) return; + enterCreate(); + }, + ); + + activation.bindAction( + "spatial-skeleton-split", + (_event: ActionEvent) => { + // splitKeyHeld makes split a one-shot per keydown — browser key-repeat + // would otherwise fire it continuously while s is held. + if (splitKeyHeld || dragInProgress || pending) return; + splitKeyHeld = true; const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.moveNodes, + SpatialSkeletonActions.splitSkeletons, ); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); return; } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) { StatusMessage.showTemporaryMessage( "No spatially indexed skeleton source is currently loaded.", ); return; } - const actionPanel = this.getRenderedDataPanelForEvent(event.detail); - const pickedNode = this.getPickedSpatialSkeletonNode(); - if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.selectSegmentByNumber(pickedSegmentId); - layer.clearSpatialSkeletonNodeSelection(false); + const pickedNode = tool.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Hover over a skeleton node to split at that point.", + ); + return; + } + tool.pinSegmentByNumber(pickedNode.segmentId); + layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + const splitPoint: SpatialSkeletonToolPointInfo = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }; + pending = true; + setStatus("Splitting selected node.", splitPoint); + void (async () => { + try { + await executeSpatialSkeletonSplit(layer, { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + }); + } catch (error) { + showSpatialSkeletonActionError("split skeleton", error); + } finally { + pending = false; + clearStatus(); + } + })(); + }, + ); + + // --- Capture-phase mousedown listeners --- + // We don't bind mousedown0 in the EventActionMap because doing so would + // shadow NG's rotate-via-mouse-drag / translate-via-mouse-drag actions + // (both are EventActionMap actions at lower priority — stopPropagation:false + // on the binding only affects native DOM propagation, not the NG action + // dispatch hierarchy). Instead we register capture-phase DOM listeners so + // we fire before MouseEventBinder's bubbling-phase listener. Off-node in + // Default mode we return without calling stopPropagation, which lets + // MouseEventBinder dispatch the rotate/pan action normally. + + for (const panel of layer.manager.root.display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + const captureMousedown = (event: MouseEvent) => { + // Middle mouse: rotate in perspective panels (handled by EventActionMap + // mousedown1 → rotate-via-mouse-drag), pan in slice panels. + // For slice panels we intercept here and drive translateByViewportPixels + // directly via startRelativeMouseDrag, consuming the event before + // MouseEventBinder can dispatch rotate-via-mouse-drag from the EventActionMap. + // For perspective panels we let it pass through unmodified. + if (event.button === 1) { + if (!(panel instanceof PerspectivePanel)) { + event.stopPropagation(); + event.preventDefault(); + startRelativeMouseDrag(event, (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }); } return; } - const pickedPosition = this.mouseState.position; - const hasPickedPosition = - pickedPosition.length >= 3 && - Number.isFinite(pickedPosition[0]) && - Number.isFinite(pickedPosition[1]) && - Number.isFinite(pickedPosition[2]); - if (!hasPickedPosition) return; - const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); - if (nodeInfo === undefined) { + + // ctrl+mousedown0 → EventActionMap (add-node); shift+mousedown0 → + // NG's translate/rotate action; other buttons → normal dispatch. + // All three must pass through the capture listener unmodified. + if (event.button !== 0 || event.ctrlKey || event.shiftKey) return; + if (currentMode === SkeletonEditMode.Merge) { + event.stopPropagation(); + event.preventDefault(); + handleMergeSecondPick(event); return; } - const dragPanel = actionPanel; - if (dragPanel === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve active panel for node drag.", - ); + if (currentMode === SkeletonEditMode.Create) { + event.stopPropagation(); + event.preventDefault(); + handleCreatePlace(event); return; } - let moved = false; - let finished = false; - this.dragModelSpacePosition.set(nodeInfo.position); - vec3.set( - this.dragGlobalAnchorPosition, - Number(pickedPosition[0]), - Number(pickedPosition[1]), - Number(pickedPosition[2]), + // Default mode: only consume if hovering a node. + handleDefaultMousedown(event, panel); + }; + panel.element.addEventListener("mousedown", captureMousedown, { + capture: true, + }); + activation.registerDisposer(() => { + panel.element.removeEventListener("mousedown", captureMousedown, { + capture: true, + }); + }); + } + + function handleDefaultMousedown( + event: MouseEvent, + panel: RenderedDataPanel, + ) { + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); + const pickedNode = skeletonLayer + ? tool.getPickedSpatialSkeletonNode() + : undefined; + + if (pickedNode === undefined) { + // Off-node: don't consume — NG's bubbling-phase listener handles + // rotate/pan. Show a transient press cursor so the user knows what NG + // is about to do. + const pressMode = + panel instanceof PerspectivePanel ? "rotate" : "pan"; + panel.element.dataset.skeletonPressMode = pressMode; + window.addEventListener( + "pointerup", + () => { + delete panel.element.dataset.skeletonPressMode; + }, + { once: true }, ); - let totalDeltaX = 0; - let totalDeltaY = 0; - let dragStarted = false; - cachedNodeSummary = this.getSelectedSpatialSkeletonNodeSummary(); - setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); - startRelativeMouseDrag( - event.detail, - (_event, deltaX, deltaY) => { - totalDeltaX += deltaX; - totalDeltaY += deltaY; - if (!dragStarted) { - const thresholdSquared = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - if ( - totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < - thresholdSquared - ) - return; - dragStarted = true; - skeletonLayer.markSegmentEdited(nodeInfo.segmentId); - } - dragPanel.translateDataPointByViewportPixels( - this.dragGlobalPosition, - this.dragGlobalAnchorPosition, - totalDeltaX, - totalDeltaY, - ); - if ( - !Number.isFinite(this.dragGlobalPosition[0]) || - !Number.isFinite(this.dragGlobalPosition[1]) || - !Number.isFinite(this.dragGlobalPosition[2]) - ) { - return; - } - const modelPosition = this.globalToSkeletonCoordinates( - this.dragGlobalPosition, - skeletonLayer, - ); - if (modelPosition === undefined) return; - const previewChanged = - layer.spatialSkeletonState.setPendingNodePosition( - pickedNode.nodeId, - modelPosition, - ); - if (!previewChanged) return; - moved = true; - this.dragModelSpacePosition.set(modelPosition); - }, - (_finishEvent) => { - if (finished) return; - finished = true; - clearCachedNodeSummary(); - setReadyStatus(); - if (!dragStarted) { + return; + } + + // On a node: consume the event so NG doesn't also start a rotate/pan. + event.stopPropagation(); + event.preventDefault(); + if (skeletonLayer === undefined) return; + + const canMove = + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.moveNodes, + ) === undefined; + const nodeInfo = canMove + ? skeletonLayer.getNode(pickedNode.nodeId) + : undefined; + + const pickedPosition = tool.mouseState.position; + const hasPickedPosition = + pickedPosition.length >= 3 && + Number.isFinite(pickedPosition[0]) && + Number.isFinite(pickedPosition[1]) && + Number.isFinite(pickedPosition[2]); + + // Can't start a drag: immediately select. + if (nodeInfo === undefined || !hasPickedPosition) { + if (pickedNode.segmentId !== undefined) { + tool.pinSegmentByNumber(pickedNode.segmentId); + } + layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + return; + } + + // Arm drag: threshold → move, no threshold → select. + let totalDeltaX = 0; + let totalDeltaY = 0; + let dragStarted = false; + let finished = false; + let moved = false; + + tool.dragModelSpacePosition.set(nodeInfo.position); + vec3.set( + tool.dragGlobalAnchorPosition, + Number(pickedPosition[0]), + Number(pickedPosition[1]), + Number(pickedPosition[2]), + ); + + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + totalDeltaX += deltaX; + totalDeltaY += deltaY; + if (!dragStarted) { + const thresholdSq = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if ( + totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < + thresholdSq + ) { return; } - if (moved) { - void executeSpatialSkeletonMoveNode(layer, { - node: nodeInfo, - nextPositionInModelSpace: new Float32Array( - this.dragModelSpacePosition, - ), - }) - .then(() => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - }) - .catch((error) => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - showSpatialSkeletonActionError("move node", error); - }); - return; + dragStarted = true; + dragInProgress = true; + skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); + panel.element.dataset.skeletonPressMode = "move"; + setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); + } + panel.translateDataPointByViewportPixels( + tool.dragGlobalPosition, + tool.dragGlobalAnchorPosition, + totalDeltaX, + totalDeltaY, + ); + if ( + !Number.isFinite(tool.dragGlobalPosition[0]) || + !Number.isFinite(tool.dragGlobalPosition[1]) || + !Number.isFinite(tool.dragGlobalPosition[2]) + ) { + return; + } + const modelPosition = tool.globalToSkeletonCoordinates( + tool.dragGlobalPosition, + skeletonLayer!, + ); + if (modelPosition === undefined) return; + const previewChanged = + layer.spatialSkeletonState.setPendingNodePosition( + pickedNode.nodeId, + modelPosition, + ); + if (!previewChanged) return; + moved = true; + tool.dragModelSpacePosition.set(modelPosition); + }, + (_finishEvent) => { + if (finished) return; + finished = true; + if (dragInProgress) { + dragInProgress = false; + delete panel.element.dataset.skeletonPressMode; + clearStatus(); + } + if (!dragStarted) { + // Click → select. + if (pickedNode.segmentId !== undefined) { + tool.pinSegmentByNumber(pickedNode.segmentId); } - layer.spatialSkeletonState.clearPendingNodePosition( + layer.selectSpatialSkeletonNode( pickedNode.nodeId, + true, + pickedNode, ); - }, + return; + } + if (moved) { + void executeSpatialSkeletonMoveNode(layer, { + node: nodeInfo!, + nextPositionInModelSpace: new Float32Array( + tool.dragModelSpacePosition, + ), + }) + .then(() => { + layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + }) + .catch((error) => { + layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + showSpatialSkeletonActionError("move node", error); + }); + return; + } + layer.spatialSkeletonState.clearPendingNodePosition(pickedNode.nodeId); + }, + ); + } + + function handleMergeSecondPick(event: MouseEvent) { + // Caller (capture listener) already called stopPropagation/preventDefault. + void event; + if (pending) return; + + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", ); - }, - ); + return; + } - activation.bindAction( - "spatial-skeleton-delete-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.deleteNodes, + const anchorNodeId = layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId === undefined) { + exitMerge(); + return; + } + const anchorNodeInfo = + skeletonLayer.getNode(anchorNodeId) ?? + layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const firstNode = { + nodeId: anchorNodeId, + segmentId: anchorNodeInfo?.segmentId, + position: anchorNodeInfo?.position, + sourceState: anchorNodeInfo?.sourceState, + }; + + const pickedNode = + tool.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) + return; + + if ( + pickedNode.nodeId === anchorNodeId || + pickedNode.segmentId === firstNode.segmentId + ) { + StatusMessage.showTemporaryMessage( + "Select a node from a different skeleton to merge with.", ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const pickedNode = this.getPickedSpatialSkeletonNode(); - if (pickedNode === undefined) { - return; - } - const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); - if (nodeInfo === undefined) { - StatusMessage.showTemporaryMessage( - `Unable to resolve node ${pickedNode.nodeId} for deletion.`, + return; + } + + if (firstNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve merge anchor segment.", + ); + return; + } + if (!tool.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, + 3000, + ); + return; + } + + tool.pinSegmentByNumber(pickedNode.segmentId); + layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + pending = true; + setStatus("Merging selected nodes."); + + void (async () => { + try { + await waitForNextAnimationFrame(); + await executeSpatialSkeletonMerge( + layer, + { + nodeId: firstNode.nodeId, + segmentId: firstNode.segmentId!, + position: firstNode.position, + sourceState: firstNode.sourceState, + }, + { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + position: pickedNode.position, + sourceState: pickedNode.sourceState, + }, ); - return; + } catch (error) { + showSpatialSkeletonActionError("merge skeletons", error); + } finally { + pending = false; + renderStatus(); // Keep merge mode — user may still be holding m. } - void layer - .getSpatialSkeletonDeleteOperationContext(nodeInfo) - .then(() => executeSpatialSkeletonDeleteNode(layer, nodeInfo)) - .catch((error) => { - showSpatialSkeletonActionError("delete node", error); - }); - }, - ); - } -} + })(); + } -export class SpatialSkeletonMergeModeTool extends SpatialSkeletonToolBase { - toJSON() { - return SPATIAL_SKELETON_MERGE_MODE_TOOL_ID; - } + function handleCreatePlace(event: MouseEvent) { + // Caller (capture listener) already called stopPropagation/preventDefault. + void event; - get description() { - return "Skeleton merge"; - } + if (pending || createPlacedThisHold) return; - activate(activation: ToolActivation) { - if ( - !this.cancelActivationIfPreconditionsFail( - activation, - SpatialSkeletonActions.mergeSkeletons, - ) - ) - return; - const rawInputEventMapBinder = activation.inputEventMapBinder; - - this.activateModeWatchable(activation, this.layer.spatialSkeletonMergeMode); - const { body, header } = - makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Spatial skeleton merge"; - let pending = false; - type MergeAnchorSelection = { - nodeId: number; - segmentId?: number; - position?: ArrayLike; - sourceState?: SpatialSkeletonSourceState; - }; - let anchorSelection: MergeAnchorSelection | undefined; - let statusOverride: string | undefined; - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - const selectedNode = - this.getSelectedSpatialSkeletonNodeForTool(skeletonLayer); - if (selectedNode !== undefined) { - anchorSelection = selectedNode; - this.layer.selectSpatialSkeletonNode( - selectedNode.nodeId, - true, - selectedNode, + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, ); - this.layer.setSpatialSkeletonMergeAnchor(selectedNode.nodeId); - } else { - this.layer.clearSpatialSkeletonMergeAnchor(); - } - activation.registerDisposer(() => { - this.layer.clearSpatialSkeletonMergeAnchor(); - }); - const getAnchorNode = (): MergeAnchorSelection | undefined => { - const nodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; - if (nodeId === undefined || !Number.isSafeInteger(nodeId)) { - anchorSelection = undefined; - return undefined; + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; } - const cachedNode = - this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode(nodeId) ?? - this.layer.spatialSkeletonState.getCachedNode(nodeId); - if ( - anchorSelection?.nodeId === nodeId && - (cachedNode === undefined || - anchorSelection.segmentId === cachedNode.segmentId) - ) { - return anchorSelection; + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; } - const anchorNode = { - nodeId, - segmentId: cachedNode?.segmentId, - position: cachedNode?.position, - sourceState: cachedNode?.sourceState, - }; - anchorSelection = anchorNode; - return anchorNode; - }; - const renderStatus = () => { - const anchorNode = getAnchorNode(); - let mergeStatus: string; - if (anchorNode === undefined) { - mergeStatus = SPATIAL_SKELETON_MERGE_BANNER_MESSAGE; - } else if ( - anchorNode.segmentId !== undefined && - !this.isSpatialSkeletonSegmentVisible(anchorNode.segmentId) - ) { - mergeStatus = `Make this segment visible, then select a 2nd node to merge with`; - } else { - mergeStatus = SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE; + + const clickPosition = + tool.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve click position for new skeleton.", + ); + return; } - renderSpatialSkeletonToolStatus(body, { - message: statusOverride ?? mergeStatus, - point: anchorNode, - }); - }; - const setStatus = (nextStatus: string | undefined) => { - statusOverride = nextStatus; - renderStatus(); - }; - const setReadyStatus = () => { - setStatus(undefined); - }; - setReadyStatus(); - activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindClearSelectionAction(activation); - this.registerAutoCancelOnDisabled( - activation, - SpatialSkeletonActions.mergeSkeletons, - setReadyStatus, - ); - activation.registerDisposer( - this.layer.spatialSkeletonState.mergeAnchorNodeId.changed.add( - renderStatus, - ), - ); - activation.registerDisposer( - this.layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( - renderStatus, - ), - ); - activation.registerDisposer( - this.layer.selectedSpatialSkeletonNodeInfo.changed.add(() => { - const selectedNodeId = - this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - if (selectedNodeId === undefined) { - if ( - this.layer.spatialSkeletonState.mergeAnchorNodeId.value !== - undefined - ) { - anchorSelection = undefined; - this.layer.clearSpatialSkeletonMergeAnchor(); - } - return; - } - if (this.layer.spatialSkeletonState.commandHistory.isBusy.value) { - this.layer.setSpatialSkeletonMergeAnchor(selectedNodeId); + + createPlacedThisHold = true; + pending = true; + setStatus("Creating new skeleton."); + + void (async () => { + try { + await executeSpatialSkeletonAddNode(layer, { + skeletonId: 0, + parentNodeId: undefined, + positionInModelSpace: new Float32Array(clickPosition), + }); + } catch (error) { + showSpatialSkeletonActionError("create skeleton", error); + } finally { + pending = false; + renderStatus(); } - renderStatus(); - }), - ); + })(); + } + + // --- Add node handler (ctrl+mousedown0) --- + activation.bindAction( - "spatial-skeleton-pick-node", - (_event: ActionEvent) => { - if (pending) return; - const disabledReason = - this.layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.mergeSkeletons, - ); + "spatial-skeleton-add-node", + (event: ActionEvent) => { + event.stopPropagation(); + event.detail.preventDefault(); + + // Exit any sub-mode so state stays consistent. + if (currentMode === SkeletonEditMode.Merge) exitMerge(); + if (currentMode === SkeletonEditMode.Create) exitCreate(); + + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); return; } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) { StatusMessage.showTemporaryMessage( "No spatially indexed skeleton source is currently loaded.", ); return; } - const pickedNode = - this.resolvePickedNodeSelectionForMerge(skeletonLayer); - const anchorNode = getAnchorNode(); - if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); - if (anchorNode === undefined) { - this.layer.clearSpatialSkeletonNodeSelection(false); - } - renderStatus(); - } - return; - } - if (pickedNode.segmentId === undefined) { - return; - } - if ( - anchorNode === undefined || - anchorNode.nodeId === pickedNode.nodeId || - anchorNode.segmentId === pickedNode.segmentId - ) { - this.pinSegmentByNumber(pickedNode.segmentId); - anchorSelection = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - sourceState: pickedNode.sourceState, - }; - this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, + + const selectedParentNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedParentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Select a node first, then ctrl+click to append a child.", ); - renderStatus(); return; } - const firstNode = anchorNode; - const secondNode = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - sourceState: pickedNode.sourceState, - }; - if ( - firstNode.segmentId === undefined || - secondNode.segmentId === undefined - ) { - StatusMessage.showTemporaryMessage( - "Unable to resolve both merge segments.", - ); + const addNodeBlockedReason = tool.getAddNodeBlockedReason( + skeletonLayer, + selectedParentNodeId, + ); + if (addNodeBlockedReason !== undefined) { + StatusMessage.showTemporaryMessage(addNodeBlockedReason); return; } - if (!this.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { + + const clickStartPosition = + tool.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickStartPosition === undefined) { StatusMessage.showTemporaryMessage( - `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, - 3000, + "Unable to resolve add-node position for this click.", ); return; } - this.pinSegmentByNumber(pickedNode.segmentId); - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - pending = true; - setStatus("Merging selected nodes."); - void (async () => { - try { - await waitForNextAnimationFrame(); - await executeSpatialSkeletonMerge( - this.layer, - { - nodeId: firstNode.nodeId, - segmentId: firstNode.segmentId!, - position: firstNode.position, - sourceState: firstNode.sourceState, - }, - { - nodeId: secondNode.nodeId, - segmentId: secondNode.segmentId!, - position: secondNode.position, - sourceState: secondNode.sourceState, - }, + + let dragDistanceSquared = 0; + startRelativeMouseDrag( + event.detail, + (_dragEvent, deltaX, deltaY) => { + dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; + }, + (_finishEvent) => { + const thresholdSquared = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if (dragDistanceSquared > thresholdSquared) { + return; + } + const currentParentNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (currentParentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Select a node first, then ctrl+click to append a child.", + ); + return; + } + const blockedReason = tool.getAddNodeBlockedReason( + skeletonLayer, + currentParentNodeId, ); - } catch (error) { - showSpatialSkeletonActionError("merge skeletons", error); - } finally { - pending = false; - this.layer.setSpatialSkeletonMergeAnchor(secondNode.nodeId); - setReadyStatus(); - } - })(); + if (blockedReason !== undefined) { + StatusMessage.showTemporaryMessage(blockedReason); + return; + } + const selectedParentNode = tool.getSelectedParentNodeForAdd( + skeletonLayer, + currentParentNodeId, + ); + const clickPositionInModelSpace = + tool.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPositionInModelSpace === undefined) return; + void (async () => { + try { + await executeSpatialSkeletonAddNode(layer, { + skeletonId: selectedParentNode?.segmentId ?? 0, + parentNodeId: currentParentNodeId, + positionInModelSpace: new Float32Array( + clickPositionInModelSpace, + ), + }); + } catch (error) { + showSpatialSkeletonActionError("create node", error); + } + })(); + }, + ); }, ); - } -} - -export class SpatialSkeletonSplitModeTool extends SpatialSkeletonToolBase { - toJSON() { - return SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID; - } - get description() { - return "Skeleton split"; - } - - activate(activation: ToolActivation) { - if ( - !this.cancelActivationIfPreconditionsFail( - activation, - SpatialSkeletonActions.splitSkeletons, - ) - ) - return; - const rawInputEventMapBinder = activation.inputEventMapBinder; + // --- Delete node handler (ctrl+alt+mousedown2) --- - this.activateModeWatchable(activation, this.layer.spatialSkeletonSplitMode); - const { body, header } = - makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton split"; - let pending = false; - let statusOverride: string | undefined; - let pendingPoint: SpatialSkeletonToolPointInfo | undefined; - const renderStatus = () => { - renderSpatialSkeletonToolStatus(body, { - message: statusOverride ?? SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, - point: pendingPoint, - }); - }; - const setStatus = ( - nextStatus: string | undefined, - nextPoint: SpatialSkeletonToolPointInfo | undefined = pendingPoint, - ) => { - statusOverride = nextStatus; - pendingPoint = nextPoint; - renderStatus(); - }; - const setReadyStatus = () => { - setStatus(undefined, undefined); - }; - const splitNode = ( - pickedNode: { - nodeId: number; - segmentId?: number; - position?: SpatialSkeletonVector; - sourceState?: SpatialSkeletonSourceState; - }, - options: { - selectNode?: boolean; - } = {}, - ) => { - if (pickedNode.segmentId === undefined) { - return false; - } - this.pinSegmentByNumber(pickedNode.segmentId); - if (options.selectNode ?? true) { - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - } - const point = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - }; - pending = true; - setStatus("Splitting selected node.", point); - void (async () => { - try { - await executeSpatialSkeletonSplit(this.layer, { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId!, - }); - } catch (error) { - showSpatialSkeletonActionError("split skeleton", error); - } finally { - pending = false; - setReadyStatus(); - } - })(); - return true; - }; - setReadyStatus(); - activation.bindInputEventMap(SPATIAL_SKELETON_PICK_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindClearSelectionAction(activation); - this.registerAutoCancelOnDisabled( - activation, - SpatialSkeletonActions.splitSkeletons, - setReadyStatus, - ); - const selectedNode = this.getSelectedSpatialSkeletonNodeForTool( - this.getActiveSpatiallyIndexedSkeletonLayer(), - ); - if ( - selectedNode?.segmentId !== undefined && - this.isSpatialSkeletonSegmentVisible(selectedNode.segmentId) - ) { - splitNode(selectedNode); - } activation.bindAction( - "spatial-skeleton-pick-node", - (_event: ActionEvent) => { - if (pending) return; - const disabledReason = - this.layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.splitSkeletons, - ); + "spatial-skeleton-delete-node", + (event: ActionEvent) => { + event.stopPropagation(); + event.detail.preventDefault(); + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteNodes, + ); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); return; } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) { StatusMessage.showTemporaryMessage( "No spatially indexed skeleton source is currently loaded.", ); return; } - const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + const pickedNode = tool.getPickedSpatialSkeletonNode(); if (pickedNode === undefined) { - const pickedSegmentId = this.getPickedSpatialSkeletonSegment(); - if (pickedSegmentId !== undefined) { - this.pinSegmentByNumber(pickedSegmentId); - this.layer.clearSpatialSkeletonNodeSelection(false); - renderStatus(); - } return; } - if (pickedNode.segmentId === undefined) { + const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); + if (nodeInfo === undefined) { + StatusMessage.showTemporaryMessage( + `Unable to resolve node ${pickedNode.nodeId} for deletion.`, + ); return; } - splitNode(pickedNode); + void layer + .getSpatialSkeletonDeleteOperationContext(nodeInfo) + .then(() => executeSpatialSkeletonDeleteNode(layer, nodeInfo)) + .catch((error) => { + showSpatialSkeletonActionError("delete node", error); + }); }, ); } } +// Backward-compat alias — external code referencing SpatialSkeletonEditModeTool still works. +export { SpatialSkeletonEditTool as SpatialSkeletonEditModeTool }; + function makeSpatialSkeletonToolLister(toolId: string) { return (layer: SegmentationUserLayer, onChange?: () => void) => { if (onChange !== undefined) { @@ -1396,19 +1421,7 @@ export function registerSpatialSkeletonEditModeTool( registerTool( contextType, SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - (layer) => new SpatialSkeletonEditModeTool(layer), + (layer) => new SpatialSkeletonEditTool(layer), makeSpatialSkeletonToolLister(SPATIAL_SKELETON_EDIT_MODE_TOOL_ID), ); - registerTool( - contextType, - SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - (layer) => new SpatialSkeletonMergeModeTool(layer), - makeSpatialSkeletonToolLister(SPATIAL_SKELETON_MERGE_MODE_TOOL_ID), - ); - registerTool( - contextType, - SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, - (layer) => new SpatialSkeletonSplitModeTool(layer), - makeSpatialSkeletonToolLister(SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID), - ); } diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 7a49f32033..865d8f6250 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -69,11 +69,7 @@ import { } from "#src/skeleton/spatial_skeleton_commands.js"; import { StatusMessage } from "#src/status.js"; import { observeWatchable, registerNested } from "#src/trackable_value.js"; -import { - SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, -} from "#src/ui/skeleton_edit_tools.js"; +import { SPATIAL_SKELETON_EDIT_MODE_TOOL_ID } from "#src/ui/skeleton_edit_tools.js"; import { buildSpatialSkeletonSegmentRenderState, type SpatialSkeletonSegmentRenderRow, @@ -175,21 +171,7 @@ export class SpatialSkeletonEditTab extends Tab { makeToolButton(this, layer.toolBinder, { toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, label: "Edit", - title: "Toggle skeleton node edit mode", - }), - ); - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_MERGE_MODE_TOOL_ID, - label: "Merge", - title: "Toggle skeleton merge mode", - }), - ); - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_SPLIT_MODE_TOOL_ID, - label: "Split", - title: "Toggle skeleton split mode", + title: "Toggle skeleton edit mode", }), ); const toolbarActions = document.createElement("div"); From f07210d95c5518947bbf620e6ef21e3c47a40f61 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 13:55:17 +0200 Subject: [PATCH 02/11] refactor: move dumped logic in activate into class methods and class state (cherry picked from commit 404c1652301ddbbbba71c42accb82d14eef590f7) --- src/ui/skeleton_edit_tools.ts | 1566 +++++++++++++++++---------------- 1 file changed, 791 insertions(+), 775 deletions(-) diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 416571387a..b08b82a6e9 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -434,6 +434,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { return "Skeleton edit"; } + // Persistent coordinate-transform fields — created once, never reassigned. private curChunkRank = -1; private tempChunkPosition = new Float32Array(0); private readonly dragModelSpacePosition = vec3.create(); @@ -509,381 +510,842 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { return undefined; } - activate(activation: ToolActivation) { - const tool = this; - const { layer } = this; - const rawInputEventMapBinder = activation.inputEventMapBinder; - const { body, header } = - makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton edit"; - - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], - { ignoreCommandBusy: true, requireVisibleChunks: false }, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - renderSpatialSkeletonToolStatus(body, { message: disabledReason }); - queueMicrotask(() => activation.cancel()); - return; - } - if (tool.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { - const msg = "No spatially indexed skeleton source is currently loaded."; - StatusMessage.showTemporaryMessage(msg); - renderSpatialSkeletonToolStatus(body, { message: msg }); - queueMicrotask(() => activation.cancel()); - return; - } - - // Keep editMode on for the entire tool lifetime — drives the lines+points render mode. - tool.activateModeWatchable(activation, layer.spatialSkeletonEditMode); - activation.bindInputEventMap(SKELETON_UNIFIED_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - tool.bindPinnedSelectionAction(activation, { showNodeSelectionMessage: false }); - tool.bindClearSelectionAction(activation); - - let currentMode: SkeletonEditMode = SkeletonEditMode.Default; - let dragInProgress = false; - let pending = false; - let createPlacedThisHold = false; - // One-shot guards: prevent repeated fires while a key is held down. - let mergeKeyHeld = false; - let splitKeyHeld = false; - // Modifier-held state drives cursor indicators and blocks node actions. - let ctrlHeld = false; - let shiftHeld = false; - let statusOverride: string | undefined; - let statusPoint: SpatialSkeletonToolPointInfo | undefined; - - // --- Cursor helpers --- - - const setModeAttribute = (mode: string | undefined) => { - const { display } = layer.manager.root; - for (const panel of display.panels) { - if (!(panel instanceof RenderedDataPanel)) continue; - if (mode === undefined) { - delete panel.element.dataset.skeletonEditMode; - } else { - panel.element.dataset.skeletonEditMode = mode; - } - } - }; - - // Recomputes the correct data-skeleton-edit-mode attribute from current - // mode + held modifiers so callers don't have to care about that interaction. - // Priority: sustained tool modes > ctrl (add) > shift (translate hint). - const updateModeAttribute = () => { - if (currentMode === SkeletonEditMode.Merge) { - setModeAttribute("merge"); - } else if (currentMode === SkeletonEditMode.Create) { - setModeAttribute("create"); - } else if (ctrlHeld) { - setModeAttribute("add"); - } else if (shiftHeld) { - setModeAttribute("shift"); + // Activation-scoped state — reset at the start of each activate() call. + private currentMode: SkeletonEditMode = SkeletonEditMode.Default; + private dragInProgress = false; + private pending = false; + private createPlacedThisHold = false; + // One-shot guards: prevent repeated fires while a key is held down. + private mergeKeyHeld = false; + private splitKeyHeld = false; + // Modifier-held state drives cursor indicators and blocks node actions. + private ctrlHeld = false; + private shiftHeld = false; + private statusOverride: string | undefined = undefined; + private statusPoint: SpatialSkeletonToolPointInfo | undefined = undefined; + // Set at activation start; cleared by the activation disposer to prevent + // post-deactivation UI writes. + private statusBody: HTMLElement | undefined = undefined; + + // --- Cursor helpers --- + + private setModeAttribute(mode: string | undefined) { + const { display } = this.layer.manager.root; + for (const panel of display.panels) { + if (!(panel instanceof RenderedDataPanel)) continue; + if (mode === undefined) { + delete panel.element.dataset.skeletonEditMode; } else { - setModeAttribute(undefined); + panel.element.dataset.skeletonEditMode = mode; } - }; + } + } - activation.registerDisposer(() => setModeAttribute(undefined)); + // Recomputes the correct data-skeleton-edit-mode attribute from current + // mode + held modifiers so callers don't have to care about that interaction. + // Priority: sustained tool modes > ctrl (add) > shift (translate hint). + private updateModeAttribute() { + if (this.currentMode === SkeletonEditMode.Merge) { + this.setModeAttribute("merge"); + } else if (this.currentMode === SkeletonEditMode.Create) { + this.setModeAttribute("create"); + } else if (this.ctrlHeld) { + this.setModeAttribute("add"); + } else if (this.shiftHeld) { + this.setModeAttribute("shift"); + } else { + this.setModeAttribute(undefined); + } + } - // --- Status rendering --- + // --- Status rendering --- - const renderStatus = () => { - if (statusOverride !== undefined) { - renderSpatialSkeletonToolStatus(body, { - message: statusOverride, - point: statusPoint, - }); - return; - } - if (currentMode === SkeletonEditMode.Merge) { - const anchorNodeId = - layer.spatialSkeletonState.mergeAnchorNodeId.value; - if (anchorNodeId !== undefined) { - const cachedNode = - tool.getActiveSpatiallyIndexedSkeletonLayer()?.getNode( - anchorNodeId, - ) ?? layer.spatialSkeletonState.getCachedNode(anchorNodeId); - const point: SpatialSkeletonToolPointInfo = { - nodeId: anchorNodeId, - segmentId: cachedNode?.segmentId, - position: cachedNode?.position, - }; - if ( - cachedNode?.segmentId !== undefined && - !tool.isSpatialSkeletonSegmentVisible(cachedNode.segmentId) - ) { - renderSpatialSkeletonToolStatus(body, { - message: - "Make this segment visible, then select a 2nd node to merge with · release m to exit", - point, - }); - } else { - renderSpatialSkeletonToolStatus(body, { - message: SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - point, - }); - } + private renderStatus() { + if (this.statusBody === undefined) return; + const body = this.statusBody; + if (this.statusOverride !== undefined) { + renderSpatialSkeletonToolStatus(body, { + message: this.statusOverride, + point: this.statusPoint, + }); + return; + } + if (this.currentMode === SkeletonEditMode.Merge) { + const anchorNodeId = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId !== undefined) { + const cachedNode = + this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode( + anchorNodeId, + ) ?? this.layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const point: SpatialSkeletonToolPointInfo = { + nodeId: anchorNodeId, + segmentId: cachedNode?.segmentId, + position: cachedNode?.position, + }; + if ( + cachedNode?.segmentId !== undefined && + !this.isSpatialSkeletonSegmentVisible(cachedNode.segmentId) + ) { + renderSpatialSkeletonToolStatus(body, { + message: + "Make this segment visible, then select a 2nd node to merge with · release m to exit", + point, + }); } else { renderSpatialSkeletonToolStatus(body, { - message: "Select a node to merge with · release m to exit", + message: SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, + point, }); } - return; - } - if (currentMode === SkeletonEditMode.Create) { + } else { renderSpatialSkeletonToolStatus(body, { - message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, + message: "Select a node to merge with · release m to exit", }); - return; } - // Default mode - const selectedPoint = tool.getSelectedSpatialSkeletonNodeSummary(); + return; + } + if (this.currentMode === SkeletonEditMode.Create) { renderSpatialSkeletonToolStatus(body, { - message: getSpatialSkeletonEditBannerMessage(selectedPoint), - point: selectedPoint, + message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, }); - }; + return; + } + // Default mode + const selectedPoint = this.getSelectedSpatialSkeletonNodeSummary(); + renderSpatialSkeletonToolStatus(body, { + message: getSpatialSkeletonEditBannerMessage(selectedPoint), + point: selectedPoint, + }); + } - const setStatus = ( - message: string | undefined, - point?: SpatialSkeletonToolPointInfo, - ) => { - statusOverride = message; - statusPoint = point; - renderStatus(); - }; + private setStatus( + message: string | undefined, + point?: SpatialSkeletonToolPointInfo, + ) { + this.statusOverride = message; + this.statusPoint = point; + this.renderStatus(); + } - const clearStatus = () => setStatus(undefined, undefined); + private clearStatus() { + this.setStatus(undefined, undefined); + } - // --- Mode transitions --- + // --- Modifier tracking --- + + // Sync ctrlHeld/shiftHeld from the logical modifier flags on any event that + // carries them. This mirrors what NG's EventActionMap does via + // getEventModifierMask, so OS-level modifier rebindings are transparent — + // we never inspect specific key codes for ctrl or shift. + private syncModifiers(event: { ctrlKey: boolean; shiftKey: boolean }) { + const isCtrl = event.ctrlKey; + const isShift = event.shiftKey; + if (this.ctrlHeld === isCtrl && this.shiftHeld === isShift) return; + this.ctrlHeld = isCtrl; + this.shiftHeld = isShift; + this.updateModeAttribute(); + } - const enterMerge = (anchorNode: { - nodeId: number; - segmentId?: number; - position?: SpatialSkeletonVector; - sourceState?: SpatialSkeletonSourceState; - }) => { - if (anchorNode.segmentId !== undefined) { - tool.pinSegmentByNumber(anchorNode.segmentId); - } - layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); - layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); - layer.spatialSkeletonMergeMode.value = true; - currentMode = SkeletonEditMode.Merge; - updateModeAttribute(); - renderStatus(); - }; + // --- Mode transitions --- - const exitMerge = () => { - if (currentMode !== SkeletonEditMode.Merge) return; - layer.clearSpatialSkeletonMergeAnchor(); - layer.spatialSkeletonMergeMode.value = false; - currentMode = SkeletonEditMode.Default; - updateModeAttribute(); - clearStatus(); - }; + private enterMerge(anchorNode: { + nodeId: number; + segmentId?: number; + position?: SpatialSkeletonVector; + sourceState?: SpatialSkeletonSourceState; + }) { + if (anchorNode.segmentId !== undefined) { + this.pinSegmentByNumber(anchorNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); + this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); + this.layer.spatialSkeletonMergeMode.value = true; + this.currentMode = SkeletonEditMode.Merge; + this.updateModeAttribute(); + this.renderStatus(); + } - const enterCreate = () => { - currentMode = SkeletonEditMode.Create; - createPlacedThisHold = false; - updateModeAttribute(); - renderStatus(); - }; + private exitMerge() { + if (this.currentMode !== SkeletonEditMode.Merge) return; + this.layer.clearSpatialSkeletonMergeAnchor(); + this.layer.spatialSkeletonMergeMode.value = false; + this.currentMode = SkeletonEditMode.Default; + this.updateModeAttribute(); + this.clearStatus(); + } - const exitCreate = () => { - if (currentMode !== SkeletonEditMode.Create) return; - currentMode = SkeletonEditMode.Default; - createPlacedThisHold = false; - updateModeAttribute(); - clearStatus(); - }; + private enterCreate() { + this.currentMode = SkeletonEditMode.Create; + this.createPlacedThisHold = false; + this.updateModeAttribute(); + this.renderStatus(); + } - activation.registerDisposer(() => { - layer.spatialSkeletonMergeMode.value = false; - layer.spatialSkeletonState.clearPendingNodePositions(); - }); + private exitCreate() { + if (this.currentMode !== SkeletonEditMode.Create) return; + this.currentMode = SkeletonEditMode.Default; + this.createPlacedThisHold = false; + this.updateModeAttribute(); + this.clearStatus(); + } - // Sync ctrlHeld/shiftHeld from the logical modifier flags on any event that - // carries them. This mirrors what NG's EventActionMap does via - // getEventModifierMask, so OS-level modifier rebindings are transparent — - // we never inspect specific key codes for ctrl or shift. - const syncModifiers = (event: { ctrlKey: boolean; shiftKey: boolean }) => { - const isCtrl = event.ctrlKey; - const isShift = event.shiftKey; - if (ctrlHeld === isCtrl && shiftHeld === isShift) return; - ctrlHeld = isCtrl; - shiftHeld = isShift; - updateModeAttribute(); - }; + // --- Mouse handlers --- - // Release held-key modes on keyup or when the window loses focus. - const onKeyDown = (event: KeyboardEvent) => { - syncModifiers(event); - }; - const onKeyUp = (event: KeyboardEvent) => { - if (event.code === "KeyM") { - mergeKeyHeld = false; - exitMerge(); + private handleDefaultMousedown( + event: MouseEvent, + panel: RenderedDataPanel, + ) { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const pickedNode = skeletonLayer + ? this.getPickedSpatialSkeletonNode() + : undefined; + + if (pickedNode === undefined) { + // Off-node: don't consume — NG's bubbling-phase listener handles + // rotate/pan. Show a transient press cursor so the user knows what NG + // is about to do. + const pressMode = + panel instanceof PerspectivePanel ? "rotate" : "pan"; + panel.element.dataset.skeletonPressMode = pressMode; + window.addEventListener( + "pointerup", + () => { + delete panel.element.dataset.skeletonPressMode; + }, + { once: true }, + ); + return; + } + + // On a node: consume the event so NG doesn't also start a rotate/pan. + event.stopPropagation(); + event.preventDefault(); + if (skeletonLayer === undefined) return; + + const canMove = + this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.moveNodes, + ) === undefined; + const nodeInfo = canMove + ? skeletonLayer.getNode(pickedNode.nodeId) + : undefined; + + const pickedPosition = this.mouseState.position; + const hasPickedPosition = + pickedPosition.length >= 3 && + Number.isFinite(pickedPosition[0]) && + Number.isFinite(pickedPosition[1]) && + Number.isFinite(pickedPosition[2]); + + // Can't start a drag: immediately select. + if (nodeInfo === undefined || !hasPickedPosition) { + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); } - if (event.code === "KeyN") exitCreate(); - if (event.code === "KeyS") splitKeyHeld = false; - syncModifiers(event); - }; - // mousemove catches modifiers pressed/released while keyboard focus is - // outside the panel (e.g. a text input elsewhere in the UI). - const onMouseMove = (event: MouseEvent) => { - syncModifiers(event); - }; - const onBlur = () => { - mergeKeyHeld = false; - splitKeyHeld = false; - ctrlHeld = false; - shiftHeld = false; - exitMerge(); - exitCreate(); - updateModeAttribute(); - }; - window.addEventListener("keydown", onKeyDown); - window.addEventListener("keyup", onKeyUp); - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("blur", onBlur); - activation.registerDisposer(() => { - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("keyup", onKeyUp); - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("blur", onBlur); - }); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + return; + } - activation.registerDisposer( - layer.selectedSpatialSkeletonNodeInfo.changed.add(renderStatus), - ); - activation.registerDisposer( - layer.manager.root.selectionState.changed.add(renderStatus), - ); - activation.registerDisposer( - layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(renderStatus), + // Arm drag: threshold → move, no threshold → select. + let totalDeltaX = 0; + let totalDeltaY = 0; + let dragStarted = false; + let finished = false; + let moved = false; + + this.dragModelSpacePosition.set(nodeInfo.position); + vec3.set( + this.dragGlobalAnchorPosition, + Number(pickedPosition[0]), + Number(pickedPosition[1]), + Number(pickedPosition[2]), ); - activation.registerDisposer( - layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( - renderStatus, - ), - ); - activation.registerDisposer( - layer.layersChanged.add(() => { - const reason = layer.getSpatialSkeletonActionsDisabledReason( - [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], - { ignoreCommandBusy: true, requireVisibleChunks: false }, + + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + totalDeltaX += deltaX; + totalDeltaY += deltaY; + if (!dragStarted) { + const thresholdSq = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if ( + totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < + thresholdSq + ) { + return; + } + dragStarted = true; + this.dragInProgress = true; + skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); + panel.element.dataset.skeletonPressMode = "move"; + this.setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); + } + panel.translateDataPointByViewportPixels( + this.dragGlobalPosition, + this.dragGlobalAnchorPosition, + totalDeltaX, + totalDeltaY, ); - if (reason !== undefined) { - StatusMessage.showTemporaryMessage(reason); - activation.cancel(); + if ( + !Number.isFinite(this.dragGlobalPosition[0]) || + !Number.isFinite(this.dragGlobalPosition[1]) || + !Number.isFinite(this.dragGlobalPosition[2]) + ) { + return; } - }), - ); - - renderStatus(); - - // --- Mode key action handlers --- - - activation.bindAction( - "spatial-skeleton-enter-merge", - (_event: ActionEvent) => { - // mergeKeyHeld prevents the error toast from repeating on key-repeat - // events while m is held without a hovered node. - if (mergeKeyHeld || dragInProgress || pending || currentMode !== SkeletonEditMode.Default) return; - mergeKeyHeld = true; - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) return; - const pickedNode = - tool.resolvePickedNodeSelectionForMerge(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage( - "Hover over a skeleton node to enter merge mode.", + const modelPosition = this.globalToSkeletonCoordinates( + this.dragGlobalPosition, + skeletonLayer!, + ); + if (modelPosition === undefined) return; + const previewChanged = + this.layer.spatialSkeletonState.setPendingNodePosition( + pickedNode.nodeId, + modelPosition, ); - return; + if (!previewChanged) return; + moved = true; + this.dragModelSpacePosition.set(modelPosition); + }, + (_finishEvent) => { + if (finished) return; + finished = true; + if (this.dragInProgress) { + this.dragInProgress = false; + delete panel.element.dataset.skeletonPressMode; + this.clearStatus(); } - if (!tool.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { - StatusMessage.showTemporaryMessage( - `Make skeleton ${pickedNode.segmentId} visible before merging.`, + if (!dragStarted) { + // Click → select. + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode( + pickedNode.nodeId, + true, + pickedNode, ); return; } - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.mergeSkeletons, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); + if (moved) { + void executeSpatialSkeletonMoveNode(this.layer, { + node: nodeInfo!, + nextPositionInModelSpace: new Float32Array( + this.dragModelSpacePosition, + ), + }) + .then(() => { + this.layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + }) + .catch((error) => { + this.layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); + showSpatialSkeletonActionError("move node", error); + }); return; } - enterMerge(pickedNode); + this.layer.spatialSkeletonState.clearPendingNodePosition(pickedNode.nodeId); }, ); + } - activation.bindAction( - "spatial-skeleton-enter-create", - (_event: ActionEvent) => { - if (dragInProgress || pending || currentMode !== SkeletonEditMode.Default) return; - enterCreate(); - }, + private handleMergeSecondPick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } - activation.bindAction( - "spatial-skeleton-split", - (_event: ActionEvent) => { - // splitKeyHeld makes split a one-shot per keydown — browser key-repeat - // would otherwise fire it continuously while s is held. - if (splitKeyHeld || dragInProgress || pending) return; - splitKeyHeld = true; - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.splitSkeletons, + const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + if (anchorNodeId === undefined) { + this.exitMerge(); + return; + } + const anchorNodeInfo = + skeletonLayer.getNode(anchorNodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(anchorNodeId); + const firstNode = { + nodeId: anchorNodeId, + segmentId: anchorNodeInfo?.segmentId, + position: anchorNodeInfo?.position, + sourceState: anchorNodeInfo?.sourceState, + }; + + const pickedNode = + this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) + return; + + if ( + pickedNode.nodeId === anchorNodeId || + pickedNode.segmentId === firstNode.segmentId + ) { + StatusMessage.showTemporaryMessage( + "Select a node from a different skeleton to merge with.", + ); + return; + } + + if (firstNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve merge anchor segment.", + ); + return; + } + if (!this.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, + 3000, + ); + return; + } + + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + this.pending = true; + this.setStatus("Merging selected nodes."); + + void (async () => { + try { + await waitForNextAnimationFrame(); + await executeSpatialSkeletonMerge( + this.layer, + { + nodeId: firstNode.nodeId, + segmentId: firstNode.segmentId!, + position: firstNode.position, + sourceState: firstNode.sourceState, + }, + { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + position: pickedNode.position, + sourceState: pickedNode.sourceState, + }, ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); + } catch (error) { + showSpatialSkeletonActionError("merge skeletons", error); + } finally { + this.pending = false; + this.renderStatus(); // Keep merge mode — user may still be holding m. + } + })(); + } + + private handleCreatePlace() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending || this.createPlacedThisHold) return; + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + + const clickPosition = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve click position for new skeleton.", + ); + return; + } + + this.createPlacedThisHold = true; + this.pending = true; + this.setStatus("Creating new skeleton."); + + void (async () => { + try { + await executeSpatialSkeletonAddNode(this.layer, { + skeletonId: 0, + parentNodeId: undefined, + positionInModelSpace: new Float32Array(clickPosition), + }); + } catch (error) { + showSpatialSkeletonActionError("create skeleton", error); + } finally { + this.pending = false; + this.renderStatus(); + } + })(); + } + + // --- Action implementations --- + + private onEnterMergeAction() { + // mergeKeyHeld prevents the error toast from repeating on key-repeat + // events while m is held without a hovered node. + if (this.mergeKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; + this.mergeKeyHeld = true; + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) return; + const pickedNode = + this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Hover over a skeleton node to enter merge mode.", + ); + return; + } + if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `Make skeleton ${pickedNode.segmentId} visible before merging.`, + ); + return; + } + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + this.enterMerge(pickedNode); + } + + private onEnterCreateAction() { + if (this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; + this.enterCreate(); + } + + private onSplitAction() { + // splitKeyHeld makes split a one-shot per keydown — browser key-repeat + // would otherwise fire it continuously while s is held. + if (this.splitKeyHeld || this.dragInProgress || this.pending) return; + this.splitKeyHeld = true; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.splitSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage( + "Hover over a skeleton node to split at that point.", + ); + return; + } + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + const splitPoint: SpatialSkeletonToolPointInfo = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }; + this.pending = true; + this.setStatus("Splitting selected node.", splitPoint); + void (async () => { + try { + await executeSpatialSkeletonSplit(this.layer, { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId!, + }); + } catch (error) { + showSpatialSkeletonActionError("split skeleton", error); + } finally { + this.pending = false; + this.clearStatus(); + } + })(); + } + + private onAddNodeAction(event: ActionEvent) { + event.stopPropagation(); + event.detail.preventDefault(); + + // Exit any sub-mode so state stays consistent. + if (this.currentMode === SkeletonEditMode.Merge) this.exitMerge(); + if (this.currentMode === SkeletonEditMode.Create) this.exitCreate(); + + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.addNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + + const selectedParentNodeId = + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedParentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Select a node first, then ctrl+click to append a child.", + ); + return; + } + const addNodeBlockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + selectedParentNodeId, + ); + if (addNodeBlockedReason !== undefined) { + StatusMessage.showTemporaryMessage(addNodeBlockedReason); + return; + } + + const clickStartPosition = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickStartPosition === undefined) { + StatusMessage.showTemporaryMessage( + "Unable to resolve add-node position for this click.", + ); + return; + } + + let dragDistanceSquared = 0; + startRelativeMouseDrag( + event.detail, + (_dragEvent, deltaX, deltaY) => { + dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; + }, + (_finishEvent) => { + const thresholdSquared = + DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + if (dragDistanceSquared > thresholdSquared) { return; } - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { + const currentParentNodeId = + this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (currentParentNodeId === undefined) { StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", + "Select a node first, then ctrl+click to append a child.", ); return; } - const pickedNode = tool.resolvePickedNodeSelection(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage( - "Hover over a skeleton node to split at that point.", - ); + const blockedReason = this.getAddNodeBlockedReason( + skeletonLayer, + currentParentNodeId, + ); + if (blockedReason !== undefined) { + StatusMessage.showTemporaryMessage(blockedReason); return; } - tool.pinSegmentByNumber(pickedNode.segmentId); - layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - const splitPoint: SpatialSkeletonToolPointInfo = { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - }; - pending = true; - setStatus("Splitting selected node.", splitPoint); + const selectedParentNode = this.getSelectedParentNodeForAdd( + skeletonLayer, + currentParentNodeId, + ); + const clickPositionInModelSpace = + this.getMousePositionInSkeletonCoordinates(skeletonLayer); + if (clickPositionInModelSpace === undefined) return; void (async () => { try { - await executeSpatialSkeletonSplit(layer, { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId!, + await executeSpatialSkeletonAddNode(this.layer, { + skeletonId: selectedParentNode?.segmentId ?? 0, + parentNodeId: currentParentNodeId, + positionInModelSpace: new Float32Array( + clickPositionInModelSpace, + ), }); } catch (error) { - showSpatialSkeletonActionError("split skeleton", error); - } finally { - pending = false; - clearStatus(); + showSpatialSkeletonActionError("create node", error); } })(); }, ); + } + + private onDeleteNodeAction(event: ActionEvent) { + event.stopPropagation(); + event.detail.preventDefault(); + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.deleteNodes, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.getPickedSpatialSkeletonNode(); + if (pickedNode === undefined) { + return; + } + const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); + if (nodeInfo === undefined) { + StatusMessage.showTemporaryMessage( + `Unable to resolve node ${pickedNode.nodeId} for deletion.`, + ); + return; + } + void this.layer + .getSpatialSkeletonDeleteOperationContext(nodeInfo) + .then(() => executeSpatialSkeletonDeleteNode(this.layer, nodeInfo)) + .catch((error) => { + showSpatialSkeletonActionError("delete node", error); + }); + } + + activate(activation: ToolActivation) { + const { layer } = this; + const rawInputEventMapBinder = activation.inputEventMapBinder; - // --- Capture-phase mousedown listeners --- + // 1. Reset all activation-scoped state. + this.currentMode = SkeletonEditMode.Default; + this.dragInProgress = false; + this.pending = false; + this.createPlacedThisHold = false; + this.mergeKeyHeld = false; + this.splitKeyHeld = false; + this.ctrlHeld = false; + this.shiftHeld = false; + this.statusOverride = undefined; + this.statusPoint = undefined; + + // 2. Create status UI. + const { body, header } = + makeToolActivationStatusMessageWithHeader(activation); + header.textContent = "Skeleton edit"; + this.statusBody = body; + + // 3. Precondition checks. + const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + renderSpatialSkeletonToolStatus(body, { message: disabledReason }); + queueMicrotask(() => activation.cancel()); + return; + } + if (this.getActiveSpatiallyIndexedSkeletonLayer() === undefined) { + const msg = "No spatially indexed skeleton source is currently loaded."; + StatusMessage.showTemporaryMessage(msg); + renderSpatialSkeletonToolStatus(body, { message: msg }); + queueMicrotask(() => activation.cancel()); + return; + } + + // 4. Register disposer: clear statusBody, reset mode attribute, and + // deactivate layer-level mode flags. + activation.registerDisposer(() => { + this.statusBody = undefined; + this.setModeAttribute(undefined); + layer.spatialSkeletonMergeMode.value = false; + layer.spatialSkeletonState.clearPendingNodePositions(); + }); + + // 5. Activate edit mode watchable. + this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); + + // 6. Bind event maps. + activation.bindInputEventMap(SKELETON_UNIFIED_INPUT_EVENT_MAP); + rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); + this.bindPinnedSelectionAction(activation, { showNodeSelectionMessage: false }); + this.bindClearSelectionAction(activation); + + // 7. Register state-change watcher disposers. + activation.registerDisposer( + layer.selectedSpatialSkeletonNodeInfo.changed.add(() => this.renderStatus()), + ); + activation.registerDisposer( + layer.manager.root.selectionState.changed.add(() => this.renderStatus()), + ); + activation.registerDisposer( + layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(() => this.renderStatus()), + ); + activation.registerDisposer( + layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( + () => this.renderStatus(), + ), + ); + + // 8. Layer validity watcher. + activation.registerDisposer( + layer.layersChanged.add(() => { + const reason = layer.getSpatialSkeletonActionsDisabledReason( + [SpatialSkeletonActions.addNodes, SpatialSkeletonActions.moveNodes], + { ignoreCommandBusy: true, requireVisibleChunks: false }, + ); + if (reason !== undefined) { + StatusMessage.showTemporaryMessage(reason); + activation.cancel(); + } + }), + ); + + // 9. Global key/mouse listeners — thin lambda wrappers delegating to class methods. + const onKeyDown = (event: KeyboardEvent) => this.syncModifiers(event); + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === "KeyM") { + this.mergeKeyHeld = false; + this.exitMerge(); + } + if (event.code === "KeyN") this.exitCreate(); + if (event.code === "KeyS") this.splitKeyHeld = false; + this.syncModifiers(event); + }; + // mousemove catches modifiers pressed/released while keyboard focus is + // outside the panel (e.g. a text input elsewhere in the UI). + const onMouseMove = (event: MouseEvent) => this.syncModifiers(event); + const onBlur = () => { + this.mergeKeyHeld = false; + this.splitKeyHeld = false; + this.ctrlHeld = false; + this.shiftHeld = false; + this.exitMerge(); + this.exitCreate(); + this.updateModeAttribute(); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("blur", onBlur); + activation.registerDisposer(() => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("blur", onBlur); + }); + + // 10. Per-panel capture listeners — closures per panel; body delegates to class methods. // We don't bind mousedown0 in the EventActionMap because doing so would // shadow NG's rotate-via-mouse-drag / translate-via-mouse-drag actions // (both are EventActionMap actions at lower priority — stopPropagation:false @@ -892,7 +1354,6 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // we fire before MouseEventBinder's bubbling-phase listener. Off-node in // Default mode we return without calling stopPropagation, which lets // MouseEventBinder dispatch the rotate/pan action normally. - for (const panel of layer.manager.root.display.panels) { if (!(panel instanceof RenderedDataPanel)) continue; const captureMousedown = (event: MouseEvent) => { @@ -918,20 +1379,20 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // NG's translate/rotate action; other buttons → normal dispatch. // All three must pass through the capture listener unmodified. if (event.button !== 0 || event.ctrlKey || event.shiftKey) return; - if (currentMode === SkeletonEditMode.Merge) { + if (this.currentMode === SkeletonEditMode.Merge) { event.stopPropagation(); event.preventDefault(); - handleMergeSecondPick(event); + this.handleMergeSecondPick(); return; } - if (currentMode === SkeletonEditMode.Create) { + if (this.currentMode === SkeletonEditMode.Create) { event.stopPropagation(); event.preventDefault(); - handleCreatePlace(event); + this.handleCreatePlace(); return; } // Default mode: only consume if hovering a node. - handleDefaultMousedown(event, panel); + this.handleDefaultMousedown(event, panel); }; panel.element.addEventListener("mousedown", captureMousedown, { capture: true, @@ -943,460 +1404,15 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }); } - function handleDefaultMousedown( - event: MouseEvent, - panel: RenderedDataPanel, - ) { - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - const pickedNode = skeletonLayer - ? tool.getPickedSpatialSkeletonNode() - : undefined; - - if (pickedNode === undefined) { - // Off-node: don't consume — NG's bubbling-phase listener handles - // rotate/pan. Show a transient press cursor so the user knows what NG - // is about to do. - const pressMode = - panel instanceof PerspectivePanel ? "rotate" : "pan"; - panel.element.dataset.skeletonPressMode = pressMode; - window.addEventListener( - "pointerup", - () => { - delete panel.element.dataset.skeletonPressMode; - }, - { once: true }, - ); - return; - } - - // On a node: consume the event so NG doesn't also start a rotate/pan. - event.stopPropagation(); - event.preventDefault(); - if (skeletonLayer === undefined) return; - - const canMove = - layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.moveNodes, - ) === undefined; - const nodeInfo = canMove - ? skeletonLayer.getNode(pickedNode.nodeId) - : undefined; - - const pickedPosition = tool.mouseState.position; - const hasPickedPosition = - pickedPosition.length >= 3 && - Number.isFinite(pickedPosition[0]) && - Number.isFinite(pickedPosition[1]) && - Number.isFinite(pickedPosition[2]); - - // Can't start a drag: immediately select. - if (nodeInfo === undefined || !hasPickedPosition) { - if (pickedNode.segmentId !== undefined) { - tool.pinSegmentByNumber(pickedNode.segmentId); - } - layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - return; - } - - // Arm drag: threshold → move, no threshold → select. - let totalDeltaX = 0; - let totalDeltaY = 0; - let dragStarted = false; - let finished = false; - let moved = false; - - tool.dragModelSpacePosition.set(nodeInfo.position); - vec3.set( - tool.dragGlobalAnchorPosition, - Number(pickedPosition[0]), - Number(pickedPosition[1]), - Number(pickedPosition[2]), - ); - - startRelativeMouseDrag( - event, - (_dragEvent, deltaX, deltaY) => { - totalDeltaX += deltaX; - totalDeltaY += deltaY; - if (!dragStarted) { - const thresholdSq = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - if ( - totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < - thresholdSq - ) { - return; - } - dragStarted = true; - dragInProgress = true; - skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); - panel.element.dataset.skeletonPressMode = "move"; - setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); - } - panel.translateDataPointByViewportPixels( - tool.dragGlobalPosition, - tool.dragGlobalAnchorPosition, - totalDeltaX, - totalDeltaY, - ); - if ( - !Number.isFinite(tool.dragGlobalPosition[0]) || - !Number.isFinite(tool.dragGlobalPosition[1]) || - !Number.isFinite(tool.dragGlobalPosition[2]) - ) { - return; - } - const modelPosition = tool.globalToSkeletonCoordinates( - tool.dragGlobalPosition, - skeletonLayer!, - ); - if (modelPosition === undefined) return; - const previewChanged = - layer.spatialSkeletonState.setPendingNodePosition( - pickedNode.nodeId, - modelPosition, - ); - if (!previewChanged) return; - moved = true; - tool.dragModelSpacePosition.set(modelPosition); - }, - (_finishEvent) => { - if (finished) return; - finished = true; - if (dragInProgress) { - dragInProgress = false; - delete panel.element.dataset.skeletonPressMode; - clearStatus(); - } - if (!dragStarted) { - // Click → select. - if (pickedNode.segmentId !== undefined) { - tool.pinSegmentByNumber(pickedNode.segmentId); - } - layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - return; - } - if (moved) { - void executeSpatialSkeletonMoveNode(layer, { - node: nodeInfo!, - nextPositionInModelSpace: new Float32Array( - tool.dragModelSpacePosition, - ), - }) - .then(() => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - }) - .catch((error) => { - layer.spatialSkeletonState.clearPendingNodePosition( - pickedNode.nodeId, - ); - showSpatialSkeletonActionError("move node", error); - }); - return; - } - layer.spatialSkeletonState.clearPendingNodePosition(pickedNode.nodeId); - }, - ); - } - - function handleMergeSecondPick(event: MouseEvent) { - // Caller (capture listener) already called stopPropagation/preventDefault. - void event; - if (pending) return; - - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.mergeSkeletons, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - - const anchorNodeId = layer.spatialSkeletonState.mergeAnchorNodeId.value; - if (anchorNodeId === undefined) { - exitMerge(); - return; - } - const anchorNodeInfo = - skeletonLayer.getNode(anchorNodeId) ?? - layer.spatialSkeletonState.getCachedNode(anchorNodeId); - const firstNode = { - nodeId: anchorNodeId, - segmentId: anchorNodeInfo?.segmentId, - position: anchorNodeInfo?.position, - sourceState: anchorNodeInfo?.sourceState, - }; - - const pickedNode = - tool.resolvePickedNodeSelectionForMerge(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) - return; - - if ( - pickedNode.nodeId === anchorNodeId || - pickedNode.segmentId === firstNode.segmentId - ) { - StatusMessage.showTemporaryMessage( - "Select a node from a different skeleton to merge with.", - ); - return; - } - - if (firstNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve merge anchor segment.", - ); - return; - } - if (!tool.isSpatialSkeletonSegmentVisible(firstNode.segmentId)) { - StatusMessage.showTemporaryMessage( - `The first node selected for a merge operation must be from a visible skeleton. Make skeleton ${firstNode.segmentId} visible in the Seg tab or by double-clicking it in the viewer.`, - 3000, - ); - return; - } - - tool.pinSegmentByNumber(pickedNode.segmentId); - layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - pending = true; - setStatus("Merging selected nodes."); - - void (async () => { - try { - await waitForNextAnimationFrame(); - await executeSpatialSkeletonMerge( - layer, - { - nodeId: firstNode.nodeId, - segmentId: firstNode.segmentId!, - position: firstNode.position, - sourceState: firstNode.sourceState, - }, - { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId!, - position: pickedNode.position, - sourceState: pickedNode.sourceState, - }, - ); - } catch (error) { - showSpatialSkeletonActionError("merge skeletons", error); - } finally { - pending = false; - renderStatus(); // Keep merge mode — user may still be holding m. - } - })(); - } - - function handleCreatePlace(event: MouseEvent) { - // Caller (capture listener) already called stopPropagation/preventDefault. - void event; - - if (pending || createPlacedThisHold) return; - - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.addNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - - const clickPosition = - tool.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickPosition === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve click position for new skeleton.", - ); - return; - } - - createPlacedThisHold = true; - pending = true; - setStatus("Creating new skeleton."); - - void (async () => { - try { - await executeSpatialSkeletonAddNode(layer, { - skeletonId: 0, - parentNodeId: undefined, - positionInModelSpace: new Float32Array(clickPosition), - }); - } catch (error) { - showSpatialSkeletonActionError("create skeleton", error); - } finally { - pending = false; - renderStatus(); - } - })(); - } + // 11. Bind actions — thin one-liners delegating to class methods. + activation.bindAction("spatial-skeleton-enter-merge", () => this.onEnterMergeAction()); + activation.bindAction("spatial-skeleton-enter-create", () => this.onEnterCreateAction()); + activation.bindAction("spatial-skeleton-split", () => this.onSplitAction()); + activation.bindAction("spatial-skeleton-add-node", (event) => this.onAddNodeAction(event as ActionEvent)); + activation.bindAction("spatial-skeleton-delete-node", (event) => this.onDeleteNodeAction(event as ActionEvent)); - // --- Add node handler (ctrl+mousedown0) --- - - activation.bindAction( - "spatial-skeleton-add-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - - // Exit any sub-mode so state stays consistent. - if (currentMode === SkeletonEditMode.Merge) exitMerge(); - if (currentMode === SkeletonEditMode.Create) exitCreate(); - - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.addNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - - const selectedParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - if (selectedParentNodeId === undefined) { - StatusMessage.showTemporaryMessage( - "Select a node first, then ctrl+click to append a child.", - ); - return; - } - const addNodeBlockedReason = tool.getAddNodeBlockedReason( - skeletonLayer, - selectedParentNodeId, - ); - if (addNodeBlockedReason !== undefined) { - StatusMessage.showTemporaryMessage(addNodeBlockedReason); - return; - } - - const clickStartPosition = - tool.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickStartPosition === undefined) { - StatusMessage.showTemporaryMessage( - "Unable to resolve add-node position for this click.", - ); - return; - } - - let dragDistanceSquared = 0; - startRelativeMouseDrag( - event.detail, - (_dragEvent, deltaX, deltaY) => { - dragDistanceSquared += deltaX * deltaX + deltaY * deltaY; - }, - (_finishEvent) => { - const thresholdSquared = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; - if (dragDistanceSquared > thresholdSquared) { - return; - } - const currentParentNodeId = - layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; - if (currentParentNodeId === undefined) { - StatusMessage.showTemporaryMessage( - "Select a node first, then ctrl+click to append a child.", - ); - return; - } - const blockedReason = tool.getAddNodeBlockedReason( - skeletonLayer, - currentParentNodeId, - ); - if (blockedReason !== undefined) { - StatusMessage.showTemporaryMessage(blockedReason); - return; - } - const selectedParentNode = tool.getSelectedParentNodeForAdd( - skeletonLayer, - currentParentNodeId, - ); - const clickPositionInModelSpace = - tool.getMousePositionInSkeletonCoordinates(skeletonLayer); - if (clickPositionInModelSpace === undefined) return; - void (async () => { - try { - await executeSpatialSkeletonAddNode(layer, { - skeletonId: selectedParentNode?.segmentId ?? 0, - parentNodeId: currentParentNodeId, - positionInModelSpace: new Float32Array( - clickPositionInModelSpace, - ), - }); - } catch (error) { - showSpatialSkeletonActionError("create node", error); - } - })(); - }, - ); - }, - ); - - // --- Delete node handler (ctrl+alt+mousedown2) --- - - activation.bindAction( - "spatial-skeleton-delete-node", - (event: ActionEvent) => { - event.stopPropagation(); - event.detail.preventDefault(); - const disabledReason = layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.deleteNodes, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } - const skeletonLayer = tool.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) { - StatusMessage.showTemporaryMessage( - "No spatially indexed skeleton source is currently loaded.", - ); - return; - } - const pickedNode = tool.getPickedSpatialSkeletonNode(); - if (pickedNode === undefined) { - return; - } - const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); - if (nodeInfo === undefined) { - StatusMessage.showTemporaryMessage( - `Unable to resolve node ${pickedNode.nodeId} for deletion.`, - ); - return; - } - void layer - .getSpatialSkeletonDeleteOperationContext(nodeInfo) - .then(() => executeSpatialSkeletonDeleteNode(layer, nodeInfo)) - .catch((error) => { - showSpatialSkeletonActionError("delete node", error); - }); - }, - ); + // 12. Initial render. + this.renderStatus(); } } From 5517428a58ed4af933648d6357fe55c5ba7c1c69 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 14:15:30 +0200 Subject: [PATCH 03/11] feat: update UI with less tools (cherry picked from commit 28b61a0876c9489b918cce8745665958e9c9cd2f) --- src/ui/skeleton_tab.css | 4 ++++ src/ui/skeleton_tab.ts | 22 ++++++++-------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index 76396a9538..5503b3e738 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -115,6 +115,10 @@ flex: 0 0 auto; } +.neuroglancer-skeleton-filter-row .neuroglancer-tool-button { + margin-left: auto; +} + .neuroglancer-skeleton-navigation-bar { display: flex; align-items: center; diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 865d8f6250..2628169670 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -164,16 +164,6 @@ export class SpatialSkeletonEditTab extends Tab { const { element } = this; element.classList.add("neuroglancer-skeleton-tab"); - const toolbox = document.createElement("div"); - toolbox.className = - "neuroglancer-segmentation-toolbox neuroglancer-skeleton-toolbar"; - toolbox.appendChild( - makeToolButton(this, layer.toolBinder, { - toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, - label: "Edit", - title: "Toggle skeleton edit mode", - }), - ); const toolbarActions = document.createElement("div"); toolbarActions.className = "neuroglancer-skeleton-toolbar-actions"; @@ -213,8 +203,6 @@ export class SpatialSkeletonEditTab extends Tab { } })(); }); - toolbox.appendChild(toolbarActions); - const navTools = document.createElement("div"); navTools.className = "neuroglancer-skeleton-nav-tools"; @@ -275,9 +263,17 @@ export class SpatialSkeletonEditTab extends Tab { new VirtualList({ source: virtualListSource }), ); nodesList.element.className = "neuroglancer-skeleton-tree"; + nodeFilterTypeRow.appendChild( + makeToolButton(this, layer.toolBinder, { + toolJson: SPATIAL_SKELETON_EDIT_MODE_TOOL_ID, + label: "Edit", + title: "Toggle skeleton edit mode", + }), + ); nodesSection.appendChild(filterInput); nodesSection.appendChild(nodeFilterTypeRow); nodesNavigationBar.appendChild(navTools); + nodesNavigationBar.appendChild(toolbarActions); nodesSection.appendChild(nodesNavigationBar); nodesSummaryBar.appendChild(nodesSummary); nodesSection.appendChild(nodesSummaryBar); @@ -999,8 +995,6 @@ export class SpatialSkeletonEditTab extends Tab { goToClosestUnfinishedBranch(); }, ); - element.insertBefore(toolbox, nodesSection); - const gatedControls = [ goRootButton, goBranchStartButton, From ed584f647e93c2e6f9fbff823e772651dce33e8c Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 16:03:25 +0200 Subject: [PATCH 04/11] feat: add dual mode for split and merge (cherry picked from commit 1d573adc34ca5efd14c440c50b62f88fd8dd456b) --- src/ui/skeleton_edit_tools.css | 4 + src/ui/skeleton_edit_tools.ts | 191 ++++++++++++++++++++++++--------- 2 files changed, 146 insertions(+), 49 deletions(-) diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index fde9702ba1..7e1fc9eeff 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -66,6 +66,10 @@ cursor: url("data:image/svg+xml,N") 16 16, crosshair; } +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { + cursor: url("data:image/svg+xml,S") 16 16, crosshair; +} + /* Transient press-state cursors — driven by data-skeleton-press-mode on the panel element */ .neuroglancer-rendered-data-panel[data-skeleton-press-mode="rotate"] { cursor: move; diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index b08b82a6e9..54f7e8b7f8 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -69,12 +69,13 @@ import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; export const SPATIAL_SKELETON_EDIT_MODE_TOOL_ID = "spatialSkeletonEditMode"; -// Internal mode enum — only Default, Merge, Create are sustained states. -// Move and Select are both handled in Default; Split is a one-shot keydown with no sustained mode. +// Internal mode enum for sustained editing states. +// Move and Select are both handled in Default. const enum SkeletonEditMode { Default = 0, Merge = 1, Create = 2, + Split = 3, } // mousedown0 is intentionally absent — it is handled via capture-phase DOM @@ -549,6 +550,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute("merge"); } else if (this.currentMode === SkeletonEditMode.Create) { this.setModeAttribute("create"); + } else if (this.currentMode === SkeletonEditMode.Split) { + this.setModeAttribute("split"); } else if (this.ctrlHeld) { this.setModeAttribute("add"); } else if (this.shiftHeld) { @@ -600,11 +603,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } } else { renderSpatialSkeletonToolStatus(body, { - message: "Select a node to merge with · release m to exit", + message: "Click a node to set as merge anchor · release m to exit", }); } return; } + if (this.currentMode === SkeletonEditMode.Split) { + renderSpatialSkeletonToolStatus(body, { + message: "Click a node to split · release s to exit", + }); + return; + } if (this.currentMode === SkeletonEditMode.Create) { renderSpatialSkeletonToolStatus(body, { message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, @@ -649,17 +658,19 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Mode transitions --- - private enterMerge(anchorNode: { + private enterMerge(anchorNode?: { nodeId: number; segmentId?: number; position?: SpatialSkeletonVector; sourceState?: SpatialSkeletonSourceState; }) { - if (anchorNode.segmentId !== undefined) { - this.pinSegmentByNumber(anchorNode.segmentId); + if (anchorNode !== undefined) { + if (anchorNode.segmentId !== undefined) { + this.pinSegmentByNumber(anchorNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); + this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); } - this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); - this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); this.layer.spatialSkeletonMergeMode.value = true; this.currentMode = SkeletonEditMode.Merge; this.updateModeAttribute(); @@ -690,6 +701,19 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.clearStatus(); } + private enterSplit() { + this.currentMode = SkeletonEditMode.Split; + this.updateModeAttribute(); + this.renderStatus(); + } + + private exitSplit() { + if (this.currentMode !== SkeletonEditMode.Split) return; + this.currentMode = SkeletonEditMode.Default; + this.updateModeAttribute(); + this.clearStatus(); + } + // --- Mouse handlers --- private handleDefaultMousedown( @@ -854,6 +878,85 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); } + private executeSplitOnNode(pickedNode: { + nodeId: number; + segmentId: number; + position?: SpatialSkeletonVector; + }) { + this.pinSegmentByNumber(pickedNode.segmentId); + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + const splitPoint: SpatialSkeletonToolPointInfo = { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }; + this.pending = true; + this.setStatus("Splitting selected node.", splitPoint); + void (async () => { + try { + await executeSpatialSkeletonSplit(this.layer, { + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + }); + } catch (error) { + showSpatialSkeletonActionError("split skeleton", error); + } finally { + this.pending = false; + this.renderStatus(); + } + })(); + } + + private handleSplitPick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage("Click a skeleton node to split."); + return; + } + this.executeSplitOnNode({ + nodeId: pickedNode.nodeId, + segmentId: pickedNode.segmentId, + position: pickedNode.position, + }); + } + + private handleMergeFirstPick() { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + if (skeletonLayer === undefined) { + StatusMessage.showTemporaryMessage( + "No spatially indexed skeleton source is currently loaded.", + ); + return; + } + const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) { + StatusMessage.showTemporaryMessage("Click a skeleton node to set as merge anchor."); + return; + } + if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { + StatusMessage.showTemporaryMessage( + `Make skeleton ${pickedNode.segmentId} visible before merging.`, + ); + return; + } + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + this.renderStatus(); + } + private handleMergeSecondPick() { // Caller (capture listener) already called stopPropagation/preventDefault. if (this.pending) return; @@ -875,7 +978,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; if (anchorNodeId === undefined) { - this.exitMerge(); + // No anchor yet — this click sets the merge anchor. + this.handleMergeFirstPick(); return; } const anchorNodeInfo = @@ -1000,18 +1104,22 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Action implementations --- private onEnterMergeAction() { - // mergeKeyHeld prevents the error toast from repeating on key-repeat - // events while m is held without a hovered node. + // mergeKeyHeld prevents re-entry on key-repeat while m is held. if (this.mergeKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; this.mergeKeyHeld = true; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.mergeSkeletons, + ); + if (disabledReason !== undefined) { + StatusMessage.showTemporaryMessage(disabledReason); + return; + } const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); if (skeletonLayer === undefined) return; - const pickedNode = - this.resolvePickedNodeSelectionForMerge(skeletonLayer); + const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); if (pickedNode === undefined || pickedNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage( - "Hover over a skeleton node to enter merge mode.", - ); + // No node hovered — enter merge mode and wait for the user to click an anchor. + this.enterMerge(); return; } if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { @@ -1020,13 +1128,6 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); return; } - const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.mergeSkeletons, - ); - if (disabledReason !== undefined) { - StatusMessage.showTemporaryMessage(disabledReason); - return; - } this.enterMerge(pickedNode); } @@ -1036,9 +1137,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } private onSplitAction() { - // splitKeyHeld makes split a one-shot per keydown — browser key-repeat - // would otherwise fire it continuously while s is held. - if (this.splitKeyHeld || this.dragInProgress || this.pending) return; + // splitKeyHeld prevents re-entry on key-repeat while s is held. + if (this.splitKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; this.splitKeyHeld = true; const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.splitSkeletons, @@ -1056,33 +1156,16 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); if (pickedNode === undefined || pickedNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage( - "Hover over a skeleton node to split at that point.", - ); + // No node hovered — enter split mode and wait for the user to click a node. + this.enterSplit(); return; } - this.pinSegmentByNumber(pickedNode.segmentId); - this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - const splitPoint: SpatialSkeletonToolPointInfo = { + // Node is hovered — split immediately. + this.executeSplitOnNode({ nodeId: pickedNode.nodeId, segmentId: pickedNode.segmentId, position: pickedNode.position, - }; - this.pending = true; - this.setStatus("Splitting selected node.", splitPoint); - void (async () => { - try { - await executeSpatialSkeletonSplit(this.layer, { - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId!, - }); - } catch (error) { - showSpatialSkeletonActionError("split skeleton", error); - } finally { - this.pending = false; - this.clearStatus(); - } - })(); + }); } private onAddNodeAction(event: ActionEvent) { @@ -1319,7 +1402,10 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.exitMerge(); } if (event.code === "KeyN") this.exitCreate(); - if (event.code === "KeyS") this.splitKeyHeld = false; + if (event.code === "KeyS") { + this.splitKeyHeld = false; + this.exitSplit(); + } this.syncModifiers(event); }; // mousemove catches modifiers pressed/released while keyboard focus is @@ -1332,6 +1418,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.shiftHeld = false; this.exitMerge(); this.exitCreate(); + this.exitSplit(); this.updateModeAttribute(); }; window.addEventListener("keydown", onKeyDown); @@ -1385,6 +1472,12 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.handleMergeSecondPick(); return; } + if (this.currentMode === SkeletonEditMode.Split) { + event.stopPropagation(); + event.preventDefault(); + this.handleSplitPick(); + return; + } if (this.currentMode === SkeletonEditMode.Create) { event.stopPropagation(); event.preventDefault(); From b91b02434099ecebbfdac227a2adc02046e24507 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 14:00:31 +0200 Subject: [PATCH 05/11] fix: block modes at the same time (cherry picked from commit 791922be71ac65a78e8424b814cfe4c1f20ec3ec) --- src/ui/skeleton_edit_tools.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 54f7e8b7f8..7c6a3ff79b 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -1137,7 +1137,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } private onSplitAction() { - // splitKeyHeld prevents re-entry on key-repeat while s is held. + // splitKeyHeld makes split a one-shot per keydown — browser key-repeat + // would otherwise fire it continuously while s is held. if (this.splitKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; this.splitKeyHeld = true; const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( @@ -1172,9 +1173,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { event.stopPropagation(); event.detail.preventDefault(); - // Exit any sub-mode so state stays consistent. - if (this.currentMode === SkeletonEditMode.Merge) this.exitMerge(); - if (this.currentMode === SkeletonEditMode.Create) this.exitCreate(); + if (this.currentMode !== SkeletonEditMode.Default) return; const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.addNodes, From 50d9eb583c536577e6a018342b0224906a14672d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 17:03:16 +0200 Subject: [PATCH 06/11] refactor: update command naming (cherry picked from commit 7cd39be3bcc5b67a4404cc10ec589b36fdb35f7c) --- .../catmaid/spatial_skeleton_commands.ts | 14 ++++------ src/layer/segmentation/index.spec.ts | 2 +- src/layer/segmentation/index.ts | 28 +++++++++---------- src/skeleton/command_factories.ts | 4 +-- src/skeleton/command_history.spec.ts | 6 ++-- src/skeleton/command_history.ts | 12 +------- .../{actions.ts => command_protocol.ts} | 26 +++++++++++++++++ ...eton_commands.spec.ts => commands.spec.ts} | 14 +++++----- ...atial_skeleton_commands.ts => commands.ts} | 10 +++---- src/skeleton/spatial_skeleton_manager.spec.ts | 2 +- src/skeleton/spatial_skeleton_manager.ts | 2 +- src/ui/skeleton_edit_tools.spec.ts | 8 +++--- src/ui/skeleton_edit_tools.ts | 18 ++++++------ src/ui/skeleton_tab.ts | 18 ++++++------ 14 files changed, 88 insertions(+), 76 deletions(-) rename src/skeleton/{actions.ts => command_protocol.ts} (72%) rename src/skeleton/{spatial_skeleton_commands.spec.ts => commands.spec.ts} (99%) rename src/skeleton/{spatial_skeleton_commands.ts => commands.ts} (98%) diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index ea2b31c2b4..e19315f06a 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -52,20 +52,18 @@ import { addSegmentToVisibleSets, removeSegmentFromVisibleSets, } from "#src/segmentation_display_state/base.js"; -import { - SpatialSkeletonActions, - type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; import type { SpatialSkeletonEditCommandFactory } from "#src/skeleton/command_factories.js"; -import type { - SpatialSkeletonCommand, - SpatialSkeletonCommandContext, -} from "#src/skeleton/command_history.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, + type SpatialSkeletonCommand, + type SpatialSkeletonCommandContext, +} from "#src/skeleton/command_protocol.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { findSpatiallyIndexedSkeletonNode, diff --git a/src/layer/segmentation/index.spec.ts b/src/layer/segmentation/index.spec.ts index a0066483d7..eb835e8fd7 100644 --- a/src/layer/segmentation/index.spec.ts +++ b/src/layer/segmentation/index.spec.ts @@ -17,7 +17,7 @@ import { describe, expect, it, vi } from "vitest"; import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { WatchableValue } from "#src/trackable_value.js"; if (!("WebGL2RenderingContext" in globalThis)) { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 5ef75d3f13..4cd9b41faf 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -95,17 +95,26 @@ import type { import { SegmentationGraphSourceTab } from "#src/segmentation_graph/source.js"; import { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import type { + SpatiallyIndexedSkeletonNode, + SpatialSkeletonSourceState, +} from "#src/skeleton/api.js"; import { DEFAULT_SPATIAL_SKELETON_EDIT_ACTIONS, getSpatialSkeletonActionSupportLabel, isSpatialSkeletonEditAction, SpatialSkeletonActions, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { - SpatiallyIndexedSkeletonNode, - SpatialSkeletonSourceState, -} from "#src/skeleton/api.js"; +} from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeConfidenceUpdate, + executeSpatialSkeletonNodeDescriptionUpdate, + executeSpatialSkeletonNodeRadiusUpdate, + executeSpatialSkeletonReroot, + executeSpatialSkeletonNodeTrueEndUpdate, + showSpatialSkeletonActionError, +} from "#src/skeleton/commands.js"; import { PerspectiveViewSkeletonLayer, SkeletonLayer, @@ -129,15 +138,6 @@ import { SpatialSkeletonDisplayNodeType, SpatialSkeletonNodeFilterType, } from "#src/skeleton/node_types.js"; -import { - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonNodeConfidenceUpdate, - executeSpatialSkeletonNodeDescriptionUpdate, - executeSpatialSkeletonNodeRadiusUpdate, - executeSpatialSkeletonReroot, - executeSpatialSkeletonNodeTrueEndUpdate, - showSpatialSkeletonActionError, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { editableSpatiallyIndexedSkeletonSourceSupportsAction, getEditableSpatiallyIndexedSkeletonSource, diff --git a/src/skeleton/command_factories.ts b/src/skeleton/command_factories.ts index f889601bc0..5803a85a61 100644 --- a/src/skeleton/command_factories.ts +++ b/src/skeleton/command_factories.ts @@ -17,9 +17,9 @@ import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { SpatialSkeletonActions, + type SpatialSkeletonCommand, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +} from "#src/skeleton/command_protocol.js"; export type SpatialSkeletonCommandPayload = object; diff --git a/src/skeleton/command_history.spec.ts b/src/skeleton/command_history.spec.ts index ef3d36aaaa..a7f2467d9e 100644 --- a/src/skeleton/command_history.spec.ts +++ b/src/skeleton/command_history.spec.ts @@ -16,10 +16,8 @@ import { describe, expect, it } from "vitest"; -import { - SpatialSkeletonCommandHistory, - type SpatialSkeletonCommand, -} from "#src/skeleton/command_history.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import { type SpatialSkeletonCommand } from "#src/skeleton/command_protocol.js"; function deferred() { let resolve: (() => void) | undefined; diff --git a/src/skeleton/command_history.ts b/src/skeleton/command_history.ts index 4ca7093210..c81aa18013 100644 --- a/src/skeleton/command_history.ts +++ b/src/skeleton/command_history.ts @@ -14,22 +14,12 @@ * limitations under the License. */ +import type { SpatialSkeletonCommand } from "#src/skeleton/command_protocol.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; export const SPATIAL_SKELETON_COMMAND_HISTORY_MAX_ENTRIES = 100; -export interface SpatialSkeletonCommandContext { - readonly mappings: SpatialSkeletonCommandMappings; -} - -export interface SpatialSkeletonCommand { - readonly label: string; - execute(context: SpatialSkeletonCommandContext): Promise; - undo(context: SpatialSkeletonCommandContext): Promise; - redo?(context: SpatialSkeletonCommandContext): Promise; -} - interface SpatialSkeletonCommandMappingSnapshot { nodeIdMappings: Array<[number, number]>; segmentIdMappings: Array<[number, number]>; diff --git a/src/skeleton/actions.ts b/src/skeleton/command_protocol.ts similarity index 72% rename from src/skeleton/actions.ts rename to src/skeleton/command_protocol.ts index 283cb7a7a7..77aaa775a0 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/command_protocol.ts @@ -72,3 +72,29 @@ export function getSpatialSkeletonActionSupportLabel( return "skeleton splitting"; } } + +export interface SpatialSkeletonCommandContext { + readonly mappings: { + resolveNodeId(nodeId: number | undefined): number | undefined; + resolveSegmentId(segmentId: number | undefined): number | undefined; + getStableNodeId(nodeId: number | undefined): number | undefined; + getStableSegmentId(segmentId: number | undefined): number | undefined; + getStableOrCurrentNodeId(nodeId: number | undefined): number | undefined; + getStableOrCurrentSegmentId(segmentId: number | undefined): number | undefined; + remapNodeId( + originalNodeId: number | undefined, + currentNodeId: number, + ): boolean; + remapSegmentId( + originalSegmentId: number | undefined, + currentSegmentId: number, + ): boolean; + }; +} + +export interface SpatialSkeletonCommand { + readonly label: string; + execute(context: SpatialSkeletonCommandContext): Promise; + undo(context: SpatialSkeletonCommandContext): Promise; + redo?(context: SpatialSkeletonCommandContext): Promise; +} diff --git a/src/skeleton/spatial_skeleton_commands.spec.ts b/src/skeleton/commands.spec.ts similarity index 99% rename from src/skeleton/spatial_skeleton_commands.spec.ts rename to src/skeleton/commands.spec.ts index 26db38d875..9987713a12 100644 --- a/src/skeleton/spatial_skeleton_commands.spec.ts +++ b/src/skeleton/commands.spec.ts @@ -19,14 +19,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { buildCatmaidNeighborhoodEditContext } from "#src/datasource/catmaid/edit_state.js"; import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; -import { - findSpatiallyIndexedSkeletonNode, - getSpatiallyIndexedSkeletonDirectChildren, - getSpatiallyIndexedSkeletonNodeParent, -} from "#src/skeleton/node_traversal.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonDeleteNode, @@ -41,7 +36,12 @@ import { executeSpatialSkeletonSplit, redoSpatialSkeletonCommand, undoSpatialSkeletonCommand, -} from "#src/skeleton/spatial_skeleton_commands.js"; +} from "#src/skeleton/commands.js"; +import { + findSpatiallyIndexedSkeletonNode, + getSpatiallyIndexedSkeletonDirectChildren, + getSpatiallyIndexedSkeletonNodeParent, +} from "#src/skeleton/node_traversal.js"; import { SpatialSkeletonState } from "#src/skeleton/spatial_skeleton_manager.js"; import { StatusMessage } from "#src/status.js"; diff --git a/src/skeleton/spatial_skeleton_commands.ts b/src/skeleton/commands.ts similarity index 98% rename from src/skeleton/spatial_skeleton_commands.ts rename to src/skeleton/commands.ts index 83731a5f09..cbd5671e4f 100644 --- a/src/skeleton/spatial_skeleton_commands.ts +++ b/src/skeleton/commands.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - SpatialSkeletonActions, - type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; import type { EditableSpatiallyIndexedSkeletonSource, SpatiallyIndexedSkeletonNode, @@ -26,7 +22,11 @@ import type { SpatialSkeletonCommandPayload, SpatialSkeletonEditCommandFactory, } from "#src/skeleton/command_factories.js"; -import type { SpatialSkeletonCommand } from "#src/skeleton/command_history.js"; +import { + SpatialSkeletonActions, + type SpatialSkeletonAction, + type SpatialSkeletonCommand, +} from "#src/skeleton/command_protocol.js"; import { getSpatialSkeletonActionErrorMessage } from "#src/skeleton/edit_errors.js"; import { getEditableSpatiallyIndexedSkeletonSource, diff --git a/src/skeleton/spatial_skeleton_manager.spec.ts b/src/skeleton/spatial_skeleton_manager.spec.ts index 64b7b4f54e..02fa70e9c9 100644 --- a/src/skeleton/spatial_skeleton_manager.spec.ts +++ b/src/skeleton/spatial_skeleton_manager.spec.ts @@ -16,7 +16,7 @@ import { describe, expect, it, vi } from "vitest"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, getFlatListNodeIds, diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index d8d1c65389..ba39996136 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import type { SpatialSkeletonAction } from "#src/skeleton/actions.js"; import type { EditableSpatiallyIndexedSkeletonSource, SpatialSkeletonConfidenceConfiguration, @@ -29,6 +28,7 @@ import { SPATIAL_SKELETON_EDIT_COMMAND_METADATA, } from "#src/skeleton/command_factories.js"; import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +import type { SpatialSkeletonAction } from "#src/skeleton/command_protocol.js"; import type { SpatiallyIndexedSkeletonLayer } from "#src/skeleton/frontend.js"; import { WatchableValue } from "#src/trackable_value.js"; import { RefCounted } from "#src/util/disposable.js"; diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 4c60a6c762..340953cb55 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -18,16 +18,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { makeCatmaidNodeSourceState } from "#src/datasource/catmaid/api.js"; import { CatmaidSpatialSkeletonEditCommands } from "#src/datasource/catmaid/spatial_skeleton_commands.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; -import { SpatialSkeletonCommandHistory } from "#src/skeleton/command_history.js"; +} from "#src/skeleton/command_protocol.js"; import { executeSpatialSkeletonAddNode, executeSpatialSkeletonMerge, -} from "#src/skeleton/spatial_skeleton_commands.js"; +} from "#src/skeleton/commands.js"; import { StatusMessage } from "#src/status.js"; if (!("WebGL2RenderingContext" in globalThis)) { diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 7c6a3ff79b..7af38273e7 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -25,11 +25,19 @@ import { PerspectivePanel } from "#src/perspective_view/panel.js"; import { getChunkPositionFromCombinedGlobalLocalPositions } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; -import { SpatialSkeletonActions } from "#src/skeleton/actions.js"; import type { SpatialSkeletonSourceState, SpatialSkeletonVector, } from "#src/skeleton/api.js"; +import { SpatialSkeletonActions } from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonAddNode, + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonMerge, + executeSpatialSkeletonMoveNode, + executeSpatialSkeletonSplit, + showSpatialSkeletonActionError, +} from "#src/skeleton/commands.js"; import { type SpatiallyIndexedSkeletonLayer, setSpatialSkeletonModesToLinesAndPoints, @@ -38,14 +46,6 @@ import { PerspectiveViewSpatiallyIndexedSkeletonLayer, SliceViewPanelSpatiallyIndexedSkeletonLayer, } from "#src/skeleton/frontend.js"; -import { - executeSpatialSkeletonAddNode, - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonMerge, - executeSpatialSkeletonMoveNode, - executeSpatialSkeletonSplit, - showSpatialSkeletonActionError, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { StatusMessage } from "#src/status.js"; import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; import { diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 2628169670..1c75f7f2c5 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -36,11 +36,18 @@ import { getVisibleSegments, } from "#src/segmentation_display_state/base.js"; import { getBaseObjectColor } from "#src/segmentation_display_state/frontend.js"; +import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonActions, type SpatialSkeletonAction, -} from "#src/skeleton/actions.js"; -import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; +} from "#src/skeleton/command_protocol.js"; +import { + executeSpatialSkeletonDeleteNode, + executeSpatialSkeletonNodeTrueEndUpdate, + redoSpatialSkeletonCommand, + showSpatialSkeletonActionError, + undoSpatialSkeletonCommand, +} from "#src/skeleton/commands.js"; import { buildSpatiallyIndexedSkeletonNavigationGraph, getBranchEnd as getBranchEndFromGraph, @@ -60,13 +67,6 @@ import { SpatialSkeletonDisplayNodeType, SpatialSkeletonNodeFilterType, } from "#src/skeleton/node_types.js"; -import { - executeSpatialSkeletonDeleteNode, - executeSpatialSkeletonNodeTrueEndUpdate, - redoSpatialSkeletonCommand, - showSpatialSkeletonActionError, - undoSpatialSkeletonCommand, -} from "#src/skeleton/spatial_skeleton_commands.js"; import { StatusMessage } from "#src/status.js"; import { observeWatchable, registerNested } from "#src/trackable_value.js"; import { SPATIAL_SKELETON_EDIT_MODE_TOOL_ID } from "#src/ui/skeleton_edit_tools.js"; From d8fd73277eb8a38a090ab309c49c059b4e96a0cd Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 26 Jun 2026 17:09:05 +0200 Subject: [PATCH 07/11] chore: update func to async (cherry picked from commit bc8475846c5cbdedd492f4bbc253c4adf851b8e0) --- src/skeleton/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/skeleton/commands.ts b/src/skeleton/commands.ts index cbd5671e4f..73798994ef 100644 --- a/src/skeleton/commands.ts +++ b/src/skeleton/commands.ts @@ -69,7 +69,7 @@ function executeCommand( return layer.spatialSkeletonState.commandHistory.execute(command); } -function executeCommandWithPendingMessage( +async function executeCommandWithPendingMessage( promise: Promise, message: string, ) { From b2822ec796ad5e14baa70236dca988b178938ee0 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Sun, 28 Jun 2026 16:02:04 +0200 Subject: [PATCH 08/11] feat: update edit tool behaviour add actions for all skeleton edit buttons add mac specific binds indicate binds on buttons make controls non ambiguous tool banner shows if node from non visible skeleton block true end from being set as root (cherry picked from commit 71332b631fe4d4cefe6059855d18f5a7097d62e1) --- src/help/input_event_bindings.ts | 13 +- src/layer/segmentation/index.ts | 17 +- src/skeleton/actions.ts | 46 +++ src/skeleton/command_protocol.ts | 4 +- src/ui/command_palette.ts | 5 + src/ui/default_input_event_bindings.ts | 106 +++++++ src/ui/skeleton_edit_tool_messages.ts | 6 +- src/ui/skeleton_edit_tools.css | 20 +- src/ui/skeleton_edit_tools.spec.ts | 30 +- src/ui/skeleton_edit_tools.ts | 367 ++++++++++++----------- src/ui/skeleton_tab.ts | 395 ++++++++++++++++++++++--- src/ui/tool.ts | 4 + src/util/event_action_map.ts | 13 +- src/util/platform.ts | 22 ++ 14 files changed, 820 insertions(+), 228 deletions(-) create mode 100644 src/skeleton/actions.ts create mode 100644 src/util/platform.ts diff --git a/src/help/input_event_bindings.ts b/src/help/input_event_bindings.ts index ff5ec6b1b2..97c1c0581b 100644 --- a/src/help/input_event_bindings.ts +++ b/src/help/input_event_bindings.ts @@ -32,6 +32,7 @@ import { type EventActionMap, } from "#src/util/event_action_map.js"; import { emptyToUndefined } from "#src/util/json.js"; +import { isMacPlatform } from "#src/util/platform.js"; declare let NEUROGLANCER_BUILD_INFO: | { tag: string; url?: string; timestamp?: string } @@ -51,8 +52,16 @@ export function formatKeyName(name: string) { } export function formatKeyStroke(stroke: string) { - const parts = stroke.split("+"); - return parts.map(formatKeyName).join("+"); + const mac = isMacPlatform(); + return stroke + .split("+") + .map((part) => { + if (mac && part === "control") return "⌘"; + if (mac && part === "alt") return "⌥"; + if (mac && part === "shift") return "⇧"; + return formatKeyName(part); + }) + .join("+"); } const DEFAULT_HELP_PANEL_LOCATION: SidePanelLocation = { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 4cd9b41faf..0a9a578e4d 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -2295,12 +2295,14 @@ export class SegmentationUserLayer extends Base { ? "Load the active skeleton in the Skeleton tab before rerooting from Selection." : fullNodeInfo.parentNodeId === undefined ? "Selected node is already root." - : this.getSpatialSkeletonActionsDisabledReason( - SpatialSkeletonActions.reroot, - { - requireVisibleChunks: false, - }, - ); + : (fullNodeInfo.isTrueEnd ?? false) + ? "True end nodes cannot be set as root. Clear the true end state first." + : this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.reroot, + { + requireVisibleChunks: false, + }, + ); const rerootButton = document.createElement("button"); rerootButton.type = "button"; rerootButton.className = "neuroglancer-selection-details-skeleton-action"; @@ -2319,7 +2321,8 @@ export class SegmentationUserLayer extends Base { rerootButton.disabled || rerootPending || completeNodeInfo === undefined || - completeNodeInfo.parentNodeId === undefined + completeNodeInfo.parentNodeId === undefined || + (completeNodeInfo.isTrueEnd ?? false) ) { return; } diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts new file mode 100644 index 0000000000..c05b626b53 --- /dev/null +++ b/src/skeleton/actions.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Neuroglancer event action identifier strings for all skeleton UI. +// These are the "skeleton-*" prefixed action names used in EventActionMap bindings +// and registerActionListener calls throughout the skeleton tab and edit tool. +// Default key bindings live in src/ui/default_input_event_bindings.ts. + +// --- Tab navigation --- +export const SKELETON_GO_ROOT = "skeleton-go-root"; +export const SKELETON_GO_BRANCH_START = "skeleton-go-branch-start"; +export const SKELETON_GO_BRANCH_END = "skeleton-go-branch-end"; +export const SKELETON_GO_PARENT = "skeleton-go-parent"; +export const SKELETON_GO_CHILD = "skeleton-go-child"; +export const SKELETON_CYCLE_BRANCHES = "skeleton-cycle-branches"; +export const SKELETON_GO_UNFINISHED = "skeleton-go-unfinished-branch"; +export const SKELETON_UNDO = "skeleton-undo"; +export const SKELETON_REDO = "skeleton-redo"; + +// --- Node mutations (tab list focus + edit tool viewer focus) --- +export const SKELETON_TOGGLE_TRUE_END = "skeleton-toggle-true-end"; +export const SKELETON_REROOT = "skeleton-reroot"; + +// --- Edit tool spatial actions --- +export const SKELETON_ADD_NODE = "skeleton-add-node"; +// Merge (m): enters merge mode; click the anchor node, then the target node. +export const SKELETON_ENTER_MERGE_MODE = "skeleton-enter-merge-mode"; +// Split (s): enters split mode; click the node to split. +export const SKELETON_ENTER_SPLIT_MODE = "skeleton-enter-split-mode"; +export const SKELETON_ENTER_CREATE = "skeleton-enter-create"; +export const SKELETON_PIN_NODE = "skeleton-pin-node"; +export const SKELETON_DELETE_NODE = "skeleton-delete-node"; +export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection"; diff --git a/src/skeleton/command_protocol.ts b/src/skeleton/command_protocol.ts index 77aaa775a0..bfa367a78a 100644 --- a/src/skeleton/command_protocol.ts +++ b/src/skeleton/command_protocol.ts @@ -80,7 +80,9 @@ export interface SpatialSkeletonCommandContext { getStableNodeId(nodeId: number | undefined): number | undefined; getStableSegmentId(segmentId: number | undefined): number | undefined; getStableOrCurrentNodeId(nodeId: number | undefined): number | undefined; - getStableOrCurrentSegmentId(segmentId: number | undefined): number | undefined; + getStableOrCurrentSegmentId( + segmentId: number | undefined, + ): number | undefined; remapNodeId( originalNodeId: number | undefined, currentNodeId: number, diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts index f928cc45ae..14113c1c1a 100644 --- a/src/ui/command_palette.ts +++ b/src/ui/command_palette.ts @@ -25,6 +25,7 @@ import type { NormalizedEventIdentifier, } from "#src/util/event_action_map.js"; import { friendlyEventIdentifier } from "#src/util/event_action_map.js"; +import { isMacPlatform } from "#src/util/platform.js"; import type { Viewer } from "#src/viewer.js"; const SUPPLEMENTAL_COMMANDS: readonly { @@ -49,9 +50,13 @@ export interface CommandPaletteEntry { } function formatKeyStroke(stroke: string): string { + const mac = isMacPlatform(); return stroke .split("+") .map((part) => { + if (mac && part === "control") return "⌘"; + if (mac && part === "alt") return "⌥"; + if (mac && part === "shift") return "⇧"; if (part.startsWith("key")) return part.substring(3); if (part.startsWith("digit")) return part.substring(5); if (part.startsWith("arrow")) return part.substring(5); diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 3a1fc8429f..066a2e200f 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -14,6 +14,26 @@ * limitations under the License. */ +import { + SKELETON_ADD_NODE, + SKELETON_CLEAR_SELECTION, + SKELETON_CYCLE_BRANCHES, + SKELETON_DELETE_NODE, + SKELETON_ENTER_CREATE, + SKELETON_ENTER_MERGE_MODE, + SKELETON_ENTER_SPLIT_MODE, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_PIN_NODE, + SKELETON_REDO, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import { EventActionMap } from "#src/util/event_action_map.js"; import type { InputEventBindings } from "#src/viewer.js"; @@ -187,6 +207,92 @@ export function getDefaultSliceViewPanelBindings() { return defaultSliceViewPanelBindings; } +let defaultSkeletonTabBindings: EventActionMap | undefined; +export function getDefaultSkeletonTabBindings() { + if (defaultSkeletonTabBindings === undefined) { + defaultSkeletonTabBindings = EventActionMap.fromObject( + { + keyr: SKELETON_GO_ROOT, + "shift+keyr": SKELETON_REROOT, + keyb: SKELETON_GO_BRANCH_END, + "control+keyb": SKELETON_GO_BRANCH_START, + bracketleft: SKELETON_GO_PARENT, + bracketright: SKELETON_GO_CHILD, + keyl: SKELETON_CYCLE_BRANCHES, + keyf: SKELETON_GO_UNFINISHED, + "control+keyz": { action: SKELETON_UNDO, preventDefault: true }, + "control+shift+keyz": { action: SKELETON_REDO, preventDefault: true }, + }, + { label: "Skeleton Tab" }, + ); + } + return defaultSkeletonTabBindings; +} + +let defaultSkeletonListBindings: EventActionMap | undefined; +export function getDefaultSkeletonListBindings() { + if (defaultSkeletonListBindings === undefined) { + defaultSkeletonListBindings = EventActionMap.fromObject({ + keyt: SKELETON_TOGGLE_TRUE_END, + "shift+keyr": SKELETON_REROOT, + }); + } + return defaultSkeletonListBindings; +} + +let defaultSkeletonEditToolBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditToolBindings() { + if (defaultSkeletonEditToolBindings === undefined) { + defaultSkeletonEditToolBindings = EventActionMap.fromObject({ + "at:mousedown1": "rotate-via-mouse-drag", + "at:control+mousedown1": "translate-via-mouse-drag", + "at:shift+mousedown0": SKELETON_ADD_NODE, + "at:keym": SKELETON_ENTER_MERGE_MODE, + "at:keys": SKELETON_ENTER_SPLIT_MODE, + "at:keyn": SKELETON_ENTER_CREATE, + "at:control+mousedown2": { + action: SKELETON_PIN_NODE, + stopPropagation: true, + preventDefault: true, + }, + "at:control+alt+mousedown2": { + action: SKELETON_DELETE_NODE, + stopPropagation: true, + preventDefault: true, + }, + }); + } + return defaultSkeletonEditToolBindings; +} + +let defaultSkeletonEditAuxBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditAuxBindings() { + if (defaultSkeletonEditAuxBindings === undefined) { + defaultSkeletonEditAuxBindings = EventActionMap.fromObject({ + "at:shift+control+mousedown2": { + action: SKELETON_CLEAR_SELECTION, + stopPropagation: true, + preventDefault: true, + }, + }); + } + return defaultSkeletonEditAuxBindings; +} + +let defaultSkeletonEditNodeBindings: EventActionMap | undefined; +export function getDefaultSkeletonEditNodeBindings() { + if (defaultSkeletonEditNodeBindings === undefined) { + defaultSkeletonEditNodeBindings = EventActionMap.fromObject( + { + keyt: SKELETON_TOGGLE_TRUE_END, + "shift+keyr": SKELETON_REROOT, + }, + { label: "Skeleton Edit (node)" }, + ); + } + return defaultSkeletonEditNodeBindings; +} + export function setDefaultInputEventBindings( inputEventBindings: InputEventBindings, ) { diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index 8622ed554c..675d11e72b 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -46,11 +46,13 @@ export const SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE = "Select 1 node to split"; export const SPATIAL_SKELETON_MOVING_NODE_MESSAGE = "Moving node"; export const SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE = - "Click node to select · drag to move · hold m to merge · s to split · hold n to create · ctrl+click to add"; + "Click node to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · shift+click to create"; export const SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE = - "Node selected · drag to move · ctrl+click to add child · hold m to merge · s to split · hold n to create"; + "Node selected · drag to move · shift+click to create · hold m to merge · hold s to split · hold n for new skeleton"; export const SPATIAL_SKELETON_CREATE_BANNER_MESSAGE = "Click to place a new skeleton · release n to exit"; +export const SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE = + "Node selected from hidden skeleton · double-click to show it before moving or creating"; export function formatSpatialSkeletonToolPoint( point: SpatialSkeletonToolPointInfo, diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index 7e1fc9eeff..b9e362d08e 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -55,19 +55,31 @@ } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="add"] { - cursor: url("data:image/svg+xml,+") 16 16, crosshair; + cursor: + url("data:image/svg+xml,+") + 16 16, + crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { - cursor: url("data:image/svg+xml,M") 16 16, crosshair; + cursor: + url("data:image/svg+xml,M") + 16 16, + crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { - cursor: url("data:image/svg+xml,N") 16 16, crosshair; + cursor: + url("data:image/svg+xml,N") + 16 16, + crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { - cursor: url("data:image/svg+xml,S") 16 16, crosshair; + cursor: + url("data:image/svg+xml,S") + 16 16, + crosshair; } /* Transient press-state cursors — driven by data-skeleton-press-mode on the panel element */ diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 340953cb55..83f7123392 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -469,9 +469,8 @@ describe("spatial_skeleton_edit_tool", () => { }); it("blocks appending a child to a selected true-end node", () => { - const getAddNodeBlockedReason = ( - SpatialSkeletonEditTool.prototype as any - ).getAddNodeBlockedReason as ( + const getAddNodeBlockedReason = (SpatialSkeletonEditTool.prototype as any) + .getAddNodeBlockedReason as ( this: any, skeletonLayer: any, parentNodeId: number | undefined, @@ -493,9 +492,8 @@ describe("spatial_skeleton_edit_tool", () => { getCachedNode, }, }, - getSelectedParentNodeForAdd: ( - SpatialSkeletonEditTool.prototype as any - ).getSelectedParentNodeForAdd, + getSelectedParentNodeForAdd: (SpatialSkeletonEditTool.prototype as any) + .getSelectedParentNodeForAdd, }; expect(getAddNodeBlockedReason.call(tool, { getNode }, 17)).toBe( @@ -628,9 +626,8 @@ describe("spatial_skeleton_edit_tool", () => { it("clears the merge anchor when the clear-selection action runs with an active merge anchor", () => { suppressStatusMessages(); - const bindClearSelectionAction = ( - SpatialSkeletonEditTool.prototype as any - ).bindClearSelectionAction as (this: any, activation: any) => void; + const bindClearSelectionAction = (SpatialSkeletonEditTool.prototype as any) + .bindClearSelectionAction as (this: any, activation: any) => void; const clearSpatialSkeletonNodeSelection = vi.fn(); const clearSpatialSkeletonMergeAnchor = vi.fn(); const unpin = vi.fn(); @@ -754,7 +751,10 @@ describe("spatial_skeleton_edit_tool", () => { layersChanged: makeChangedSignal(), }; const { activation, actions, dispose } = makeToolActivation(); - const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); + const tool = Object.assign( + Object.create(SpatialSkeletonEditTool.prototype), + { layer }, + ); try { SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); @@ -841,7 +841,10 @@ describe("spatial_skeleton_edit_tool", () => { layersChanged: makeChangedSignal(), }; const { activation, actions, dispose } = makeToolActivation(); - const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); + const tool = Object.assign( + Object.create(SpatialSkeletonEditTool.prototype), + { layer }, + ); try { SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); @@ -910,7 +913,10 @@ describe("spatial_skeleton_edit_tool", () => { layersChanged: makeChangedSignal(), }; const { activation, actions, dispose } = makeToolActivation(); - const tool = Object.assign(Object.create(SpatialSkeletonEditTool.prototype), { layer }); + const tool = Object.assign( + Object.create(SpatialSkeletonEditTool.prototype), + { layer }, + ); try { SpatialSkeletonEditTool.prototype.activate.call(tool, activation as any); diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 7af38273e7..e2dae16b06 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -25,6 +25,17 @@ import { PerspectivePanel } from "#src/perspective_view/panel.js"; import { getChunkPositionFromCombinedGlobalLocalPositions } from "#src/render_coordinate_transform.js"; import { RenderedDataPanel } from "#src/rendered_data_panel.js"; import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; +import { + SKELETON_ADD_NODE, + SKELETON_CLEAR_SELECTION, + SKELETON_DELETE_NODE, + SKELETON_ENTER_CREATE, + SKELETON_ENTER_MERGE_MODE, + SKELETON_ENTER_SPLIT_MODE, + SKELETON_PIN_NODE, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, +} from "#src/skeleton/actions.js"; import type { SpatialSkeletonSourceState, SpatialSkeletonVector, @@ -35,6 +46,7 @@ import { executeSpatialSkeletonDeleteNode, executeSpatialSkeletonMerge, executeSpatialSkeletonMoveNode, + executeSpatialSkeletonNodeTrueEndUpdate, executeSpatialSkeletonSplit, showSpatialSkeletonActionError, } from "#src/skeleton/commands.js"; @@ -47,9 +59,15 @@ import { SliceViewPanelSpatiallyIndexedSkeletonLayer, } from "#src/skeleton/frontend.js"; import { StatusMessage } from "#src/status.js"; +import { + getDefaultSkeletonEditAuxBindings, + getDefaultSkeletonEditNodeBindings, + getDefaultSkeletonEditToolBindings, +} from "#src/ui/default_input_event_bindings.js"; import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; import { SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, + SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE, SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, SPATIAL_SKELETON_MOVING_NODE_MESSAGE, getSpatialSkeletonEditBannerMessage, @@ -63,7 +81,6 @@ import { } from "#src/ui/tool.js"; import { removeChildren } from "#src/util/dom.js"; import type { ActionEvent } from "#src/util/event_action_map.js"; -import { EventActionMap } from "#src/util/event_action_map.js"; import { vec3 } from "#src/util/geom.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; @@ -78,41 +95,18 @@ const enum SkeletonEditMode { Split = 3, } -// mousedown0 is intentionally absent — it is handled via capture-phase DOM -// listeners registered in activate() so that off-node clicks pass through to -// NG's own rotate/pan EventActionMap actions (which would be shadowed if we -// bound mousedown0 here at POSITIVE_INFINITY priority). +// In edit mode, left click is selection-only — it never rotates or pans. +// Navigation (rotate in perspective, pan in slice) is handled exclusively by +// middle mouse (mousedown1). mousedown0 is therefore handled only via the +// capture-phase DOM listeners in activate(); it is not in the EventActionMap. // -// mousedown1 (middle) is bound here for perspective-panel rotation. Slice -// panels intercept middle mouse in the capture listener below and call -// translateByViewportPixels directly, consuming the event before +// mousedown1 → rotate-via-mouse-drag covers perspective panels via the +// EventActionMap. Slice panels intercept middle mouse in the capture listener +// and call translateByViewportPixels directly, consuming the event before // MouseEventBinder can dispatch this action. -const SKELETON_UNIFIED_INPUT_EVENT_MAP = EventActionMap.fromObject({ - "at:mousedown1": "rotate-via-mouse-drag", - "at:control+mousedown0": "spatial-skeleton-add-node", - "at:keym": "spatial-skeleton-enter-merge", - "at:keys": "spatial-skeleton-split", - "at:keyn": "spatial-skeleton-enter-create", - "at:control+mousedown2": { - action: "spatial-skeleton-pin-node", - stopPropagation: true, - preventDefault: true, - }, - "at:control+alt+mousedown2": { - action: "spatial-skeleton-delete-node", - stopPropagation: true, - preventDefault: true, - }, -}); - -// Bound via the raw inputEventMapBinder so it stays out of the auto-generated subtitle. -const SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP = EventActionMap.fromObject({ - "at:shift+control+mousedown2": { - action: "spatial-skeleton-clear-node-selection", - stopPropagation: true, - preventDefault: true, - }, -}); +// +// Default bindings are defined in getDefaultSkeletonEditToolBindings() / +// getDefaultSkeletonEditAuxBindings() in default_input_event_bindings.ts. const DRAG_START_DISTANCE_PX = 2; @@ -350,7 +344,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool ) { const { showNodeSelectionMessage = true } = options; activation.bindAction( - "spatial-skeleton-pin-node", + SKELETON_PIN_NODE, (event: ActionEvent) => { event.stopPropagation(); event.detail.preventDefault(); @@ -387,7 +381,7 @@ abstract class SpatialSkeletonToolBase extends LayerTool protected bindClearSelectionAction(activation: ToolActivation) { activation.bindAction( - "spatial-skeleton-clear-node-selection", + SKELETON_CLEAR_SELECTION, (event: ActionEvent) => { event.stopPropagation(); event.detail.preventDefault(); @@ -520,7 +514,6 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private mergeKeyHeld = false; private splitKeyHeld = false; // Modifier-held state drives cursor indicators and blocks node actions. - private ctrlHeld = false; private shiftHeld = false; private statusOverride: string | undefined = undefined; private statusPoint: SpatialSkeletonToolPointInfo | undefined = undefined; @@ -544,7 +537,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // Recomputes the correct data-skeleton-edit-mode attribute from current // mode + held modifiers so callers don't have to care about that interaction. - // Priority: sustained tool modes > ctrl (add) > shift (translate hint). + // Priority: sustained tool modes > shift (add cursor hint). private updateModeAttribute() { if (this.currentMode === SkeletonEditMode.Merge) { this.setModeAttribute("merge"); @@ -552,10 +545,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute("create"); } else if (this.currentMode === SkeletonEditMode.Split) { this.setModeAttribute("split"); - } else if (this.ctrlHeld) { - this.setModeAttribute("add"); } else if (this.shiftHeld) { - this.setModeAttribute("shift"); + this.setModeAttribute("add"); } else { this.setModeAttribute(undefined); } @@ -622,8 +613,13 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } // Default mode const selectedPoint = this.getSelectedSpatialSkeletonNodeSummary(); + const isHidden = + selectedPoint?.segmentId !== undefined && + !this.isSpatialSkeletonSegmentVisible(selectedPoint.segmentId); renderSpatialSkeletonToolStatus(body, { - message: getSpatialSkeletonEditBannerMessage(selectedPoint), + message: isHidden + ? SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE + : getSpatialSkeletonEditBannerMessage(selectedPoint), point: selectedPoint, }); } @@ -643,15 +639,12 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Modifier tracking --- - // Sync ctrlHeld/shiftHeld from the logical modifier flags on any event that - // carries them. This mirrors what NG's EventActionMap does via - // getEventModifierMask, so OS-level modifier rebindings are transparent — - // we never inspect specific key codes for ctrl or shift. - private syncModifiers(event: { ctrlKey: boolean; shiftKey: boolean }) { - const isCtrl = event.ctrlKey; + // Sync shiftHeld from the logical modifier flag on any event that carries it. + // This mirrors what NG's EventActionMap does via getEventModifierMask, so + // OS-level modifier rebindings are transparent — we never inspect key codes. + private syncModifiers(event: { shiftKey: boolean }) { const isShift = event.shiftKey; - if (this.ctrlHeld === isCtrl && this.shiftHeld === isShift) return; - this.ctrlHeld = isCtrl; + if (this.shiftHeld === isShift) return; this.shiftHeld = isShift; this.updateModeAttribute(); } @@ -716,29 +709,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Mouse handlers --- - private handleDefaultMousedown( - event: MouseEvent, - panel: RenderedDataPanel, - ) { + private handleDefaultMousedown(event: MouseEvent, panel: RenderedDataPanel) { const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); const pickedNode = skeletonLayer ? this.getPickedSpatialSkeletonNode() : undefined; if (pickedNode === undefined) { - // Off-node: don't consume — NG's bubbling-phase listener handles - // rotate/pan. Show a transient press cursor so the user knows what NG - // is about to do. - const pressMode = - panel instanceof PerspectivePanel ? "rotate" : "pan"; - panel.element.dataset.skeletonPressMode = pressMode; - window.addEventListener( - "pointerup", - () => { - delete panel.element.dataset.skeletonPressMode; - }, - { once: true }, - ); + // Off-node left click: consume so NG's rotate/pan actions don't fire. + // Navigation is handled exclusively by middle mouse in edit mode. + event.stopPropagation(); + event.preventDefault(); return; } @@ -762,16 +743,18 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { Number.isFinite(pickedPosition[1]) && Number.isFinite(pickedPosition[2]); - // Can't start a drag: immediately select. + // Select immediately on mousedown so it always happens even if the drag + // finish callback never fires (e.g. pointer capture lost). + if (pickedNode.segmentId !== undefined) { + this.pinSegmentByNumber(pickedNode.segmentId); + } + this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + if (nodeInfo === undefined || !hasPickedPosition) { - if (pickedNode.segmentId !== undefined) { - this.pinSegmentByNumber(pickedNode.segmentId); - } - this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); - return; + return; // Can't drag: done after the select above. } - // Arm drag: threshold → move, no threshold → select. + // Arm drag: if threshold exceeded, move the node. let totalDeltaX = 0; let totalDeltaY = 0; let dragStarted = false; @@ -792,8 +775,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { totalDeltaX += deltaX; totalDeltaY += deltaY; if (!dragStarted) { - const thresholdSq = - DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; + const thresholdSq = DRAG_START_DISTANCE_PX * DRAG_START_DISTANCE_PX; if ( totalDeltaX * totalDeltaX + totalDeltaY * totalDeltaY < thresholdSq @@ -841,18 +823,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { delete panel.element.dataset.skeletonPressMode; this.clearStatus(); } - if (!dragStarted) { - // Click → select. - if (pickedNode.segmentId !== undefined) { - this.pinSegmentByNumber(pickedNode.segmentId); - } - this.layer.selectSpatialSkeletonNode( - pickedNode.nodeId, - true, - pickedNode, - ); - return; - } + if (!dragStarted) return; // Pure click: selection already happened on mousedown. if (moved) { void executeSpatialSkeletonMoveNode(this.layer, { node: nodeInfo!, @@ -873,7 +844,9 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }); return; } - this.layer.spatialSkeletonState.clearPendingNodePosition(pickedNode.nodeId); + this.layer.spatialSkeletonState.clearPendingNodePosition( + pickedNode.nodeId, + ); }, ); } @@ -940,7 +913,9 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); if (pickedNode === undefined || pickedNode.segmentId === undefined) { - StatusMessage.showTemporaryMessage("Click a skeleton node to set as merge anchor."); + StatusMessage.showTemporaryMessage( + "Click a skeleton node to set as merge anchor.", + ); return; } if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { @@ -976,7 +951,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { return; } - const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + const anchorNodeId = + this.layer.spatialSkeletonState.mergeAnchorNodeId.value; if (anchorNodeId === undefined) { // No anchor yet — this click sets the merge anchor. this.handleMergeFirstPick(); @@ -992,10 +968,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { sourceState: anchorNodeInfo?.sourceState, }; - const pickedNode = - this.resolvePickedNodeSelectionForMerge(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) - return; + const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); + if (pickedNode === undefined || pickedNode.segmentId === undefined) return; if ( pickedNode.nodeId === anchorNodeId || @@ -1103,9 +1077,15 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Action implementations --- - private onEnterMergeAction() { - // mergeKeyHeld prevents re-entry on key-repeat while m is held. - if (this.mergeKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; + // Merge (m): enters merge mode — click to pick the anchor node, then the target. + private onEnterMergeModeAction() { + if ( + this.mergeKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; this.mergeKeyHeld = true; const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.mergeSkeletons, @@ -1114,32 +1094,28 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { StatusMessage.showTemporaryMessage(disabledReason); return; } - const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); - if (skeletonLayer === undefined) return; - const pickedNode = this.resolvePickedNodeSelectionForMerge(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) { - // No node hovered — enter merge mode and wait for the user to click an anchor. - this.enterMerge(); - return; - } - if (!this.isSpatialSkeletonSegmentVisible(pickedNode.segmentId)) { - StatusMessage.showTemporaryMessage( - `Make skeleton ${pickedNode.segmentId} visible before merging.`, - ); - return; - } - this.enterMerge(pickedNode); + this.enterMerge(); } private onEnterCreateAction() { - if (this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; + if ( + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; this.enterCreate(); } - private onSplitAction() { - // splitKeyHeld makes split a one-shot per keydown — browser key-repeat - // would otherwise fire it continuously while s is held. - if (this.splitKeyHeld || this.dragInProgress || this.pending || this.currentMode !== SkeletonEditMode.Default) return; + // Split (s): enters split mode — click the node to split. + private onEnterSplitModeAction() { + if ( + this.splitKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; this.splitKeyHeld = true; const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.splitSkeletons, @@ -1155,18 +1131,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); return; } - const pickedNode = this.resolvePickedNodeSelection(skeletonLayer); - if (pickedNode === undefined || pickedNode.segmentId === undefined) { - // No node hovered — enter split mode and wait for the user to click a node. - this.enterSplit(); - return; - } - // Node is hovered — split immediately. - this.executeSplitOnNode({ - nodeId: pickedNode.nodeId, - segmentId: pickedNode.segmentId, - position: pickedNode.position, - }); + this.enterSplit(); } private onAddNodeAction(event: ActionEvent) { @@ -1194,7 +1159,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; if (selectedParentNodeId === undefined) { StatusMessage.showTemporaryMessage( - "Select a node first, then ctrl+click to append a child.", + "Select a node first, then shift+click to append a child.", ); return; } @@ -1232,7 +1197,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; if (currentParentNodeId === undefined) { StatusMessage.showTemporaryMessage( - "Select a node first, then ctrl+click to append a child.", + "Select a node first, then shift+click to append a child.", ); return; } @@ -1256,9 +1221,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { await executeSpatialSkeletonAddNode(this.layer, { skeletonId: selectedParentNode?.segmentId ?? 0, parentNodeId: currentParentNodeId, - positionInModelSpace: new Float32Array( - clickPositionInModelSpace, - ), + positionInModelSpace: new Float32Array(clickPositionInModelSpace), }); } catch (error) { showSpatialSkeletonActionError("create node", error); @@ -1315,7 +1278,6 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.createPlacedThisHold = false; this.mergeKeyHeld = false; this.splitKeyHeld = false; - this.ctrlHeld = false; this.shiftHeld = false; this.statusOverride = undefined; this.statusPoint = undefined; @@ -1358,20 +1320,27 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.activateModeWatchable(activation, layer.spatialSkeletonEditMode); // 6. Bind event maps. - activation.bindInputEventMap(SKELETON_UNIFIED_INPUT_EVENT_MAP); - rawInputEventMapBinder(SPATIAL_SKELETON_AUX_INPUT_EVENT_MAP, activation); - this.bindPinnedSelectionAction(activation, { showNodeSelectionMessage: false }); + activation.bindInputEventMap(getDefaultSkeletonEditToolBindings()); + rawInputEventMapBinder(getDefaultSkeletonEditAuxBindings(), activation); + rawInputEventMapBinder(getDefaultSkeletonEditNodeBindings(), activation); + this.bindPinnedSelectionAction(activation, { + showNodeSelectionMessage: false, + }); this.bindClearSelectionAction(activation); // 7. Register state-change watcher disposers. activation.registerDisposer( - layer.selectedSpatialSkeletonNodeInfo.changed.add(() => this.renderStatus()), + layer.selectedSpatialSkeletonNodeInfo.changed.add(() => + this.renderStatus(), + ), ); activation.registerDisposer( layer.manager.root.selectionState.changed.add(() => this.renderStatus()), ); activation.registerDisposer( - layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(() => this.renderStatus()), + layer.spatialSkeletonState.mergeAnchorNodeId.changed.add(() => + this.renderStatus(), + ), ); activation.registerDisposer( layer.displayState.segmentationGroupState.value.visibleSegments.changed.add( @@ -1413,7 +1382,6 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const onBlur = () => { this.mergeKeyHeld = false; this.splitKeyHeld = false; - this.ctrlHeld = false; this.shiftHeld = false; this.exitMerge(); this.exitCreate(); @@ -1432,23 +1400,18 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }); // 10. Per-panel capture listeners — closures per panel; body delegates to class methods. - // We don't bind mousedown0 in the EventActionMap because doing so would - // shadow NG's rotate-via-mouse-drag / translate-via-mouse-drag actions - // (both are EventActionMap actions at lower priority — stopPropagation:false - // on the binding only affects native DOM propagation, not the NG action - // dispatch hierarchy). Instead we register capture-phase DOM listeners so - // we fire before MouseEventBinder's bubbling-phase listener. Off-node in - // Default mode we return without calling stopPropagation, which lets - // MouseEventBinder dispatch the rotate/pan action normally. + // Left click (mousedown0) is handled here rather than in the EventActionMap so that + // we can consume off-node clicks without accidentally shadowing EventActionMap actions + // at lower priority. All left clicks are now owned by the edit tool — they either + // select a node or do nothing. Navigation (rotate/pan) belongs exclusively to middle + // mouse and is handled via the EventActionMap + the slice-panel path below. for (const panel of layer.manager.root.display.panels) { if (!(panel instanceof RenderedDataPanel)) continue; const captureMousedown = (event: MouseEvent) => { - // Middle mouse: rotate in perspective panels (handled by EventActionMap - // mousedown1 → rotate-via-mouse-drag), pan in slice panels. - // For slice panels we intercept here and drive translateByViewportPixels - // directly via startRelativeMouseDrag, consuming the event before - // MouseEventBinder can dispatch rotate-via-mouse-drag from the EventActionMap. - // For perspective panels we let it pass through unmodified. + // Middle mouse (plain): rotate in 3D (EventActionMap mousedown1 → rotate-via-mouse-drag), + // translate in 2D (intercepted here via startRelativeMouseDrag). + // Ctrl+middle: translate in 3D (EventActionMap control+mousedown1 → translate-via-mouse-drag), + // translate in 2D (intercepted here, same as plain middle). if (event.button === 1) { if (!(panel instanceof PerspectivePanel)) { event.stopPropagation(); @@ -1461,10 +1424,9 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { return; } - // ctrl+mousedown0 → EventActionMap (add-node); shift+mousedown0 → - // NG's translate/rotate action; other buttons → normal dispatch. - // All three must pass through the capture listener unmodified. - if (event.button !== 0 || event.ctrlKey || event.shiftKey) return; + // shift+mousedown0 → EventActionMap (add-node); other buttons → normal dispatch. + // Both must pass through the capture listener unmodified. + if (event.button !== 0 || event.shiftKey) return; if (this.currentMode === SkeletonEditMode.Merge) { event.stopPropagation(); event.preventDefault(); @@ -1497,11 +1459,86 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } // 11. Bind actions — thin one-liners delegating to class methods. - activation.bindAction("spatial-skeleton-enter-merge", () => this.onEnterMergeAction()); - activation.bindAction("spatial-skeleton-enter-create", () => this.onEnterCreateAction()); - activation.bindAction("spatial-skeleton-split", () => this.onSplitAction()); - activation.bindAction("spatial-skeleton-add-node", (event) => this.onAddNodeAction(event as ActionEvent)); - activation.bindAction("spatial-skeleton-delete-node", (event) => this.onDeleteNodeAction(event as ActionEvent)); + activation.bindAction(SKELETON_ENTER_MERGE_MODE, () => + this.onEnterMergeModeAction(), + ); + activation.bindAction(SKELETON_ENTER_CREATE, () => + this.onEnterCreateAction(), + ); + activation.bindAction(SKELETON_ENTER_SPLIT_MODE, () => + this.onEnterSplitModeAction(), + ); + activation.bindAction(SKELETON_ADD_NODE, (event) => + this.onAddNodeAction(event as ActionEvent), + ); + activation.bindAction(SKELETON_DELETE_NODE, (event) => + this.onDeleteNodeAction(event as ActionEvent), + ); + activation.bindAction(SKELETON_TOGGLE_TRUE_END, () => { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const nodeId = this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (nodeId === undefined) return; + const node = + skeletonLayer?.getNode(nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeId); + if (node === undefined) { + StatusMessage.showTemporaryMessage( + `Node ${nodeId} is not available in the skeleton cache.`, + ); + return; + } + const nextIsTrueEnd = !(node.isTrueEnd ?? false); + if (nextIsTrueEnd) { + if (node.parentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Cannot set the root node as a true end.", + ); + return; + } + const cachedSegmentNodes = + this.layer.spatialSkeletonState.getCachedSegmentNodes(node.segmentId); + if (cachedSegmentNodes !== undefined) { + const hasChildren = cachedSegmentNodes.some( + (candidate) => candidate.parentNodeId === node.nodeId, + ); + if (hasChildren) { + StatusMessage.showTemporaryMessage( + "Only leaf nodes can be marked as true ends.", + ); + return; + } + } + } + void executeSpatialSkeletonNodeTrueEndUpdate(this.layer, { + node, + nextIsTrueEnd, + }).catch((error) => + showSpatialSkeletonActionError("toggle true end", error), + ); + }); + activation.bindAction(SKELETON_REROOT, () => { + const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); + const nodeId = this.layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (nodeId === undefined) return; + const node = + skeletonLayer?.getNode(nodeId) ?? + this.layer.spatialSkeletonState.getCachedNode(nodeId); + if (node === undefined) { + StatusMessage.showTemporaryMessage( + `Node ${nodeId} is not available in the skeleton cache.`, + ); + return; + } + if (node.isTrueEnd) { + StatusMessage.showTemporaryMessage( + "Cannot set a true end node as root. Clear the true end state first.", + ); + return; + } + void this.layer + .rerootSpatialSkeletonNode(node) + .catch((error) => showSpatialSkeletonActionError("reroot", error)); + }); // 12. Initial render. this.renderStatus(); diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 1c75f7f2c5..b0d2a30a7d 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -36,6 +36,19 @@ import { getVisibleSegments, } from "#src/segmentation_display_state/base.js"; import { getBaseObjectColor } from "#src/segmentation_display_state/frontend.js"; +import { + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_REROOT, + SKELETON_TOGGLE_TRUE_END, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode } from "#src/skeleton/api.js"; import { SpatialSkeletonActions, @@ -69,6 +82,10 @@ import { } from "#src/skeleton/node_types.js"; import { StatusMessage } from "#src/status.js"; import { observeWatchable, registerNested } from "#src/trackable_value.js"; +import { + getDefaultSkeletonListBindings, + getDefaultSkeletonTabBindings, +} from "#src/ui/default_input_event_bindings.js"; import { SPATIAL_SKELETON_EDIT_MODE_TOOL_ID } from "#src/ui/skeleton_edit_tools.js"; import { buildSpatialSkeletonSegmentRenderState, @@ -77,7 +94,12 @@ import { } from "#src/ui/skeleton_tab_render.js"; import { makeToolButton } from "#src/ui/tool.js"; import type { ArraySpliceOp } from "#src/util/array.js"; +import { + registerActionListener, + KeyboardEventBinder, +} from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { Signal } from "#src/util/signal.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; @@ -167,6 +189,32 @@ export class SpatialSkeletonEditTab extends Tab { const toolbarActions = document.createElement("div"); toolbarActions.className = "neuroglancer-skeleton-toolbar-actions"; + const formatKeyHint = (stroke: string): string => { + const mac = isMacPlatform(); + const parts = stroke.split("+").map((part) => { + if (part === "control") return mac ? "⌘" : "Ctrl"; + if (part === "shift") return mac ? "⇧" : "Shift"; + if (part === "alt") return mac ? "⌥" : "Alt"; + if (part.startsWith("key")) return part.slice(3).toUpperCase(); + if (part.startsWith("digit")) return part.slice(5); + if (part === "bracketleft") return "["; + if (part === "bracketright") return "]"; + return part.charAt(0).toUpperCase() + part.slice(1); + }); + return parts.join(mac ? "" : "+"); + }; + + const tabBindings = getDefaultSkeletonTabBindings(); + const keyHintFor = (action: string): string => { + for (const [, eventAction] of tabBindings.entries()) { + if (eventAction.action === action) { + const key = eventAction.originalEventIdentifier; + if (key !== undefined) return ` (${formatKeyHint(key)})`; + } + } + return ""; + }; + const makeIconButton = ( parent: HTMLElement, svg: string, @@ -183,26 +231,36 @@ export class SpatialSkeletonEditTab extends Tab { parent.appendChild(button); return button; }; - const undoButton = makeIconButton(toolbarActions, svg_undo, "Undo", () => { - if (undoButton.disabled) return; - void (async () => { - try { - await undoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("undo", error); - } - })(); - }); - const redoButton = makeIconButton(toolbarActions, svg_redo, "Redo", () => { - if (redoButton.disabled) return; - void (async () => { - try { - await redoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("redo", error); - } - })(); - }); + const undoButton = makeIconButton( + toolbarActions, + svg_undo, + `Undo${keyHintFor(SKELETON_UNDO)}`, + () => { + if (undoButton.disabled) return; + void (async () => { + try { + await undoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + })(); + }, + ); + const redoButton = makeIconButton( + toolbarActions, + svg_redo, + `Redo${keyHintFor(SKELETON_REDO)}`, + () => { + if (redoButton.disabled) return; + void (async () => { + try { + await redoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + })(); + }, + ); const navTools = document.createElement("div"); navTools.className = "neuroglancer-skeleton-nav-tools"; @@ -278,8 +336,43 @@ export class SpatialSkeletonEditTab extends Tab { nodesSummaryBar.appendChild(nodesSummary); nodesSection.appendChild(nodesSummaryBar); nodesSection.appendChild(nodesList.element); + // tabIndex=-1 makes nodesSection programmatically focusable so that clicking + // anywhere in the section (buttons, labels, whitespace) focuses it, which + // causes shouldIgnoreEvent to hit the el===this.target fast-path and allow + // all keyboard shortcuts without needing a list row to be focused. + nodesSection.tabIndex = -1; element.appendChild(nodesSection); + const sectionKeyBinder = this.registerDisposer( + new KeyboardEventBinder(nodesSection, getDefaultSkeletonTabBindings()), + ); + // modifierShortcutsAreGlobal=true (the default) blocks Alt/Ctrl shortcuts + // when a BUTTON child (nav or undo/redo buttons) has focus. Setting false + // lets those shortcuts through while still blocking them in the filter INPUT. + sectionKeyBinder.modifierShortcutsAreGlobal = false; + + const listKeyBinder = this.registerDisposer( + new KeyboardEventBinder( + nodesList.element, + getDefaultSkeletonListBindings(), + ), + ); + listKeyBinder.modifierShortcutsAreGlobal = false; + + // Add the tab navigation map to the viewer's slice and perspective view + // panels so shortcuts work when the user's focus is on a viewport, not just + // the sidebar. Scoped to this Tab's lifetime via `this` as the context. + layer.manager.root.toolBinder.bindInputEventMap( + getDefaultSkeletonTabBindings(), + this, + ); + + // Guard used by all window-level action listeners to ensure only the + // selected layer's tab handles global actions when multiple skeleton + // layers are loaded simultaneously. + const isThisLayerSelected = () => + layer.managedLayer === layer.manager.root.selectedLayer.layer; + let allNodes: SpatiallyIndexedSkeletonNode[] = []; let activeSegmentId: number | undefined; let nodesBySegment = new Map(); @@ -461,7 +554,7 @@ export class SpatialSkeletonEditTab extends Tab { ) => { const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => - event.ctrlKey && !event.altKey && !event.metaKey; + (isMacPlatform() ? event.metaKey : event.ctrlKey) && !event.altKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { return; @@ -480,10 +573,14 @@ export class SpatialSkeletonEditTab extends Tab { }); }; - const getSegmentSelectionTitle = (segmentId: number) => - `segment ${segmentId}\n` + - "Ctrl+right-click to pin selection\n" + - "Ctrl+shift+right-click to unpin"; + const getSegmentSelectionTitle = (segmentId: number) => { + const modKey = isMacPlatform() ? "Cmd" : "Ctrl"; + return ( + `segment ${segmentId}\n` + + `${modKey}+right-click to pin selection\n` + + `${modKey}+shift+right-click to unpin` + ); + }; const getNodeDescriptionText = (node: SpatiallyIndexedSkeletonNode) => layer.getSpatialSkeletonNodeDisplayDescription(node); @@ -721,6 +818,24 @@ export class SpatialSkeletonEditTab extends Tab { ) => { if (!ensureActionsAllowed(SpatialSkeletonActions.editNodeTrueEnd)) return; if (pendingTrueEndNodes.has(node.nodeId)) return; + if (present) { + if (node.parentNodeId === undefined) { + StatusMessage.showTemporaryMessage( + "Cannot set the root node as a true end.", + ); + return; + } + const segmentNodes = nodesBySegment.get(node.segmentId) ?? []; + const hasChildren = segmentNodes.some( + (candidate) => candidate.parentNodeId === node.nodeId, + ); + if (hasChildren) { + StatusMessage.showTemporaryMessage( + "Only leaf nodes can be marked as true ends.", + ); + return; + } + } pendingTrueEndNodes.add(node.nodeId); updateDisplay(); void (async () => { @@ -822,6 +937,12 @@ export class SpatialSkeletonEditTab extends Tab { StatusMessage.showTemporaryMessage("Selected node is already root."); return; } + if (node.isTrueEnd) { + StatusMessage.showTemporaryMessage( + "Cannot set a true end node as root. Clear the true end state first.", + ); + return; + } if (pendingRerootNodes.has(node.nodeId)) { return; } @@ -842,7 +963,7 @@ export class SpatialSkeletonEditTab extends Tab { const goRootButton = makeIconButton( navTools, svg_origin, - "Go to root", + `Go to root${keyHintFor(SKELETON_GO_ROOT)}`, () => { const segmentId = getSelectedNavigationContext( false /* requireNode */, @@ -866,7 +987,7 @@ export class SpatialSkeletonEditTab extends Tab { const goBranchStartButton = makeIconButton( navTools, svg_chevrons_left, - "Go to start of branch", + `Go to start of branch${keyHintFor(SKELETON_GO_BRANCH_START)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -888,7 +1009,7 @@ export class SpatialSkeletonEditTab extends Tab { const goTreeEndButton = makeIconButton( navTools, svg_chevrons_right, - "Go to end of branch", + `Go to end of branch${keyHintFor(SKELETON_GO_BRANCH_END)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -910,7 +1031,7 @@ export class SpatialSkeletonEditTab extends Tab { const cycleBranchesButton = makeIconButton( navTools, svg_retweet, - "Cycle through level nodes", + `Cycle through level nodes${keyHintFor(SKELETON_CYCLE_BRANCHES)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -934,7 +1055,7 @@ export class SpatialSkeletonEditTab extends Tab { const goParentButton = makeIconButton( navTools, svg_arrow_left, - "Go to parent", + `Go to parent${keyHintFor(SKELETON_GO_PARENT)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -963,7 +1084,7 @@ export class SpatialSkeletonEditTab extends Tab { const goChildButton = makeIconButton( navTools, svg_arrow_right, - "Go to child", + `Go to child${keyHintFor(SKELETON_GO_CHILD)}`, () => { const selectedNode = getSelectedNavigationContext(); if (selectedNode === undefined) return; @@ -990,7 +1111,7 @@ export class SpatialSkeletonEditTab extends Tab { const goUnfinishedBranchButton = makeIconButton( navTools, svg_chevron_right, - "Go to nearest unfinished leaf node", + `Go to nearest unfinished leaf node${keyHintFor(SKELETON_GO_UNFINISHED)}`, () => { goToClosestUnfinishedBranch(); }, @@ -1306,7 +1427,11 @@ export class SpatialSkeletonEditTab extends Tab { const actions = document.createElement("div"); actions.className = "neuroglancer-skeleton-node-actions"; let rerootActionTitle = - node.parentNodeId === undefined ? "Already root" : "Set as root"; + node.parentNodeId === undefined + ? "Already root" + : nodeIsTrueEnd + ? "Clear true end state first to set as root" + : "Set as root"; if (pendingRerootNodes.has(node.nodeId)) { rerootActionTitle = "Setting root"; } @@ -1317,7 +1442,8 @@ export class SpatialSkeletonEditTab extends Tab { () => rerootNode(node), !nodeRerootAllowed || pendingRerootNodes.has(node.nodeId) || - node.parentNodeId === undefined, + node.parentNodeId === undefined || + nodeIsTrueEnd, ), ); let deleteActionTitle = "Delete node"; @@ -1728,6 +1854,207 @@ export class SpatialSkeletonEditTab extends Tab { updateDisplay(); }), ); + // --- Keyboard action listeners --- + + // Navigation + undo/redo: registered on window so they fire from both the + // sidebar and the viewer's slice/perspective panels. The isThisLayerSelected + // guard ensures only the selected layer's tab handles the event when multiple + // skeleton layers are loaded simultaneously. + this.registerDisposer( + registerActionListener(window, SKELETON_GO_ROOT, () => { + if (!isThisLayerSelected()) return; + const segmentId = getSelectedNavigationContext(false)?.segmentId; + if (segmentId === undefined) return; + void (async () => { + try { + navigateToNodeTarget( + await skeletonNavigationApi.getSkeletonRootNode(segmentId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate skeleton root: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_GO_BRANCH_START, () => { + if (!isThisLayerSelected()) return; + const selectedNode = getSelectedNavigationContext(); + if (selectedNode === undefined) return; + void (async () => { + try { + navigateToNodeTarget( + await skeletonNavigationApi.getBranchStart(selectedNode.nodeId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate branch start: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_GO_BRANCH_END, () => { + if (!isThisLayerSelected()) return; + const selectedNode = getSelectedNavigationContext(); + if (selectedNode === undefined) return; + void (async () => { + try { + navigateToNodeTarget( + await skeletonNavigationApi.getBranchEnd(selectedNode.nodeId), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate branch end: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_GO_PARENT, () => { + if (!isThisLayerSelected()) return; + const selectedNode = getSelectedNavigationContext(); + if (selectedNode === undefined) return; + void (async () => { + try { + const target = await skeletonNavigationApi.getParentNode( + selectedNode.nodeId, + ); + if (target === undefined) { + StatusMessage.showTemporaryMessage( + "Selected node has no parent.", + ); + return; + } + navigateToNodeTarget(target); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate parent node: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_GO_CHILD, () => { + if (!isThisLayerSelected()) return; + const selectedNode = getSelectedNavigationContext(); + if (selectedNode === undefined) return; + void (async () => { + try { + const target = await skeletonNavigationApi.getChildNode( + selectedNode.nodeId, + ); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no child."); + return; + } + navigateToNodeTarget(target); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to locate child node: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_CYCLE_BRANCHES, () => { + if (!isThisLayerSelected()) return; + const selectedNode = getSelectedNavigationContext(); + if (selectedNode === undefined) return; + void (async () => { + try { + navigateToNodeTarget( + await skeletonNavigationApi.getNextCollapsedLevelNode( + selectedNode.nodeId, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Failed to cycle through level nodes: ${message}`, + ); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_GO_UNFINISHED, () => { + if (!isThisLayerSelected()) return; + goToClosestUnfinishedBranch(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_UNDO, () => { + if (!isThisLayerSelected()) return; + void (async () => { + try { + await undoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + })(); + }), + ); + this.registerDisposer( + registerActionListener(window, SKELETON_REDO, () => { + if (!isThisLayerSelected()) return; + void (async () => { + try { + await redoSpatialSkeletonCommand(layer); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + })(); + }), + ); + + // List-level: node mutations + this.registerDisposer( + registerActionListener( + nodesList.element, + SKELETON_TOGGLE_TRUE_END, + () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) return; + const selectedNode = allNodes.find( + (node) => node.nodeId === selectedNodeId, + ); + if (selectedNode === undefined) return; + updateTrueEndLabel(selectedNode, !(selectedNode.isTrueEnd ?? false)); + }, + ), + ); + this.registerDisposer( + registerActionListener(nodesList.element, SKELETON_REROOT, () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) return; + const selectedNode = allNodes.find( + (node) => node.nodeId === selectedNodeId, + ); + if (selectedNode === undefined) return; + rerootNode(selectedNode); + }), + ); + updateGateStatus(); updateHistoryButtons(); updateHoveredViewerNode(); diff --git a/src/ui/tool.ts b/src/ui/tool.ts index 847bfe877b..30ecfb7f6d 100644 --- a/src/ui/tool.ts +++ b/src/ui/tool.ts @@ -317,6 +317,10 @@ export class GlobalToolBinder extends RefCounted { super(); } + bindInputEventMap(inputEventMap: EventActionMap, context: RefCounted) { + this.inputEventMapBinder(inputEventMap, context); + } + get(key: string): Borrowed | undefined { return this.bindings.get(key); } diff --git a/src/util/event_action_map.ts b/src/util/event_action_map.ts index 912c7074d9..99c1c96e4e 100644 --- a/src/util/event_action_map.ts +++ b/src/util/event_action_map.ts @@ -17,6 +17,7 @@ import { registerEventListener } from "#src/util/disposable.js"; import type { HierarchicalMapInterface } from "#src/util/hierarchical_map.js"; import { HierarchicalMap } from "#src/util/hierarchical_map.js"; +import { isMacPlatform } from "#src/util/platform.js"; /** * @file Facilities for dispatching user-defined actions in response to input events. @@ -463,8 +464,18 @@ export function dispatchEventWithModifiers( detail: any, eventMap: EventActionMapInterface, ) { + let modifiers = getEventModifierMask(originalEvent); + // On Mac, treat Cmd (meta) as Ctrl for shortcut matching so that + // "control+key" bindings fire when the user presses Cmd+key. + if ( + isMacPlatform() && + modifiers & Modifiers.META && + !(modifiers & Modifiers.CONTROL) + ) { + modifiers = (modifiers & ~Modifiers.META) | Modifiers.CONTROL; + } dispatchEvent( - getStrokeIdentifier(baseIdentifier, getEventModifierMask(originalEvent)), + getStrokeIdentifier(baseIdentifier, modifiers), originalEvent, originalEvent.eventPhase, detail, diff --git a/src/util/platform.ts b/src/util/platform.ts new file mode 100644 index 0000000000..b2c60fa930 --- /dev/null +++ b/src/util/platform.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac|iPhone|iPad/.test( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? "", + ); +} From 2334c1262ae2295231be2f26e6b01371c7a0d603 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Jun 2026 11:34:23 +0200 Subject: [PATCH 09/11] refactor: use correct actions pattern following recolor (cherry picked from commit 7d0df5b1ac5c001c261685c93e3a8d2d44f370ab) --- src/layer/segmentation/index.ts | 220 ++++++++++++++++++++++++++++++++ src/ui/skeleton_tab.ts | 177 ------------------------- src/viewer.ts | 27 ++++ 3 files changed, 247 insertions(+), 177 deletions(-) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 0a9a578e4d..db36fdd47c 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -95,6 +95,17 @@ import type { import { SegmentationGraphSourceTab } from "#src/segmentation_graph/source.js"; import { SharedDisjointUint64Sets } from "#src/shared_disjoint_sets.js"; import { SharedWatchableValue } from "#src/shared_watchable_value.js"; +import { + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import type { SpatiallyIndexedSkeletonNode, SpatialSkeletonSourceState, @@ -113,7 +124,9 @@ import { executeSpatialSkeletonNodeRadiusUpdate, executeSpatialSkeletonReroot, executeSpatialSkeletonNodeTrueEndUpdate, + redoSpatialSkeletonCommand, showSpatialSkeletonActionError, + undoSpatialSkeletonCommand, } from "#src/skeleton/commands.js"; import { PerspectiveViewSkeletonLayer, @@ -126,6 +139,16 @@ import { SpatiallyIndexedSkeletonSource, MultiscaleSpatiallyIndexedSkeletonSource, } from "#src/skeleton/frontend.js"; +import { + buildSpatiallyIndexedSkeletonNavigationGraph, + getBranchEnd as getBranchEndFromGraph, + getBranchStart as getBranchStartFromGraph, + getNextCollapsedLevelNode as getNextCollapsedLevelNodeFromGraph, + getOpenLeaves as getOpenLeavesFromGraph, + getParentNode as getParentNodeFromGraph, + getRandomChildNode as getRandomChildNodeFromGraph, + getSkeletonRootNode as getSkeletonRootNodeFromGraph, +} from "#src/skeleton/navigation_graph.js"; import { findSpatiallyIndexedSkeletonNode, getSpatiallyIndexedSkeletonDirectChildren, @@ -2017,8 +2040,205 @@ export class SegmentationUserLayer extends Base { } break; } + case SKELETON_GO_ROOT: + case SKELETON_GO_BRANCH_START: + case SKELETON_GO_BRANCH_END: + case SKELETON_CYCLE_BRANCHES: + case SKELETON_GO_PARENT: + case SKELETON_GO_CHILD: + case SKELETON_GO_UNFINISHED: + case SKELETON_UNDO: + case SKELETON_REDO: { + if (!this.shouldHandleGlobalSkeletonAction()) return; + void this.handleSkeletonNavigationAction(action); + break; + } } } + + private shouldHandleGlobalSkeletonAction(): boolean { + let skeletonLayerCount = 0; + for (const managedLayer of this.manager.root.layerManager.managedLayers) { + if (!managedLayer.visible || managedLayer.layer === null) continue; + const layer = managedLayer.layer; + if ( + layer instanceof SegmentationUserLayer && + layer.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + { requireVisibleChunks: false }, + ) === undefined + ) { + skeletonLayerCount++; + if (skeletonLayerCount > 1) break; + } + } + return ( + skeletonLayerCount <= 1 || + this.managedLayer === this.manager.root.selectedLayer.layer + ); + } + + private async handleSkeletonNavigationAction(action: string): Promise { + const inspectDisabledReason = this.getSpatialSkeletonActionsDisabledReason( + SpatialSkeletonActions.inspect, + { requireVisibleChunks: false }, + ); + if (inspectDisabledReason !== undefined) { + StatusMessage.showTemporaryMessage(inspectDisabledReason); + return; + } + + if (action === SKELETON_UNDO) { + try { + await undoSpatialSkeletonCommand(this); + } catch (error) { + showSpatialSkeletonActionError("undo", error); + } + return; + } + if (action === SKELETON_REDO) { + try { + await redoSpatialSkeletonCommand(this); + } catch (error) { + showSpatialSkeletonActionError("redo", error); + } + return; + } + + const nodeInfo = this.selectedSpatialSkeletonNodeInfo.value; + const cachedNode = + nodeInfo?.nodeId !== undefined + ? this.spatialSkeletonState.getCachedNode(nodeInfo.nodeId) + : undefined; + + const segmentId = + cachedNode?.segmentId ?? + nodeInfo?.segmentId ?? + getSegmentIdFromLayerSelectionValue( + this.manager.root.selectionState.value?.layers.find( + (entry) => entry.layer === this, + )?.state, + ); + + if (segmentId === undefined) { + StatusMessage.showTemporaryMessage("No segment/skeleton is selected."); + return; + } + + const segmentNodes = + this.spatialSkeletonState.getCachedSegmentNodes(segmentId); + if (segmentNodes === undefined || segmentNodes.length === 0) { + StatusMessage.showTemporaryMessage( + "A non-visible segment is selected. Make it visible to use skeleton navigation features.", + ); + return; + } + + const graph = buildSpatiallyIndexedSkeletonNavigationGraph(segmentNodes); + + try { + if (action === SKELETON_GO_ROOT) { + const target = getSkeletonRootNodeFromGraph(graph); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + return; + } + + const nodeId = cachedNode?.nodeId ?? nodeInfo?.nodeId; + if (nodeId === undefined) { + StatusMessage.showTemporaryMessage( + "No skeleton node is selected, only go to root is supported on skeleton edges.", + ); + return; + } + + switch (action) { + case SKELETON_GO_BRANCH_START: { + const target = getBranchStartFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_BRANCH_END: { + const target = getBranchEndFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_CYCLE_BRANCHES: { + const target = getNextCollapsedLevelNodeFromGraph(graph, nodeId); + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_PARENT: { + const target = getParentNodeFromGraph(graph, nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no parent."); + return; + } + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_CHILD: { + const target = getRandomChildNodeFromGraph(graph, nodeId); + if (target === undefined) { + StatusMessage.showTemporaryMessage("Selected node has no child."); + return; + } + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: target.nodeId, + segmentId, + position: target.position, + }); + break; + } + case SKELETON_GO_UNFINISHED: { + const openLeaves = getOpenLeavesFromGraph(graph, nodeId); + if (openLeaves.length === 0) { + StatusMessage.showTemporaryMessage( + "No unfinished branch was found in the current skeleton.", + ); + return; + } + openLeaves.sort((a, b) => + a.distance === b.distance + ? a.nodeId - b.nodeId + : a.distance - b.distance, + ); + const leaf = openLeaves[0]; + this.selectAndMoveToSpatialSkeletonNode({ + nodeId: leaf.nodeId, + segmentId, + position: leaf.position, + }); + break; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + StatusMessage.showTemporaryMessage( + `Skeleton navigation failed: ${message}`, + ); + } + } + selectionStateFromJson(state: this["selectionState"], json: any) { super.selectionStateFromJson(state, json); let parsedValue = state.value; diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index b0d2a30a7d..68c093e1e2 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -367,12 +367,6 @@ export class SpatialSkeletonEditTab extends Tab { this, ); - // Guard used by all window-level action listeners to ensure only the - // selected layer's tab handles global actions when multiple skeleton - // layers are loaded simultaneously. - const isThisLayerSelected = () => - layer.managedLayer === layer.manager.root.selectedLayer.layer; - let allNodes: SpatiallyIndexedSkeletonNode[] = []; let activeSegmentId: number | undefined; let nodesBySegment = new Map(); @@ -1854,177 +1848,6 @@ export class SpatialSkeletonEditTab extends Tab { updateDisplay(); }), ); - // --- Keyboard action listeners --- - - // Navigation + undo/redo: registered on window so they fire from both the - // sidebar and the viewer's slice/perspective panels. The isThisLayerSelected - // guard ensures only the selected layer's tab handles the event when multiple - // skeleton layers are loaded simultaneously. - this.registerDisposer( - registerActionListener(window, SKELETON_GO_ROOT, () => { - if (!isThisLayerSelected()) return; - const segmentId = getSelectedNavigationContext(false)?.segmentId; - if (segmentId === undefined) return; - void (async () => { - try { - navigateToNodeTarget( - await skeletonNavigationApi.getSkeletonRootNode(segmentId), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to locate skeleton root: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_GO_BRANCH_START, () => { - if (!isThisLayerSelected()) return; - const selectedNode = getSelectedNavigationContext(); - if (selectedNode === undefined) return; - void (async () => { - try { - navigateToNodeTarget( - await skeletonNavigationApi.getBranchStart(selectedNode.nodeId), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to locate branch start: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_GO_BRANCH_END, () => { - if (!isThisLayerSelected()) return; - const selectedNode = getSelectedNavigationContext(); - if (selectedNode === undefined) return; - void (async () => { - try { - navigateToNodeTarget( - await skeletonNavigationApi.getBranchEnd(selectedNode.nodeId), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to locate branch end: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_GO_PARENT, () => { - if (!isThisLayerSelected()) return; - const selectedNode = getSelectedNavigationContext(); - if (selectedNode === undefined) return; - void (async () => { - try { - const target = await skeletonNavigationApi.getParentNode( - selectedNode.nodeId, - ); - if (target === undefined) { - StatusMessage.showTemporaryMessage( - "Selected node has no parent.", - ); - return; - } - navigateToNodeTarget(target); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to locate parent node: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_GO_CHILD, () => { - if (!isThisLayerSelected()) return; - const selectedNode = getSelectedNavigationContext(); - if (selectedNode === undefined) return; - void (async () => { - try { - const target = await skeletonNavigationApi.getChildNode( - selectedNode.nodeId, - ); - if (target === undefined) { - StatusMessage.showTemporaryMessage("Selected node has no child."); - return; - } - navigateToNodeTarget(target); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to locate child node: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_CYCLE_BRANCHES, () => { - if (!isThisLayerSelected()) return; - const selectedNode = getSelectedNavigationContext(); - if (selectedNode === undefined) return; - void (async () => { - try { - navigateToNodeTarget( - await skeletonNavigationApi.getNextCollapsedLevelNode( - selectedNode.nodeId, - ), - ); - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - StatusMessage.showTemporaryMessage( - `Failed to cycle through level nodes: ${message}`, - ); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_GO_UNFINISHED, () => { - if (!isThisLayerSelected()) return; - goToClosestUnfinishedBranch(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_UNDO, () => { - if (!isThisLayerSelected()) return; - void (async () => { - try { - await undoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("undo", error); - } - })(); - }), - ); - this.registerDisposer( - registerActionListener(window, SKELETON_REDO, () => { - if (!isThisLayerSelected()) return; - void (async () => { - try { - await redoSpatialSkeletonCommand(layer); - } catch (error) { - showSpatialSkeletonActionError("redo", error); - } - })(); - }), - ); - // List-level: node mutations this.registerDisposer( registerActionListener( diff --git a/src/viewer.ts b/src/viewer.ts index 2c740bb3d5..0a8a2e283a 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -72,6 +72,17 @@ import { import { overlaysOpen } from "#src/overlay.js"; import { ScreenshotHandler } from "#src/python_integration/screenshots.js"; import { allRenderLayerRoles, RenderLayerRole } from "#src/renderlayer.js"; +import { + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_BRANCH_END, + SKELETON_GO_BRANCH_START, + SKELETON_GO_CHILD, + SKELETON_GO_PARENT, + SKELETON_GO_ROOT, + SKELETON_GO_UNFINISHED, + SKELETON_REDO, + SKELETON_UNDO, +} from "#src/skeleton/actions.js"; import { StatusMessage } from "#src/status.js"; import { ElementVisibilityFromTrackableBoolean, @@ -1073,6 +1084,22 @@ export class Viewer extends RefCounted implements ViewerState { }); } + for (const action of [ + SKELETON_GO_ROOT, + SKELETON_GO_PARENT, + SKELETON_GO_CHILD, + SKELETON_GO_BRANCH_START, + SKELETON_GO_BRANCH_END, + SKELETON_CYCLE_BRANCHES, + SKELETON_GO_UNFINISHED, + SKELETON_UNDO, + SKELETON_REDO, + ]) { + this.bindAction(action, () => { + this.layerManager.invokeAction(action); + }); + } + for (const action of ["select", "star"]) { this.bindAction(action, () => { this.mouseState.updateUnconditionally(); From fc5bae83c50925aab27c3ea1a41610edbe0d93bc Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 29 Jun 2026 20:06:58 +0200 Subject: [PATCH 10/11] fix: set skeleton state watchable (cherry picked from commit 8a02a19bd95d7826169d2a53301f172fa5f9449b) --- src/ui/skeleton_edit_tools.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index e2dae16b06..b996f11eb6 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -696,6 +696,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private enterSplit() { this.currentMode = SkeletonEditMode.Split; + this.layer.spatialSkeletonSplitMode.value = true; this.updateModeAttribute(); this.renderStatus(); } @@ -703,6 +704,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private exitSplit() { if (this.currentMode !== SkeletonEditMode.Split) return; this.currentMode = SkeletonEditMode.Default; + this.layer.spatialSkeletonSplitMode.value = false; this.updateModeAttribute(); this.clearStatus(); } @@ -1313,6 +1315,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.statusBody = undefined; this.setModeAttribute(undefined); layer.spatialSkeletonMergeMode.value = false; + layer.spatialSkeletonSplitMode.value = false; layer.spatialSkeletonState.clearPendingNodePositions(); }); From ac6883d86a40027aeda1e35df7bfbe7806713d5b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 12 Aug 2026 17:11:50 +0200 Subject: [PATCH 11/11] revert: remove mac bind based code --- src/help/input_event_bindings.ts | 13 ++----------- src/ui/command_palette.ts | 5 ----- src/ui/skeleton_tab.ts | 24 +++++++++--------------- src/util/event_action_map.ts | 13 +------------ src/util/platform.ts | 22 ---------------------- 5 files changed, 12 insertions(+), 65 deletions(-) delete mode 100644 src/util/platform.ts diff --git a/src/help/input_event_bindings.ts b/src/help/input_event_bindings.ts index 97c1c0581b..ff5ec6b1b2 100644 --- a/src/help/input_event_bindings.ts +++ b/src/help/input_event_bindings.ts @@ -32,7 +32,6 @@ import { type EventActionMap, } from "#src/util/event_action_map.js"; import { emptyToUndefined } from "#src/util/json.js"; -import { isMacPlatform } from "#src/util/platform.js"; declare let NEUROGLANCER_BUILD_INFO: | { tag: string; url?: string; timestamp?: string } @@ -52,16 +51,8 @@ export function formatKeyName(name: string) { } export function formatKeyStroke(stroke: string) { - const mac = isMacPlatform(); - return stroke - .split("+") - .map((part) => { - if (mac && part === "control") return "⌘"; - if (mac && part === "alt") return "⌥"; - if (mac && part === "shift") return "⇧"; - return formatKeyName(part); - }) - .join("+"); + const parts = stroke.split("+"); + return parts.map(formatKeyName).join("+"); } const DEFAULT_HELP_PANEL_LOCATION: SidePanelLocation = { diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts index 14113c1c1a..f928cc45ae 100644 --- a/src/ui/command_palette.ts +++ b/src/ui/command_palette.ts @@ -25,7 +25,6 @@ import type { NormalizedEventIdentifier, } from "#src/util/event_action_map.js"; import { friendlyEventIdentifier } from "#src/util/event_action_map.js"; -import { isMacPlatform } from "#src/util/platform.js"; import type { Viewer } from "#src/viewer.js"; const SUPPLEMENTAL_COMMANDS: readonly { @@ -50,13 +49,9 @@ export interface CommandPaletteEntry { } function formatKeyStroke(stroke: string): string { - const mac = isMacPlatform(); return stroke .split("+") .map((part) => { - if (mac && part === "control") return "⌘"; - if (mac && part === "alt") return "⌥"; - if (mac && part === "shift") return "⇧"; if (part.startsWith("key")) return part.substring(3); if (part.startsWith("digit")) return part.substring(5); if (part.startsWith("arrow")) return part.substring(5); diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 68c093e1e2..b63c1c6a7c 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -99,7 +99,6 @@ import { KeyboardEventBinder, } from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; -import { isMacPlatform } from "#src/util/platform.js"; import { formatScaleWithUnitAsString } from "#src/util/si_units.js"; import { Signal } from "#src/util/signal.js"; import { EnumSelectWidget } from "#src/widget/enum_widget.js"; @@ -190,18 +189,17 @@ export class SpatialSkeletonEditTab extends Tab { toolbarActions.className = "neuroglancer-skeleton-toolbar-actions"; const formatKeyHint = (stroke: string): string => { - const mac = isMacPlatform(); const parts = stroke.split("+").map((part) => { - if (part === "control") return mac ? "⌘" : "Ctrl"; - if (part === "shift") return mac ? "⇧" : "Shift"; - if (part === "alt") return mac ? "⌥" : "Alt"; + if (part === "control") return "Ctrl"; + if (part === "shift") return "Shift"; + if (part === "alt") return "Alt"; if (part.startsWith("key")) return part.slice(3).toUpperCase(); if (part.startsWith("digit")) return part.slice(5); if (part === "bracketleft") return "["; if (part === "bracketright") return "]"; return part.charAt(0).toUpperCase() + part.slice(1); }); - return parts.join(mac ? "" : "+"); + return parts.join("+"); }; const tabBindings = getDefaultSkeletonTabBindings(); @@ -548,7 +546,7 @@ export class SpatialSkeletonEditTab extends Tab { ) => { const id = BigInt(segmentId); const hasSegmentSelectionModifiers = (event: MouseEvent) => - (isMacPlatform() ? event.metaKey : event.ctrlKey) && !event.altKey; + event.ctrlKey && !event.altKey; element.addEventListener("mousedown", (event: MouseEvent) => { if (event.button !== 2 || !hasSegmentSelectionModifiers(event)) { return; @@ -567,14 +565,10 @@ export class SpatialSkeletonEditTab extends Tab { }); }; - const getSegmentSelectionTitle = (segmentId: number) => { - const modKey = isMacPlatform() ? "Cmd" : "Ctrl"; - return ( - `segment ${segmentId}\n` + - `${modKey}+right-click to pin selection\n` + - `${modKey}+shift+right-click to unpin` - ); - }; + const getSegmentSelectionTitle = (segmentId: number) => + `segment ${segmentId}\n` + + `Ctrl+right-click to pin selection\n` + + `Ctrl+shift+right-click to unpin`; const getNodeDescriptionText = (node: SpatiallyIndexedSkeletonNode) => layer.getSpatialSkeletonNodeDisplayDescription(node); diff --git a/src/util/event_action_map.ts b/src/util/event_action_map.ts index 99c1c96e4e..912c7074d9 100644 --- a/src/util/event_action_map.ts +++ b/src/util/event_action_map.ts @@ -17,7 +17,6 @@ import { registerEventListener } from "#src/util/disposable.js"; import type { HierarchicalMapInterface } from "#src/util/hierarchical_map.js"; import { HierarchicalMap } from "#src/util/hierarchical_map.js"; -import { isMacPlatform } from "#src/util/platform.js"; /** * @file Facilities for dispatching user-defined actions in response to input events. @@ -464,18 +463,8 @@ export function dispatchEventWithModifiers( detail: any, eventMap: EventActionMapInterface, ) { - let modifiers = getEventModifierMask(originalEvent); - // On Mac, treat Cmd (meta) as Ctrl for shortcut matching so that - // "control+key" bindings fire when the user presses Cmd+key. - if ( - isMacPlatform() && - modifiers & Modifiers.META && - !(modifiers & Modifiers.CONTROL) - ) { - modifiers = (modifiers & ~Modifiers.META) | Modifiers.CONTROL; - } dispatchEvent( - getStrokeIdentifier(baseIdentifier, modifiers), + getStrokeIdentifier(baseIdentifier, getEventModifierMask(originalEvent)), originalEvent, originalEvent.eventPhase, detail, diff --git a/src/util/platform.ts b/src/util/platform.ts deleted file mode 100644 index b2c60fa930..0000000000 --- a/src/util/platform.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright 2026 Google Inc. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export function isMacPlatform(): boolean { - if (typeof navigator === "undefined") return false; - return /Mac|iPhone|iPad/.test( - (navigator as any).userAgentData?.platform ?? navigator.platform ?? "", - ); -}