From ea6a2715b0a7e63d3c2b980c3a6e7005ed9ed281 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 5 Jun 2026 18:34:37 +0200 Subject: [PATCH 01/17] feat: block splitting at root from FE BE already rejects these, so this is just shortcutting that --- src/datasource/catmaid/spatial_skeleton_commands.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index e19315f06a..57b83e1a5e 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -1852,7 +1852,10 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableSegmentId, ); if (resolvedNode.node.parentNodeId === undefined) { - throw new Error("Cannot split at the root node."); + StatusMessage.showTemporaryMessage( + "Cannot split at the root node.", + ); + return; } let result: CatmaidSpatialSkeletonSplitResult; try { From a8c225494effb607132c58cbd5c529dca4bc0cd3 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 5 Jun 2026 19:31:39 +0200 Subject: [PATCH 02/17] fix: check in UI before commit if description changed --- .../catmaid/spatial_skeleton_commands.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/datasource/catmaid/spatial_skeleton_commands.ts b/src/datasource/catmaid/spatial_skeleton_commands.ts index 57b83e1a5e..a67d4e6850 100644 --- a/src/datasource/catmaid/spatial_skeleton_commands.ts +++ b/src/datasource/catmaid/spatial_skeleton_commands.ts @@ -446,6 +446,15 @@ function requireCatmaidMergeCommandPayload(payload: object) { ); } +function validateCatmaidNodeDescription(description: string | undefined) { + if (description === undefined) return; + for (const line of description.split(/\r?\n/)) { + if (line.trim().includes(",")) { + throw new Error("Node descriptions containing commas are not supported."); + } + } +} + function cloneNodeSnapshot( node: SpatiallyIndexedSkeletonNode, ): SpatiallyIndexedSkeletonNode { @@ -1515,6 +1524,7 @@ class NodeDescriptionCommand implements SpatialSkeletonCommand { nextDescription: string | undefined, statusPrefix: string, ) { + validateCatmaidNodeDescription(nextDescription); const { node } = await getResolvedNodeForEdit( this.layer, this.stableNodeId, @@ -1852,9 +1862,7 @@ class SplitCommand implements SpatialSkeletonCommand { this.stableSegmentId, ); if (resolvedNode.node.parentNodeId === undefined) { - StatusMessage.showTemporaryMessage( - "Cannot split at the root node.", - ); + StatusMessage.showTemporaryMessage("Cannot split at the root node."); return; } let result: CatmaidSpatialSkeletonSplitResult; From 4eb832153abc1d3ba4aaa34bcad7e6062e5d9c6b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 30 Jun 2026 15:39:22 +0200 Subject: [PATCH 03/17] feat: add custom cursors for tracing and override base crosshair --- src/rendered_data_panel.css | 4 +++- src/ui/images/metacell/add_node_cursor.svg | 17 +++++++++++++ src/ui/images/metacell/crosshair_cursor.svg | 8 +++++++ src/ui/images/metacell/drag_cursor.svg | 18 ++++++++++++++ src/ui/images/metacell/merge_cursor.svg | 9 +++++++ .../images/metacell/new_skeleton_cursor.svg | 9 +++++++ src/ui/images/metacell/split_cursor.svg | 16 +++++++++++++ src/ui/skeleton_edit_tools.css | 24 +++++++------------ src/ui/skeleton_edit_tools.ts | 24 +++++++++++++++---- 9 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 src/ui/images/metacell/add_node_cursor.svg create mode 100644 src/ui/images/metacell/crosshair_cursor.svg create mode 100644 src/ui/images/metacell/drag_cursor.svg create mode 100644 src/ui/images/metacell/merge_cursor.svg create mode 100644 src/ui/images/metacell/new_skeleton_cursor.svg create mode 100644 src/ui/images/metacell/split_cursor.svg diff --git a/src/rendered_data_panel.css b/src/rendered_data_panel.css index 626df55c14..73afdeb48f 100644 --- a/src/rendered_data_panel.css +++ b/src/rendered_data_panel.css @@ -15,7 +15,9 @@ */ .neuroglancer-rendered-data-panel { - cursor: crosshair; + cursor: + url("./ui/images/metacell/crosshair_cursor.svg") 20 20, + crosshair; position: relative; outline: 0; touch-action: none; diff --git a/src/ui/images/metacell/add_node_cursor.svg b/src/ui/images/metacell/add_node_cursor.svg new file mode 100644 index 0000000000..25b3478654 --- /dev/null +++ b/src/ui/images/metacell/add_node_cursor.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ui/images/metacell/crosshair_cursor.svg b/src/ui/images/metacell/crosshair_cursor.svg new file mode 100644 index 0000000000..4487b316fa --- /dev/null +++ b/src/ui/images/metacell/crosshair_cursor.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/ui/images/metacell/drag_cursor.svg b/src/ui/images/metacell/drag_cursor.svg new file mode 100644 index 0000000000..4859a0320f --- /dev/null +++ b/src/ui/images/metacell/drag_cursor.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/ui/images/metacell/merge_cursor.svg b/src/ui/images/metacell/merge_cursor.svg new file mode 100644 index 0000000000..9f300ebd9f --- /dev/null +++ b/src/ui/images/metacell/merge_cursor.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/ui/images/metacell/new_skeleton_cursor.svg b/src/ui/images/metacell/new_skeleton_cursor.svg new file mode 100644 index 0000000000..3de4a29ea3 --- /dev/null +++ b/src/ui/images/metacell/new_skeleton_cursor.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/ui/images/metacell/split_cursor.svg b/src/ui/images/metacell/split_cursor.svg new file mode 100644 index 0000000000..c0126e03bf --- /dev/null +++ b/src/ui/images/metacell/split_cursor.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index b9e362d08e..96c85fd769 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -48,37 +48,27 @@ /* 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, + url("./images/metacell/add_node_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { cursor: - url("data:image/svg+xml,M") - 16 16, + url("./images/metacell/merge_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { cursor: - url("data:image/svg+xml,N") - 16 16, + url("./images/metacell/new_skeleton_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { cursor: - url("data:image/svg+xml,S") - 16 16, + url("./images/metacell/split_cursor.svg") 20 20, crosshair; } @@ -88,9 +78,11 @@ } .neuroglancer-rendered-data-panel[data-skeleton-press-mode="pan"] { - cursor: grab; + cursor: move; } .neuroglancer-rendered-data-panel[data-skeleton-press-mode="move"] { - cursor: grabbing; + cursor: + url("./images/metacell/drag_cursor.svg") 20 20, + grabbing; } diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index b996f11eb6..0981a43735 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -1416,13 +1416,27 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // 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)) { + if (panel instanceof PerspectivePanel) { + panel.element.dataset.skeletonPressMode = "rotate"; + const onMouseUp = () => { + delete panel.element.dataset.skeletonPressMode; + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mouseup", onMouseUp); + } else { event.stopPropagation(); event.preventDefault(); - startRelativeMouseDrag(event, (_dragEvent, deltaX, deltaY) => { - panel.context.flagContinuousCameraMotion(); - panel.translateByViewportPixels(deltaX, deltaY); - }); + panel.element.dataset.skeletonPressMode = "pan"; + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }, + () => { + delete panel.element.dataset.skeletonPressMode; + }, + ); } return; } From 5154c74e85f4556938edfb42eb926cb5ea715342 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 30 Jun 2026 16:01:25 +0200 Subject: [PATCH 04/17] feat: update cursors --- src/ui/images/metacell/add_node_cursor.svg | 22 +++++++++---------- src/ui/images/metacell/crosshair_cursor.svg | 10 ++++----- src/ui/images/metacell/merge_cursor.svg | 14 ++++++------ .../images/metacell/new_skeleton_cursor.svg | 14 ++++++------ src/ui/images/metacell/split_cursor.svg | 20 ++++++++--------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/ui/images/metacell/add_node_cursor.svg b/src/ui/images/metacell/add_node_cursor.svg index 25b3478654..ef99492d87 100644 --- a/src/ui/images/metacell/add_node_cursor.svg +++ b/src/ui/images/metacell/add_node_cursor.svg @@ -1,17 +1,17 @@ - - - - + + + + + - - - - - + + + + - - + + diff --git a/src/ui/images/metacell/crosshair_cursor.svg b/src/ui/images/metacell/crosshair_cursor.svg index 4487b316fa..6a912ec41a 100644 --- a/src/ui/images/metacell/crosshair_cursor.svg +++ b/src/ui/images/metacell/crosshair_cursor.svg @@ -1,8 +1,8 @@ - - - + + + - - + + diff --git a/src/ui/images/metacell/merge_cursor.svg b/src/ui/images/metacell/merge_cursor.svg index 9f300ebd9f..a17cbf72d4 100644 --- a/src/ui/images/metacell/merge_cursor.svg +++ b/src/ui/images/metacell/merge_cursor.svg @@ -1,9 +1,9 @@ - - - - + + + + - - - + + + diff --git a/src/ui/images/metacell/new_skeleton_cursor.svg b/src/ui/images/metacell/new_skeleton_cursor.svg index 3de4a29ea3..6c9f5daa3f 100644 --- a/src/ui/images/metacell/new_skeleton_cursor.svg +++ b/src/ui/images/metacell/new_skeleton_cursor.svg @@ -1,9 +1,9 @@ - - - - + + + + - - - + + + diff --git a/src/ui/images/metacell/split_cursor.svg b/src/ui/images/metacell/split_cursor.svg index c0126e03bf..eee01d075b 100644 --- a/src/ui/images/metacell/split_cursor.svg +++ b/src/ui/images/metacell/split_cursor.svg @@ -1,16 +1,16 @@ - - - - + + + + - - - - + + + + - - + + From c795125f865479fba5c5fad9f46a46624fa7073d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 30 Jun 2026 17:32:57 +0200 Subject: [PATCH 05/17] fix: correct crosshair --- src/ui/images/metacell/crosshair_cursor.svg | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ui/images/metacell/crosshair_cursor.svg b/src/ui/images/metacell/crosshair_cursor.svg index 6a912ec41a..160b0e5f68 100644 --- a/src/ui/images/metacell/crosshair_cursor.svg +++ b/src/ui/images/metacell/crosshair_cursor.svg @@ -1,8 +1,8 @@ - - - - + + + + - - + + From 899c13d867e9b3be9d4deb7a5037563c427ddeac Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 1 Jul 2026 14:11:46 +0200 Subject: [PATCH 06/17] feat: better tool status --- src/ui/skeleton_edit_tool_messages.spec.ts | 189 ++++++++++++++++--- src/ui/skeleton_edit_tool_messages.ts | 176 +++++++++++++++--- src/ui/skeleton_edit_tools.css | 54 +++--- src/ui/skeleton_edit_tools.ts | 204 +++++++++++---------- 4 files changed, 451 insertions(+), 172 deletions(-) diff --git a/src/ui/skeleton_edit_tool_messages.spec.ts b/src/ui/skeleton_edit_tool_messages.spec.ts index c66c808acb..000f21f34f 100644 --- a/src/ui/skeleton_edit_tool_messages.spec.ts +++ b/src/ui/skeleton_edit_tool_messages.spec.ts @@ -1,16 +1,18 @@ import { describe, expect, it } from "vitest"; import { - SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, - SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, - SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE, + SPATIAL_SKELETON_ROTATE_PAN_HINT, formatSpatialSkeletonToolPoint, + getSpatialSkeletonCreateIdleStatusText, + getSpatialSkeletonCreatingStatusText, + getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonMergeStatusText, + getSpatialSkeletonMergingStatusText, + getSpatialSkeletonMovingStatusText, + getSpatialSkeletonSplitIdleStatusText, + getSpatialSkeletonSplittingStatusText, getSpatialSkeletonToolPointSummaryRow, getSpatialSkeletonToolPointStatusFields, - getSpatialSkeletonEditBannerMessage, - getSpatialSkeletonMergeBannerMessage, } from "#src/ui/skeleton_edit_tool_messages.js"; describe("spatial_skeleton_tool_messages", () => { @@ -48,27 +50,164 @@ describe("spatial_skeleton_tool_messages", () => { }); }); - it("switches edit banner copy when a node is selected", () => { - expect(getSpatialSkeletonEditBannerMessage(undefined)).toBe( - SPATIAL_SKELETON_EDIT_BANNER_MESSAGE, - ); - expect( - getSpatialSkeletonEditBannerMessage({ nodeId: 8, segmentId: 12 }), - ).toBe(SPATIAL_SKELETON_EDIT_SELECTED_BANNER_MESSAGE); + describe("getSpatialSkeletonDefaultStatusText", () => { + it("no selection", () => { + expect(getSpatialSkeletonDefaultStatusText("none", false)).toEqual({ + status: "No selection", + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, visible skeleton", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-visible", false), + ).toEqual({ + status: "Node selected", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, visible skeleton, shift held", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-visible", true), + ).toEqual({ + status: "Ready to place new node", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, non-visible skeleton", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-hidden", false), + ).toEqual({ + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("selected, non-visible skeleton, shift held — unaffected by shift", () => { + expect( + getSpatialSkeletonDefaultStatusText("selected-hidden", true), + ).toEqual({ + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); }); - it("switches merge banner copy after the first point is selected", () => { - expect(getSpatialSkeletonMergeBannerMessage(undefined)).toBe( - SPATIAL_SKELETON_MERGE_BANNER_MESSAGE, - ); - expect( - getSpatialSkeletonMergeBannerMessage({ nodeId: 8, segmentId: 12 }), - ).toBe(SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE); + it("returns a static moving-node status", () => { + expect(getSpatialSkeletonMovingStatusText()).toEqual({ + status: "Moving node", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); }); - it("keeps the split banner copy stable", () => { - expect(SPATIAL_SKELETON_SPLIT_BANNER_MESSAGE).toBe( - "Select 1 node to split", - ); + describe("getSpatialSkeletonMergeStatusText", () => { + it("no from node, key held", () => { + expect(getSpatialSkeletonMergeStatusText("no-from-node", true)).toEqual( + { + status: "Merge · no selected nodes", + actions: `Click a node to set as from node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }, + ); + }); + + it("no from node, key not held", () => { + expect(getSpatialSkeletonMergeStatusText("no-from-node", false)).toEqual( + { + status: "Merge · no selected nodes", + actions: `Click a node to set as from node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }, + ); + }); + + it("from node selected on a visible skeleton, key held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-visible", true), + ).toEqual({ + status: "Merge · from node selected", + actions: `Click a 2nd node on a different skeleton to merge · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node selected on a visible skeleton, key not held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-visible", false), + ).toEqual({ + status: "Merge · from node selected", + actions: `Click a 2nd node on a different skeleton to merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node on a non-visible skeleton, key held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-hidden", true), + ).toEqual({ + status: "Merge · from node on non-visible skeleton", + actions: `Double-click skeleton to show it · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("from node on a non-visible skeleton, key not held", () => { + expect( + getSpatialSkeletonMergeStatusText("from-node-hidden", false), + ).toEqual({ + status: "Merge · from node on non-visible skeleton", + actions: `Double-click skeleton to show it · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static merging status", () => { + expect(getSpatialSkeletonMergingStatusText()).toEqual({ + status: "Merge · merging nodes…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + + describe("getSpatialSkeletonSplitIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonSplitIdleStatusText(true)).toEqual({ + status: "Split · no selected nodes", + actions: `Click a node to split · release s to exit split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonSplitIdleStatusText(false)).toEqual({ + status: "Split · no selected nodes", + actions: `Click a node to split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static splitting status", () => { + expect(getSpatialSkeletonSplittingStatusText()).toEqual({ + status: "Split · splitting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + + describe("getSpatialSkeletonCreateIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonCreateIdleStatusText(true)).toEqual({ + status: "Create · ready to place", + actions: `Click to place a new skeleton · release n to exit create · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonCreateIdleStatusText(false)).toEqual({ + status: "Create · ready to place", + actions: `Click to place a new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static creating status", () => { + expect(getSpatialSkeletonCreatingStatusText()).toEqual({ + status: "Create · creating skeleton…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); }); }); diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index 675d11e72b..9c1db306bc 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -35,25 +35,6 @@ export interface SpatialSkeletonToolStatusField { value: string; } -export const SPATIAL_SKELETON_EDIT_BANNER_MESSAGE = - "Move nodes, select a node to append or click to start a new skeleton"; -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 · 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 · 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 · 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, ) { @@ -105,18 +86,153 @@ export function getSpatialSkeletonToolPointStatusFields( return fields; } -export function getSpatialSkeletonEditBannerMessage( - selectedPoint: SpatialSkeletonToolPointInfo | undefined, -) { - return selectedPoint === undefined - ? SPATIAL_SKELETON_DEFAULT_BANNER_MESSAGE - : SPATIAL_SKELETON_DEFAULT_SELECTED_BANNER_MESSAGE; +// --- Name / status / actions message system --- +// +// The tool's status bar is split into three parts: a name (rendered by the +// caller via a fixed header, see SPATIAL_SKELETON_EDIT_TOOL_NAME), a short +// `status` describing what's currently true, and a short `actions` list +// describing what's currently doable. Keeping these separate (rather than +// one long banner string) avoids mixing state with instructions, and lets +// the no-selection default state stop advertising actions that don't apply +// yet (e.g. shift+click, which requires an existing selection). +// +// User-facing copy says "from node" rather than "merge anchor" — the +// internal name (mergeAnchorNodeId, etc.) is unaffected. + +export interface SpatialSkeletonToolStatusText { + status: string; + actions: string; +} + +export const SPATIAL_SKELETON_EDIT_TOOL_NAME = "Skeleton editing"; +export const SPATIAL_SKELETON_ROTATE_PAN_HINT = "middle-click to rotate/pan"; + +export type SpatialSkeletonDefaultSelectionState = + | "none" + | "selected-visible" + | "selected-hidden"; + +export function getSpatialSkeletonDefaultStatusText( + state: SpatialSkeletonDefaultSelectionState, + shiftHeld: boolean, +): SpatialSkeletonToolStatusText { + switch (state) { + case "none": + return { + status: "No selection", + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + case "selected-visible": + return { + status: shiftHeld ? "Ready to place new node" : "Node selected", + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + case "selected-hidden": + return { + status: "Node selected from non-visible skeleton", + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }; + } +} + +export function getSpatialSkeletonMovingStatusText(): SpatialSkeletonToolStatusText { + return { status: "Moving node", actions: SPATIAL_SKELETON_ROTATE_PAN_HINT }; } -export function getSpatialSkeletonMergeBannerMessage( - selectedPoint: SpatialSkeletonToolPointInfo | undefined, +export type SpatialSkeletonMergeState = + | "no-from-node" + | "from-node-visible" + | "from-node-hidden"; + +function withExitHint( + action: string, + canExitWithKey: boolean, + exitHint: string, ) { - return selectedPoint === undefined - ? SPATIAL_SKELETON_MERGE_BANNER_MESSAGE - : SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE; + return canExitWithKey + ? `${action} · ${exitHint} · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}` + : `${action} · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`; +} + +export function getSpatialSkeletonMergeStatusText( + state: SpatialSkeletonMergeState, + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + const exitHint = "release m to exit merge"; + switch (state) { + case "no-from-node": + return { + status: "Merge · no selected nodes", + actions: withExitHint( + "Click a node to set as from node", + canExitWithKey, + exitHint, + ), + }; + case "from-node-visible": + return { + status: "Merge · from node selected", + actions: withExitHint( + "Click a 2nd node on a different skeleton to merge", + canExitWithKey, + exitHint, + ), + }; + case "from-node-hidden": + return { + status: "Merge · from node on non-visible skeleton", + actions: withExitHint( + "Double-click skeleton to show it", + canExitWithKey, + exitHint, + ), + }; + } +} + +export function getSpatialSkeletonMergingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Merge · merging nodes…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + +export function getSpatialSkeletonSplitIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Split · no selected nodes", + actions: withExitHint( + "Click a node to split", + canExitWithKey, + "release s to exit split", + ), + }; +} + +export function getSpatialSkeletonSplittingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Split · splitting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + +export function getSpatialSkeletonCreateIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Create · ready to place", + actions: withExitHint( + "Click to place a new skeleton", + canExitWithKey, + "release n to exit create", + ), + }; +} + +export function getSpatialSkeletonCreatingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Create · creating skeleton…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; } diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index 96c85fd769..5d61df16d3 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -16,34 +16,46 @@ .neuroglancer-skeleton-tool-status { display: flex; - flex-wrap: wrap; + flex: 1; align-items: center; - gap: 0.4rem; + gap: 0.75rem; + min-width: 0; } -.neuroglancer-skeleton-tool-status-message { - display: inline-flex; - align-items: center; +/* Sits right after the divider, immediately beside the tool name in the + header, sized to its own content (not flex-grow) rather than stretching + across the bar. */ +.neuroglancer-skeleton-tool-status-divider { + flex: 0 0 auto; + color: #6b6c6f; + line-height: 1.25rem; } -.neuroglancer-skeleton-tool-status-point { - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 0.55rem; - color: #e6cb57; -} - -.neuroglancer-skeleton-tool-status-point-field { - display: inline-flex; - align-items: center; - gap: 0.2rem; - font-weight: 600; +.neuroglancer-skeleton-tool-status-text { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25rem; } -.neuroglancer-skeleton-tool-status-point-field-label, -.neuroglancer-skeleton-tool-status-point-field-value { - color: inherit; +/* margin-left: auto consumes all remaining space on the main axis before + justify-content is applied, so the actions list is pushed to the far + right regardless of the status text's width or the parent's + justify-content (neuroglass-theme.css sets justify-content: flex-end on + this same element via a higher-specificity nested rule). */ +.neuroglancer-skeleton-tool-status-actions { + flex: 0 1 auto; + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #999ca0; + font-size: 0.8125rem; + line-height: 1.25rem; + text-align: right; } /* Per-mode cursor indicators — driven by data-skeleton-edit-mode on the panel element */ diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 0981a43735..1c0bb73b5e 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -64,14 +64,17 @@ import { getDefaultSkeletonEditNodeBindings, getDefaultSkeletonEditToolBindings, } from "#src/ui/default_input_event_bindings.js"; -import type { SpatialSkeletonToolPointInfo } from "#src/ui/skeleton_edit_tool_messages.js"; +import type { SpatialSkeletonToolStatusText } 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, - getSpatialSkeletonToolPointStatusFields, + SPATIAL_SKELETON_EDIT_TOOL_NAME, + getSpatialSkeletonCreateIdleStatusText, + getSpatialSkeletonCreatingStatusText, + getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonMergeStatusText, + getSpatialSkeletonMergingStatusText, + getSpatialSkeletonMovingStatusText, + getSpatialSkeletonSplitIdleStatusText, + getSpatialSkeletonSplittingStatusText, } from "#src/ui/skeleton_edit_tool_messages.js"; import type { ToolActivation } from "#src/ui/tool.js"; import { @@ -110,6 +113,16 @@ const enum SkeletonEditMode { const DRAG_START_DISTANCE_PX = 2; +// Physical key codes that exit the corresponding momentary mode on keyup +// (see the onKeyUp handler in activate()). Centralized here so the "which +// key exits which mode" association — inherently duplicated between the +// exit trigger and the status-bar hint that describes it — lives in exactly +// one place. Tool-scoped bindings like these aren't wired into the app's +// input-event-map rebinding system, so this can't be derived generically. +const MERGE_EXIT_KEY_CODE = "KeyM"; +const SPLIT_EXIT_KEY_CODE = "KeyS"; +const CREATE_EXIT_KEY_CODE = "KeyN"; + function waitForNextAnimationFrame() { return new Promise((resolve) => { if (typeof requestAnimationFrame !== "function") { @@ -122,36 +135,25 @@ function waitForNextAnimationFrame() { function renderSpatialSkeletonToolStatus( body: HTMLElement, - options: { - message: string; - point?: SpatialSkeletonToolPointInfo; - }, + text: SpatialSkeletonToolStatusText, ) { removeChildren(body); body.classList.add("neuroglancer-skeleton-tool-status"); - const message = document.createElement("span"); - message.className = "neuroglancer-skeleton-tool-status-message"; - message.textContent = options.message; - body.appendChild(message); - if (options.point === undefined) { + const dividerElement = document.createElement("span"); + dividerElement.className = "neuroglancer-skeleton-tool-status-divider"; + dividerElement.textContent = "—"; + body.appendChild(dividerElement); + const statusElement = document.createElement("span"); + statusElement.className = "neuroglancer-skeleton-tool-status-text"; + statusElement.textContent = text.status; + body.appendChild(statusElement); + if (text.actions.length === 0) { return; } - const point = document.createElement("span"); - point.className = "neuroglancer-skeleton-tool-status-point"; - for (const field of getSpatialSkeletonToolPointStatusFields(options.point)) { - const fieldElement = document.createElement("span"); - fieldElement.className = "neuroglancer-skeleton-tool-status-point-field"; - const label = document.createElement("span"); - label.className = "neuroglancer-skeleton-tool-status-point-field-label"; - label.textContent = field.label; - fieldElement.appendChild(label); - const value = document.createElement("span"); - value.className = "neuroglancer-skeleton-tool-status-point-field-value"; - value.textContent = field.value; - fieldElement.appendChild(value); - point.appendChild(fieldElement); - } - body.appendChild(point); + const actionsElement = document.createElement("span"); + actionsElement.className = "neuroglancer-skeleton-tool-status-actions"; + actionsElement.textContent = text.actions; + body.appendChild(actionsElement); } abstract class SpatialSkeletonToolBase extends LayerTool { @@ -515,8 +517,13 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private splitKeyHeld = false; // Modifier-held state drives cursor indicators and blocks node actions. private shiftHeld = false; - private statusOverride: string | undefined = undefined; - private statusPoint: SpatialSkeletonToolPointInfo | undefined = undefined; + // Physical key codes currently held down — used only to decide whether the + // status actions text should show a "release to exit" hint. Merge/ + // split/create can also be entered via a synthetic dispatched action (no + // physical keydown), in which case that hint would be misleading. + private heldPhysicalKeyCodes = new Set(); + private statusOverride: SpatialSkeletonToolStatusText | undefined = + undefined; // Set at activation start; cleared by the activation disposer to prevent // post-deactivation UI writes. private statusBody: HTMLElement | undefined = undefined; @@ -558,57 +565,54 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.statusBody === undefined) return; const body = this.statusBody; if (this.statusOverride !== undefined) { - renderSpatialSkeletonToolStatus(body, { - message: this.statusOverride, - point: this.statusPoint, - }); + renderSpatialSkeletonToolStatus(body, this.statusOverride); return; } if (this.currentMode === SkeletonEditMode.Merge) { const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; + const canExitWithKey = this.heldPhysicalKeyCodes.has( + MERGE_EXIT_KEY_CODE, + ); 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 ( + const isHidden = 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: SPATIAL_SKELETON_MERGE_SELECTED_BANNER_MESSAGE, - point, - }); - } + !this.isSpatialSkeletonSegmentVisible(cachedNode.segmentId); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonMergeStatusText( + isHidden ? "from-node-hidden" : "from-node-visible", + canExitWithKey, + ), + ); } else { - renderSpatialSkeletonToolStatus(body, { - message: "Click a node to set as merge anchor · release m to exit", - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonMergeStatusText("no-from-node", canExitWithKey), + ); } return; } if (this.currentMode === SkeletonEditMode.Split) { - renderSpatialSkeletonToolStatus(body, { - message: "Click a node to split · release s to exit", - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonSplitIdleStatusText( + this.heldPhysicalKeyCodes.has(SPLIT_EXIT_KEY_CODE), + ), + ); return; } if (this.currentMode === SkeletonEditMode.Create) { - renderSpatialSkeletonToolStatus(body, { - message: SPATIAL_SKELETON_CREATE_BANNER_MESSAGE, - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonCreateIdleStatusText( + this.heldPhysicalKeyCodes.has(CREATE_EXIT_KEY_CODE), + ), + ); return; } // Default mode @@ -616,25 +620,26 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const isHidden = selectedPoint?.segmentId !== undefined && !this.isSpatialSkeletonSegmentVisible(selectedPoint.segmentId); - renderSpatialSkeletonToolStatus(body, { - message: isHidden - ? SPATIAL_SKELETON_HIDDEN_SELECTED_BANNER_MESSAGE - : getSpatialSkeletonEditBannerMessage(selectedPoint), - point: selectedPoint, - }); + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonDefaultStatusText( + selectedPoint === undefined + ? "none" + : isHidden + ? "selected-hidden" + : "selected-visible", + this.shiftHeld, + ), + ); } - private setStatus( - message: string | undefined, - point?: SpatialSkeletonToolPointInfo, - ) { - this.statusOverride = message; - this.statusPoint = point; + private setStatus(text: SpatialSkeletonToolStatusText | undefined) { + this.statusOverride = text; this.renderStatus(); } private clearStatus() { - this.setStatus(undefined, undefined); + this.setStatus(undefined); } // --- Modifier tracking --- @@ -647,6 +652,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.shiftHeld === isShift) return; this.shiftHeld = isShift; this.updateModeAttribute(); + this.renderStatus(); } // --- Mode transitions --- @@ -788,7 +794,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.dragInProgress = true; skeletonLayer!.markSegmentEdited(nodeInfo!.segmentId); panel.element.dataset.skeletonPressMode = "move"; - this.setStatus(SPATIAL_SKELETON_MOVING_NODE_MESSAGE); + this.setStatus(getSpatialSkeletonMovingStatusText()); } panel.translateDataPointByViewportPixels( this.dragGlobalPosition, @@ -860,13 +866,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }) { 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); + this.setStatus(getSpatialSkeletonSplittingStatusText()); void (async () => { try { await executeSpatialSkeletonSplit(this.layer, { @@ -1000,7 +1001,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.pinSegmentByNumber(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); this.pending = true; - this.setStatus("Merging selected nodes."); + this.setStatus(getSpatialSkeletonMergingStatusText()); void (async () => { try { @@ -1059,7 +1060,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.createPlacedThisHold = true; this.pending = true; - this.setStatus("Creating new skeleton."); + this.setStatus(getSpatialSkeletonCreatingStatusText()); void (async () => { try { @@ -1281,13 +1282,13 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.mergeKeyHeld = false; this.splitKeyHeld = false; this.shiftHeld = false; + this.heldPhysicalKeyCodes = new Set(); this.statusOverride = undefined; - this.statusPoint = undefined; // 2. Create status UI. const { body, header } = makeToolActivationStatusMessageWithHeader(activation); - header.textContent = "Skeleton edit"; + header.textContent = SPATIAL_SKELETON_EDIT_TOOL_NAME; this.statusBody = body; // 3. Precondition checks. @@ -1297,14 +1298,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); if (disabledReason !== undefined) { StatusMessage.showTemporaryMessage(disabledReason); - renderSpatialSkeletonToolStatus(body, { message: disabledReason }); + renderSpatialSkeletonToolStatus(body, { + status: disabledReason, + actions: "", + }); 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 }); + renderSpatialSkeletonToolStatus(body, { status: msg, actions: "" }); queueMicrotask(() => activation.cancel()); return; } @@ -1366,14 +1370,21 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); // 9. Global key/mouse listeners — thin lambda wrappers delegating to class methods. - const onKeyDown = (event: KeyboardEvent) => this.syncModifiers(event); + const onKeyDown = (event: KeyboardEvent) => { + this.syncModifiers(event); + if (!this.heldPhysicalKeyCodes.has(event.code)) { + this.heldPhysicalKeyCodes.add(event.code); + this.renderStatus(); + } + }; const onKeyUp = (event: KeyboardEvent) => { - if (event.code === "KeyM") { + this.heldPhysicalKeyCodes.delete(event.code); + if (event.code === MERGE_EXIT_KEY_CODE) { this.mergeKeyHeld = false; this.exitMerge(); } - if (event.code === "KeyN") this.exitCreate(); - if (event.code === "KeyS") { + if (event.code === CREATE_EXIT_KEY_CODE) this.exitCreate(); + if (event.code === SPLIT_EXIT_KEY_CODE) { this.splitKeyHeld = false; this.exitSplit(); } @@ -1386,6 +1397,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.mergeKeyHeld = false; this.splitKeyHeld = false; this.shiftHeld = false; + this.heldPhysicalKeyCodes = new Set(); this.exitMerge(); this.exitCreate(); this.exitSplit(); From b8d325d167d72cbc86cf8e5a87d14075f9728c5d Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 3 Jul 2026 17:23:15 +0200 Subject: [PATCH 07/17] refactor: use rspack to preload cursor svgs --- rspack.config.ts | 10 +++ src/ui/default_input_event_bindings.ts | 7 ++ src/ui/skeleton_edit_tool_messages.ts | 6 +- src/ui/skeleton_edit_tools.ts | 100 +++++++++++++++++++------ 4 files changed, 101 insertions(+), 22 deletions(-) diff --git a/rspack.config.ts b/rspack.config.ts index e7c0c70e71..f3827fe549 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -53,6 +53,16 @@ export default defineConfig((env, args) => { resourceQuery: /raw/, type: "asset/source", }, + // Inline these specific cursor SVGs as base64 data URIs so the + // browser has nothing to fetch the first time a CSS cursor: url(...) + // rule referencing them is applied (see skeleton_edit_tools.css) — + // without this, rspack's default url-dependency handling always + // emits a separate fetched file regardless of size, which caused the + // custom cursor to only appear starting from the second activation. + { + test: /src[\\/]ui[\\/]images[\\/]metacell[\\/](add_node_cursor|merge_cursor|new_skeleton_cursor|split_cursor|drag_cursor)\.svg$/, + type: "asset/inline", + }, // Needed for .html assets used for auth redirect pages for the // brainmaps and bossDB data sources. { diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 066a2e200f..987834e1f7 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -246,6 +246,13 @@ export function getDefaultSkeletonEditToolBindings() { defaultSkeletonEditToolBindings = EventActionMap.fromObject({ "at:mousedown1": "rotate-via-mouse-drag", "at:control+mousedown1": "translate-via-mouse-drag", + // Trackpad-friendly aliases for the middle-mouse scheme above: on + // perspective panels these dispatch here; on slice panels they're + // intercepted directly in the capture-phase listener in + // skeleton_edit_tools.ts before they can bubble to this map (mirrors + // how mousedown1 is handled for slice panels). + "at:control+mousedown0": "rotate-via-mouse-drag", + "at:control+shift+mousedown0": "translate-via-mouse-drag", "at:shift+mousedown0": SKELETON_ADD_NODE, "at:keym": SKELETON_ENTER_MERGE_MODE, "at:keys": SKELETON_ENTER_SPLIT_MODE, diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index 9c1db306bc..d38b9d6df0 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { isMacPlatform } from "#src/util/platform.js"; + export interface SpatialSkeletonToolPointInfo { nodeId: number; segmentId?: number; @@ -105,7 +107,9 @@ export interface SpatialSkeletonToolStatusText { } export const SPATIAL_SKELETON_EDIT_TOOL_NAME = "Skeleton editing"; -export const SPATIAL_SKELETON_ROTATE_PAN_HINT = "middle-click to rotate/pan"; +export const SPATIAL_SKELETON_ROTATE_PAN_HINT = `middle-click or ${ + isMacPlatform() ? "cmd" : "ctrl" +}+click to rotate/pan`; export type SpatialSkeletonDefaultSelectionState = | "none" diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 1c0bb73b5e..56fafd6265 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -98,14 +98,19 @@ const enum SkeletonEditMode { Split = 3, } -// 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 +// In edit mode, plain left click is selection-only — it never rotates or +// pans. Navigation (rotate in perspective, pan in slice) is handled by +// middle mouse (mousedown1), plus trackpad-friendly aliases on the +// navigation modifier + left mouse (control+mousedown0 on most platforms, +// cmd+mousedown0 on Mac — see hasNavigationModifier below): the modifier +// alone mirrors plain middle-click, and modifier+shift mirrors +// control+middle-click. mousedown0 is therefore handled only via the // capture-phase DOM listeners in activate(); it is not in the EventActionMap. // -// 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 +// mousedown1 / control?+mousedown0 → rotate-via-mouse-drag covers perspective +// panels via the EventActionMap. Slice panels intercept middle mouse and the +// navigation-modifier chords in the capture listener and call +// translateByViewportPixels directly, consuming the event before // MouseEventBinder can dispatch this action. // // Default bindings are defined in getDefaultSkeletonEditToolBindings() / @@ -123,6 +128,11 @@ const MERGE_EXIT_KEY_CODE = "KeyM"; const SPLIT_EXIT_KEY_CODE = "KeyS"; const CREATE_EXIT_KEY_CODE = "KeyN"; +function hasNavigationModifier(event: { ctrlKey: boolean; metaKey: boolean }) { + // TODO replace by mac check + return event.metaKey || event.ctrlKey; +} + function waitForNextAnimationFrame() { return new Promise((resolve) => { if (typeof requestAnimationFrame !== "function") { @@ -517,13 +527,16 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private splitKeyHeld = false; // Modifier-held state drives cursor indicators and blocks node actions. private shiftHeld = false; + // Navigation modifier (ctrl, or cmd on Mac — see hasNavigationModifier). + // While held, the shift-driven "add node" cursor/status must be + // suppressed, since modifier+shift now means pan, not add-node. + private ctrlHeld = false; // Physical key codes currently held down — used only to decide whether the // status actions text should show a "release to exit" hint. Merge/ // split/create can also be entered via a synthetic dispatched action (no // physical keydown), in which case that hint would be misleading. private heldPhysicalKeyCodes = new Set(); - private statusOverride: SpatialSkeletonToolStatusText | undefined = - undefined; + private statusOverride: SpatialSkeletonToolStatusText | undefined = undefined; // Set at activation start; cleared by the activation disposer to prevent // post-deactivation UI writes. private statusBody: HTMLElement | undefined = undefined; @@ -552,7 +565,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute("create"); } else if (this.currentMode === SkeletonEditMode.Split) { this.setModeAttribute("split"); - } else if (this.shiftHeld) { + } else if (this.shiftHeld && !this.ctrlHeld) { this.setModeAttribute("add"); } else { this.setModeAttribute(undefined); @@ -571,9 +584,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.currentMode === SkeletonEditMode.Merge) { const anchorNodeId = this.layer.spatialSkeletonState.mergeAnchorNodeId.value; - const canExitWithKey = this.heldPhysicalKeyCodes.has( - MERGE_EXIT_KEY_CODE, - ); + const canExitWithKey = this.heldPhysicalKeyCodes.has(MERGE_EXIT_KEY_CODE); if (anchorNodeId !== undefined) { const cachedNode = this.getActiveSpatiallyIndexedSkeletonLayer()?.getNode( @@ -628,7 +639,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { : isHidden ? "selected-hidden" : "selected-visible", - this.shiftHeld, + this.shiftHeld && !this.ctrlHeld, ), ); } @@ -644,13 +655,20 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // --- Modifier tracking --- - // 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 }) { + // Sync shiftHeld/ctrlHeld 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 key codes. + private syncModifiers(event: { + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + }) { const isShift = event.shiftKey; - if (this.shiftHeld === isShift) return; + const isCtrl = hasNavigationModifier(event); + if (this.shiftHeld === isShift && this.ctrlHeld === isCtrl) return; this.shiftHeld = isShift; + this.ctrlHeld = isCtrl; this.updateModeAttribute(); this.renderStatus(); } @@ -1282,6 +1300,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.mergeKeyHeld = false; this.splitKeyHeld = false; this.shiftHeld = false; + this.ctrlHeld = false; this.heldPhysicalKeyCodes = new Set(); this.statusOverride = undefined; @@ -1397,6 +1416,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.mergeKeyHeld = false; this.splitKeyHeld = false; this.shiftHeld = false; + this.ctrlHeld = false; this.heldPhysicalKeyCodes = new Set(); this.exitMerge(); this.exitCreate(); @@ -1417,9 +1437,11 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // 10. Per-panel capture listeners — closures per panel; body delegates to class methods. // 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. + // at lower priority. All plain/shift left clicks are owned by the edit tool — they + // either select a node, add a node, or do nothing. Navigation (rotate/pan) belongs to + // middle mouse and to the navigation-modifier + left-click aliases below (for trackpad + // users without a reliable middle-click), 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) => { @@ -1453,6 +1475,42 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { return; } + // Trackpad-friendly aliases for the middle-mouse scheme above. + // Navigation modifier + left (plain): rotate in 3D (EventActionMap + // control+mousedown0 → rotate-via-mouse-drag), pan in 2D + // (intercepted here) — mirrors plain middle mouse. + // Navigation modifier + shift + left: translate in 3D + // (EventActionMap control+shift+mousedown0 → translate-via-mouse-drag), + // pan in 2D (intercepted here, same as above) — mirrors ctrl+middle + // mouse. Checked before the shift guard below so it takes priority + // over the shift+mousedown0 add-node chord; hasNavigationModifier is + // the discriminator (add-node never has the modifier held). + if (event.button === 0 && hasNavigationModifier(event)) { + if (panel instanceof PerspectivePanel) { + panel.element.dataset.skeletonPressMode = "rotate"; + const onMouseUp = () => { + delete panel.element.dataset.skeletonPressMode; + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mouseup", onMouseUp); + } else { + event.stopPropagation(); + event.preventDefault(); + panel.element.dataset.skeletonPressMode = "pan"; + startRelativeMouseDrag( + event, + (_dragEvent, deltaX, deltaY) => { + panel.context.flagContinuousCameraMotion(); + panel.translateByViewportPixels(deltaX, deltaY); + }, + () => { + delete panel.element.dataset.skeletonPressMode; + }, + ); + } + 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; From f997df9617e3bb7d362a5f8e5ca470ce1a30abed Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Jul 2026 13:11:03 +0200 Subject: [PATCH 08/17] Merge of MetaCell/feat/use-d-to-delete-tool feat: add delete mode via d to edit tool --- rspack.config.ts | 2 +- src/skeleton/actions.ts | 2 +- src/ui/default_input_event_bindings.ts | 8 +- src/ui/images/metacell/delete_cursor.svg | 16 ++++ src/ui/skeleton_edit_tool_messages.spec.ts | 55 ++++++++----- src/ui/skeleton_edit_tool_messages.ts | 26 ++++++- src/ui/skeleton_edit_tools.css | 6 ++ src/ui/skeleton_edit_tools.ts | 89 ++++++++++++++++++++-- 8 files changed, 170 insertions(+), 34 deletions(-) create mode 100644 src/ui/images/metacell/delete_cursor.svg diff --git a/rspack.config.ts b/rspack.config.ts index f3827fe549..e881c94d91 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -60,7 +60,7 @@ export default defineConfig((env, args) => { // emits a separate fetched file regardless of size, which caused the // custom cursor to only appear starting from the second activation. { - test: /src[\\/]ui[\\/]images[\\/]metacell[\\/](add_node_cursor|merge_cursor|new_skeleton_cursor|split_cursor|drag_cursor)\.svg$/, + test: /src[\\/]ui[\\/]images[\\/]metacell[\\/](add_node_cursor|merge_cursor|new_skeleton_cursor|split_cursor|delete_cursor|drag_cursor)\.svg$/, type: "asset/inline", }, // Needed for .html assets used for auth redirect pages for the diff --git a/src/skeleton/actions.ts b/src/skeleton/actions.ts index c05b626b53..df2cf06a67 100644 --- a/src/skeleton/actions.ts +++ b/src/skeleton/actions.ts @@ -42,5 +42,5 @@ export const SKELETON_ENTER_MERGE_MODE = "skeleton-enter-merge-mode"; 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_ENTER_DELETE_MODE = "skeleton-enter-delete-mode"; export const SKELETON_CLEAR_SELECTION = "skeleton-clear-node-selection"; diff --git a/src/ui/default_input_event_bindings.ts b/src/ui/default_input_event_bindings.ts index 987834e1f7..ff6512a969 100644 --- a/src/ui/default_input_event_bindings.ts +++ b/src/ui/default_input_event_bindings.ts @@ -18,8 +18,8 @@ import { SKELETON_ADD_NODE, SKELETON_CLEAR_SELECTION, SKELETON_CYCLE_BRANCHES, - SKELETON_DELETE_NODE, SKELETON_ENTER_CREATE, + SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, SKELETON_GO_BRANCH_END, @@ -257,16 +257,12 @@ export function getDefaultSkeletonEditToolBindings() { "at:keym": SKELETON_ENTER_MERGE_MODE, "at:keys": SKELETON_ENTER_SPLIT_MODE, "at:keyn": SKELETON_ENTER_CREATE, + "at:keyd": SKELETON_ENTER_DELETE_MODE, "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; diff --git a/src/ui/images/metacell/delete_cursor.svg b/src/ui/images/metacell/delete_cursor.svg new file mode 100644 index 0000000000..be3b7518bd --- /dev/null +++ b/src/ui/images/metacell/delete_cursor.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/ui/skeleton_edit_tool_messages.spec.ts b/src/ui/skeleton_edit_tool_messages.spec.ts index 000f21f34f..e8a612e00a 100644 --- a/src/ui/skeleton_edit_tool_messages.spec.ts +++ b/src/ui/skeleton_edit_tool_messages.spec.ts @@ -6,6 +6,8 @@ import { getSpatialSkeletonCreateIdleStatusText, getSpatialSkeletonCreatingStatusText, getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonDeleteIdleStatusText, + getSpatialSkeletonDeletingStatusText, getSpatialSkeletonMergeStatusText, getSpatialSkeletonMergingStatusText, getSpatialSkeletonMovingStatusText, @@ -54,7 +56,7 @@ describe("spatial_skeleton_tool_messages", () => { it("no selection", () => { expect(getSpatialSkeletonDefaultStatusText("none", false)).toEqual({ status: "No selection", - actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -63,7 +65,7 @@ describe("spatial_skeleton_tool_messages", () => { getSpatialSkeletonDefaultStatusText("selected-visible", false), ).toEqual({ status: "Node selected", - actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -72,7 +74,7 @@ describe("spatial_skeleton_tool_messages", () => { getSpatialSkeletonDefaultStatusText("selected-visible", true), ).toEqual({ status: "Ready to place new node", - actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -81,7 +83,7 @@ describe("spatial_skeleton_tool_messages", () => { getSpatialSkeletonDefaultStatusText("selected-hidden", false), ).toEqual({ status: "Node selected from non-visible skeleton", - actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -90,7 +92,7 @@ describe("spatial_skeleton_tool_messages", () => { getSpatialSkeletonDefaultStatusText("selected-hidden", true), ).toEqual({ status: "Node selected from non-visible skeleton", - actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); }); @@ -104,21 +106,17 @@ describe("spatial_skeleton_tool_messages", () => { describe("getSpatialSkeletonMergeStatusText", () => { it("no from node, key held", () => { - expect(getSpatialSkeletonMergeStatusText("no-from-node", true)).toEqual( - { - status: "Merge · no selected nodes", - actions: `Click a node to set as from node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, - }, - ); + expect(getSpatialSkeletonMergeStatusText("no-from-node", true)).toEqual({ + status: "Merge · no selected nodes", + actions: `Click a node to set as from node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); }); it("no from node, key not held", () => { - expect(getSpatialSkeletonMergeStatusText("no-from-node", false)).toEqual( - { - status: "Merge · no selected nodes", - actions: `Click a node to set as from node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, - }, - ); + expect(getSpatialSkeletonMergeStatusText("no-from-node", false)).toEqual({ + status: "Merge · no selected nodes", + actions: `Click a node to set as from node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); }); it("from node selected on a visible skeleton, key held", () => { @@ -188,6 +186,29 @@ describe("spatial_skeleton_tool_messages", () => { }); }); + describe("getSpatialSkeletonDeleteIdleStatusText", () => { + it("key held", () => { + expect(getSpatialSkeletonDeleteIdleStatusText(true)).toEqual({ + status: "Delete · no selected nodes", + actions: `Click a node to delete · release d to exit delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + + it("key not held", () => { + expect(getSpatialSkeletonDeleteIdleStatusText(false)).toEqual({ + status: "Delete · no selected nodes", + actions: `Click a node to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + }); + }); + }); + + it("returns a static deleting status", () => { + expect(getSpatialSkeletonDeletingStatusText()).toEqual({ + status: "Delete · deleting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }); + }); + describe("getSpatialSkeletonCreateIdleStatusText", () => { it("key held", () => { expect(getSpatialSkeletonCreateIdleStatusText(true)).toEqual({ diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index d38b9d6df0..fb6403e310 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -124,17 +124,17 @@ export function getSpatialSkeletonDefaultStatusText( case "none": return { status: "No selection", - actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Click to select · drag to move · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }; case "selected-visible": return { status: shiftHeld ? "Ready to place new node" : "Node selected", - actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Click to select · drag to move · shift+click to add node · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }; case "selected-hidden": return { status: "Node selected from non-visible skeleton", - actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + actions: `Double-click skeleton to show it · hold m to merge · hold s to split · hold n for new skeleton · hold d to delete · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }; } } @@ -221,6 +221,26 @@ export function getSpatialSkeletonSplittingStatusText(): SpatialSkeletonToolStat }; } +export function getSpatialSkeletonDeleteIdleStatusText( + canExitWithKey: boolean, +): SpatialSkeletonToolStatusText { + return { + status: "Delete · no selected nodes", + actions: withExitHint( + "Click a node to delete", + canExitWithKey, + "release d to exit delete", + ), + }; +} + +export function getSpatialSkeletonDeletingStatusText(): SpatialSkeletonToolStatusText { + return { + status: "Delete · deleting node…", + actions: SPATIAL_SKELETON_ROTATE_PAN_HINT, + }; +} + export function getSpatialSkeletonCreateIdleStatusText( canExitWithKey: boolean, ): SpatialSkeletonToolStatusText { diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index 5d61df16d3..e069d5d9df 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -84,6 +84,12 @@ crosshair; } +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="delete"] { + cursor: + url("./images/metacell/delete_cursor.svg") 20 20, + 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 56fafd6265..430a21fe77 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -28,8 +28,8 @@ import { getVisibleSegments } from "#src/segmentation_display_state/base.js"; import { SKELETON_ADD_NODE, SKELETON_CLEAR_SELECTION, - SKELETON_DELETE_NODE, SKELETON_ENTER_CREATE, + SKELETON_ENTER_DELETE_MODE, SKELETON_ENTER_MERGE_MODE, SKELETON_ENTER_SPLIT_MODE, SKELETON_PIN_NODE, @@ -70,6 +70,8 @@ import { getSpatialSkeletonCreateIdleStatusText, getSpatialSkeletonCreatingStatusText, getSpatialSkeletonDefaultStatusText, + getSpatialSkeletonDeleteIdleStatusText, + getSpatialSkeletonDeletingStatusText, getSpatialSkeletonMergeStatusText, getSpatialSkeletonMergingStatusText, getSpatialSkeletonMovingStatusText, @@ -96,6 +98,7 @@ const enum SkeletonEditMode { Merge = 1, Create = 2, Split = 3, + Delete = 4, } // In edit mode, plain left click is selection-only — it never rotates or @@ -127,6 +130,7 @@ const DRAG_START_DISTANCE_PX = 2; const MERGE_EXIT_KEY_CODE = "KeyM"; const SPLIT_EXIT_KEY_CODE = "KeyS"; const CREATE_EXIT_KEY_CODE = "KeyN"; +const DELETE_EXIT_KEY_CODE = "KeyD"; function hasNavigationModifier(event: { ctrlKey: boolean; metaKey: boolean }) { // TODO replace by mac check @@ -525,6 +529,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { // One-shot guards: prevent repeated fires while a key is held down. private mergeKeyHeld = false; private splitKeyHeld = false; + private deleteKeyHeld = false; // Modifier-held state drives cursor indicators and blocks node actions. private shiftHeld = false; // Navigation modifier (ctrl, or cmd on Mac — see hasNavigationModifier). @@ -565,6 +570,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute("create"); } else if (this.currentMode === SkeletonEditMode.Split) { this.setModeAttribute("split"); + } else if (this.currentMode === SkeletonEditMode.Delete) { + this.setModeAttribute("delete"); } else if (this.shiftHeld && !this.ctrlHeld) { this.setModeAttribute("add"); } else { @@ -626,6 +633,15 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); return; } + if (this.currentMode === SkeletonEditMode.Delete) { + renderSpatialSkeletonToolStatus( + body, + getSpatialSkeletonDeleteIdleStatusText( + this.heldPhysicalKeyCodes.has(DELETE_EXIT_KEY_CODE), + ), + ); + return; + } // Default mode const selectedPoint = this.getSelectedSpatialSkeletonNodeSummary(); const isHidden = @@ -733,6 +749,19 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.clearStatus(); } + private enterDelete() { + this.currentMode = SkeletonEditMode.Delete; + this.updateModeAttribute(); + this.renderStatus(); + } + + private exitDelete() { + if (this.currentMode !== SkeletonEditMode.Delete) return; + this.currentMode = SkeletonEditMode.Default; + this.updateModeAttribute(); + this.clearStatus(); + } + // --- Mouse handlers --- private handleDefaultMousedown(event: MouseEvent, panel: RenderedDataPanel) { @@ -1155,6 +1184,33 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.enterSplit(); } + // Delete (d): enters delete mode — click a node to delete it. + private onEnterDeleteModeAction() { + if ( + this.deleteKeyHeld || + this.dragInProgress || + this.pending || + this.currentMode !== SkeletonEditMode.Default + ) + return; + this.deleteKeyHeld = true; + 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; + } + this.enterDelete(); + } + private onAddNodeAction(event: ActionEvent) { event.stopPropagation(); event.detail.preventDefault(); @@ -1252,9 +1308,10 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); } - private onDeleteNodeAction(event: ActionEvent) { - event.stopPropagation(); - event.detail.preventDefault(); + private handleDeletePick() { + // Caller (capture listener) already called stopPropagation/preventDefault. + if (this.pending) return; + const disabledReason = this.layer.getSpatialSkeletonActionsDisabledReason( SpatialSkeletonActions.deleteNodes, ); @@ -1271,6 +1328,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } const pickedNode = this.getPickedSpatialSkeletonNode(); if (pickedNode === undefined) { + StatusMessage.showTemporaryMessage("Click a skeleton node to delete."); return; } const nodeInfo = skeletonLayer.getNode(pickedNode.nodeId); @@ -1280,11 +1338,17 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { ); return; } + this.pending = true; + this.setStatus(getSpatialSkeletonDeletingStatusText()); void this.layer .getSpatialSkeletonDeleteOperationContext(nodeInfo) .then(() => executeSpatialSkeletonDeleteNode(this.layer, nodeInfo)) .catch((error) => { showSpatialSkeletonActionError("delete node", error); + }) + .finally(() => { + this.pending = false; + this.renderStatus(); }); } @@ -1299,6 +1363,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.createPlacedThisHold = false; this.mergeKeyHeld = false; this.splitKeyHeld = false; + this.deleteKeyHeld = false; this.shiftHeld = false; this.ctrlHeld = false; this.heldPhysicalKeyCodes = new Set(); @@ -1407,6 +1472,10 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.splitKeyHeld = false; this.exitSplit(); } + if (event.code === DELETE_EXIT_KEY_CODE) { + this.deleteKeyHeld = false; + this.exitDelete(); + } this.syncModifiers(event); }; // mousemove catches modifiers pressed/released while keyboard focus is @@ -1415,12 +1484,14 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { const onBlur = () => { this.mergeKeyHeld = false; this.splitKeyHeld = false; + this.deleteKeyHeld = false; this.shiftHeld = false; this.ctrlHeld = false; this.heldPhysicalKeyCodes = new Set(); this.exitMerge(); this.exitCreate(); this.exitSplit(); + this.exitDelete(); this.updateModeAttribute(); }; window.addEventListener("keydown", onKeyDown); @@ -1532,6 +1603,12 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.handleCreatePlace(); return; } + if (this.currentMode === SkeletonEditMode.Delete) { + event.stopPropagation(); + event.preventDefault(); + this.handleDeletePick(); + return; + } // Default mode: only consume if hovering a node. this.handleDefaultMousedown(event, panel); }; @@ -1558,8 +1635,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { 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_ENTER_DELETE_MODE, () => + this.onEnterDeleteModeAction(), ); activation.bindAction(SKELETON_TOGGLE_TRUE_END, () => { const skeletonLayer = this.getActiveSpatiallyIndexedSkeletonLayer(); From b50e7e8f4ca43ad35a1dd059563c9d1ce01189c4 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Jul 2026 13:35:59 +0200 Subject: [PATCH 09/17] Merge of MetaCell/feat/clearer-merge-split-actions feat: hide highlight in merge split until click --- src/layer/segmentation/index.ts | 6 +++++ src/skeleton/frontend.ts | 16 +++++++++++-- src/skeleton/spatial_skeleton_manager.ts | 5 ++++ src/ui/skeleton_edit_tool_messages.spec.ts | 28 +++++++++++----------- src/ui/skeleton_edit_tool_messages.ts | 22 ++++++----------- src/ui/skeleton_edit_tools.spec.ts | 6 +++++ src/ui/skeleton_edit_tools.ts | 16 +++++++++++++ 7 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index db36fdd47c..98dbf7f87a 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -1075,6 +1075,8 @@ export class SegmentationUserLayer extends Base { readonly spatialSkeletonEditMode = this.spatialSkeletonState.editMode; readonly spatialSkeletonMergeMode = this.spatialSkeletonState.mergeMode; readonly spatialSkeletonSplitMode = this.spatialSkeletonState.splitMode; + readonly spatialSkeletonSuppressSelectedNodeHighlight = + this.spatialSkeletonState.suppressSelectedNodeHighlight; readonly spatialSkeletonNodeDataVersion = this.spatialSkeletonState.nodeDataVersion; @@ -1626,6 +1628,8 @@ export class SegmentationUserLayer extends Base { { sources2d: slicePanelSources, selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + suppressSelectedNodeHighlight: + this.spatialSkeletonState.suppressSelectedNodeHighlight, hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, @@ -1660,6 +1664,8 @@ export class SegmentationUserLayer extends Base { displayState, { selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + suppressSelectedNodeHighlight: + this.spatialSkeletonState.suppressSelectedNodeHighlight, hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 6d606d95d7..5109324ea3 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -1877,6 +1877,9 @@ interface SpatiallyIndexedSkeletonLayerOptions { selectedNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; + // When true, the selected-node highlight is hidden even though a node may be + // selected (used while entering merge/split modes). + suppressSelectedNodeHighlight?: WatchableValueInterface; hoveredNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; @@ -2109,6 +2112,9 @@ export class SpatiallyIndexedSkeletonLayer private selectedNodeInfo: | WatchableValueInterface | undefined; + private suppressSelectedNodeHighlight: + | WatchableValueInterface + | undefined; private hoveredNodeInfo: | WatchableValueInterface | undefined; @@ -2457,6 +2463,7 @@ export class SpatiallyIndexedSkeletonLayer ), ); this.selectedNodeInfo = options.selectedNodeInfo; + this.suppressSelectedNodeHighlight = options.suppressSelectedNodeHighlight; this.hoveredNodeInfo = options.hoveredNodeInfo; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; @@ -2573,6 +2580,11 @@ export class SpatiallyIndexedSkeletonLayer }), ); } + if (this.suppressSelectedNodeHighlight?.changed) { + this.registerDisposer( + this.suppressSelectedNodeHighlight.changed.add(requestRedraw), + ); + } if (this.hoveredNodeInfo?.changed) { this.registerDisposer( this.hoveredNodeInfo.changed.add(() => { @@ -3023,7 +3035,7 @@ export class SpatiallyIndexedSkeletonLayer ); gl.uniform1i( nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, + this.getHighlightedSelectedNodeId(), ); gl.uniform3fv( nodeShader.uniform("uHighlightedNodeOutlineColor"), @@ -3159,7 +3171,7 @@ export class SpatiallyIndexedSkeletonLayer ); gl.uniform1i( nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, + this.getHighlightedSelectedNodeId(), ); gl.uniform3fv( nodeShader.uniform("uHighlightedNodeOutlineColor"), diff --git a/src/skeleton/spatial_skeleton_manager.ts b/src/skeleton/spatial_skeleton_manager.ts index ba39996136..6907f965ba 100644 --- a/src/skeleton/spatial_skeleton_manager.ts +++ b/src/skeleton/spatial_skeleton_manager.ts @@ -245,6 +245,11 @@ export class SpatialSkeletonState extends RefCounted { readonly mergeAnchorNodeId = new WatchableValue( undefined, ); + // When true, the selected-node highlight is hidden even if a node is + // selected. Driven by the edit tool so that entering merge/split mode does + // not display a stale highlight until the user makes their first click + // (merge) or is suppressed entirely until a click/exit (split). + readonly suppressSelectedNodeHighlight = new WatchableValue(false); readonly nodeDataVersion = new WatchableValue(0); readonly pendingNodePositionVersion = new WatchableValue(0); diff --git a/src/ui/skeleton_edit_tool_messages.spec.ts b/src/ui/skeleton_edit_tool_messages.spec.ts index e8a612e00a..3122794606 100644 --- a/src/ui/skeleton_edit_tool_messages.spec.ts +++ b/src/ui/skeleton_edit_tool_messages.spec.ts @@ -107,15 +107,15 @@ describe("spatial_skeleton_tool_messages", () => { describe("getSpatialSkeletonMergeStatusText", () => { it("no from node, key held", () => { expect(getSpatialSkeletonMergeStatusText("no-from-node", true)).toEqual({ - status: "Merge · no selected nodes", - actions: `Click a node to set as from node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Merge · click a node to merge from", + actions: `Click to select node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); it("no from node, key not held", () => { expect(getSpatialSkeletonMergeStatusText("no-from-node", false)).toEqual({ - status: "Merge · no selected nodes", - actions: `Click a node to set as from node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Merge · click a node to merge from", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -123,8 +123,8 @@ describe("spatial_skeleton_tool_messages", () => { expect( getSpatialSkeletonMergeStatusText("from-node-visible", true), ).toEqual({ - status: "Merge · from node selected", - actions: `Click a 2nd node on a different skeleton to merge · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Merge · click a node to merge to", + actions: `Click to select node · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -132,8 +132,8 @@ describe("spatial_skeleton_tool_messages", () => { expect( getSpatialSkeletonMergeStatusText("from-node-visible", false), ).toEqual({ - status: "Merge · from node selected", - actions: `Click a 2nd node on a different skeleton to merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Merge · click a node to merge to", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -141,7 +141,7 @@ describe("spatial_skeleton_tool_messages", () => { expect( getSpatialSkeletonMergeStatusText("from-node-hidden", true), ).toEqual({ - status: "Merge · from node on non-visible skeleton", + status: "Merge · make the from-node skeleton visible", actions: `Double-click skeleton to show it · release m to exit merge · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -150,7 +150,7 @@ describe("spatial_skeleton_tool_messages", () => { expect( getSpatialSkeletonMergeStatusText("from-node-hidden", false), ).toEqual({ - status: "Merge · from node on non-visible skeleton", + status: "Merge · make the from-node skeleton visible", actions: `Double-click skeleton to show it · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); @@ -166,15 +166,15 @@ describe("spatial_skeleton_tool_messages", () => { describe("getSpatialSkeletonSplitIdleStatusText", () => { it("key held", () => { expect(getSpatialSkeletonSplitIdleStatusText(true)).toEqual({ - status: "Split · no selected nodes", - actions: `Click a node to split · release s to exit split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Split · click a node to form the root of a new skeleton", + actions: `Click to select node · release s to exit split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); it("key not held", () => { expect(getSpatialSkeletonSplitIdleStatusText(false)).toEqual({ - status: "Split · no selected nodes", - actions: `Click a node to split · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, + status: "Split · click a node to form the root of a new skeleton", + actions: `Click to select node · ${SPATIAL_SKELETON_ROTATE_PAN_HINT}`, }); }); }); diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index fb6403e310..8a10359ea3 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -166,25 +166,17 @@ export function getSpatialSkeletonMergeStatusText( switch (state) { case "no-from-node": return { - status: "Merge · no selected nodes", - actions: withExitHint( - "Click a node to set as from node", - canExitWithKey, - exitHint, - ), + status: "Merge · click a node to merge from", + actions: withExitHint("Click to select node", canExitWithKey, exitHint), }; case "from-node-visible": return { - status: "Merge · from node selected", - actions: withExitHint( - "Click a 2nd node on a different skeleton to merge", - canExitWithKey, - exitHint, - ), + status: "Merge · click a node to merge to", + actions: withExitHint("Click to select node", canExitWithKey, exitHint), }; case "from-node-hidden": return { - status: "Merge · from node on non-visible skeleton", + status: "Merge · make the from-node skeleton visible", actions: withExitHint( "Double-click skeleton to show it", canExitWithKey, @@ -205,9 +197,9 @@ export function getSpatialSkeletonSplitIdleStatusText( canExitWithKey: boolean, ): SpatialSkeletonToolStatusText { return { - status: "Split · no selected nodes", + status: "Split · click a node to form the root of a new skeleton", actions: withExitHint( - "Click a node to split", + "Click to select node", canExitWithKey, "release s to exit split", ), diff --git a/src/ui/skeleton_edit_tools.spec.ts b/src/ui/skeleton_edit_tools.spec.ts index 83f7123392..c18ee5b59f 100644 --- a/src/ui/skeleton_edit_tools.spec.ts +++ b/src/ui/skeleton_edit_tools.spec.ts @@ -724,6 +724,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, changed: makeChangedSignal(), @@ -817,6 +819,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, changed: makeChangedSignal(), @@ -889,6 +893,8 @@ describe("spatial_skeleton_edit_tool", () => { }, spatialSkeletonEditMode: makeModeWatchable(), spatialSkeletonMergeMode: makeModeWatchable(), + spatialSkeletonSplitMode: makeModeWatchable(), + spatialSkeletonSuppressSelectedNodeHighlight: makeModeWatchable(), selectedSpatialSkeletonNodeInfo: { value: undefined, // No node selected. changed: makeChangedSignal(), diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index 430a21fe77..d6a4c1a8e1 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -704,6 +704,11 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.layer.selectSpatialSkeletonNode(anchorNode.nodeId, true, anchorNode); this.layer.setSpatialSkeletonMergeAnchor(anchorNode.nodeId); } + // In merge mode the selected-node highlight is only shown once a from node + // has been picked (the first click). When entered with an explicit anchor, + // that first pick has effectively already happened. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = + anchorNode === undefined; this.layer.spatialSkeletonMergeMode.value = true; this.currentMode = SkeletonEditMode.Merge; this.updateModeAttribute(); @@ -714,6 +719,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.currentMode !== SkeletonEditMode.Merge) return; this.layer.clearSpatialSkeletonMergeAnchor(); this.layer.spatialSkeletonMergeMode.value = false; + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.currentMode = SkeletonEditMode.Default; this.updateModeAttribute(); this.clearStatus(); @@ -737,6 +743,9 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { private enterSplit() { this.currentMode = SkeletonEditMode.Split; this.layer.spatialSkeletonSplitMode.value = true; + // In split mode the selected-node highlight stays hidden until the user + // clicks a node to split (or exits back to default mode). + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = true; this.updateModeAttribute(); this.renderStatus(); } @@ -745,6 +754,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { if (this.currentMode !== SkeletonEditMode.Split) return; this.currentMode = SkeletonEditMode.Default; this.layer.spatialSkeletonSplitMode.value = false; + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.updateModeAttribute(); this.clearStatus(); } @@ -913,6 +923,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { }) { this.pinSegmentByNumber(pickedNode.segmentId); this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); + // A node was clicked: reveal the selected-node highlight for it. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.pending = true; this.setStatus(getSpatialSkeletonSplittingStatusText()); void (async () => { @@ -979,6 +991,8 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } this.layer.selectSpatialSkeletonNode(pickedNode.nodeId, true, pickedNode); this.layer.setSpatialSkeletonMergeAnchor(pickedNode.nodeId); + // First click made: reveal the selected-node highlight for the from node. + this.layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; this.renderStatus(); } @@ -1368,6 +1382,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.ctrlHeld = false; this.heldPhysicalKeyCodes = new Set(); this.statusOverride = undefined; + layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; // 2. Create status UI. const { body, header } = @@ -1404,6 +1419,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { this.setModeAttribute(undefined); layer.spatialSkeletonMergeMode.value = false; layer.spatialSkeletonSplitMode.value = false; + layer.spatialSkeletonSuppressSelectedNodeHighlight.value = false; layer.spatialSkeletonState.clearPendingNodePositions(); }); From 2063d2d2b0882b8373434560a780e0a5f499ca54 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Jul 2026 13:53:26 +0200 Subject: [PATCH 10/17] Merge of MetaCell/feat/avoid-download-data-if-skeletons-hidden feat: avoid download data of hidden skeletons if non visible --- src/skeleton/backend.ts | 8 ++++++++ src/skeleton/frontend.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/skeleton/backend.ts b/src/skeleton/backend.ts index 65b9c67f34..8e33f17643 100644 --- a/src/skeleton/backend.ts +++ b/src/skeleton/backend.ts @@ -295,12 +295,14 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager localPosition: SharedWatchableValue; skeletonSpacingTarget: SharedWatchableValue; skeletonSpacingTarget2d: SharedWatchableValue; + hiddenSkeletonsVisible: SharedWatchableValue; constructor(rpc: RPC, options: any) { super(rpc, options); this.skeletonSpacingTarget = rpc.get(options.skeletonSpacingTarget); this.skeletonSpacingTarget2d = rpc.get(options.skeletonSpacingTarget2d); this.localPosition = rpc.get(options.localPosition); + this.hiddenSkeletonsVisible = rpc.get(options.hiddenSkeletonsVisible); const scheduleUpdateChunkPriorities = () => this.chunkManager.scheduleUpdateChunkPriorities(); this.registerDisposer( @@ -312,6 +314,9 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager this.registerDisposer( this.skeletonSpacingTarget2d.changed.add(scheduleUpdateChunkPriorities), ); + this.registerDisposer( + this.hiddenSkeletonsVisible.changed.add(scheduleUpdateChunkPriorities), + ); this.registerDisposer( this.chunkManager.recomputeChunkPriorities.add(() => this.recomputeChunkPriorities(), @@ -343,6 +348,9 @@ export class SpatiallyIndexedSkeletonRenderLayerBackend extends withChunkManager } private recomputeChunkPriorities() { + if (!this.hiddenSkeletonsVisible.value) { + return; + } this.chunkManager.registerLayer(this); for (const attachment of this.attachments.values()) { const { view } = attachment; diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 5109324ea3..aef38d5e53 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -110,6 +110,7 @@ import { SliceViewPanelRenderLayer } from "#src/sliceview/renderlayer.js"; import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { + makeCachedDerivedWatchableValue, makeCachedLazyDerivedWatchableValue, TrackableValue, WatchableValue, @@ -2630,6 +2631,18 @@ export class SpatiallyIndexedSkeletonLayer ), ); + const hiddenSkeletonsVisibleWatchable = this.registerDisposer( + SharedWatchableValue.makeFromExisting( + rpc, + this.registerDisposer( + makeCachedDerivedWatchableValue( + (alpha) => alpha > 0, + [this.displayState.hiddenObjectAlpha], + ), + ), + ), + ); + sharedObject.initializeCounterpart(rpc, { chunkManager: chunkManager.rpcId, localPosition: this.registerDisposer( @@ -2637,6 +2650,7 @@ export class SpatiallyIndexedSkeletonLayer ).rpcId, skeletonSpacingTarget: skeletonSpacingTargetWatchable.rpcId, skeletonSpacingTarget2d: skeletonSpacingTarget2dWatchable.rpcId, + hiddenSkeletonsVisible: hiddenSkeletonsVisibleWatchable.rpcId, }); this.backend = sharedObject; this.gpuBrowseExcludedSegmentsHashTable = this.registerDisposer( From 324f83e92d73d6b8b2d9c92ece7256426edf7128 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Tue, 7 Jul 2026 16:49:20 +0200 Subject: [PATCH 11/17] Merge of MetaCell/feat/hide-selected-node-merge-split feat: improve picking indicator perf and always enable --- src/perspective_view/panel.ts | 71 +++++++++++++++++++++++++++++++++++ src/rendered_data_panel.ts | 68 +++++++++++++++++++++++++++++++++ src/sliceview/panel.ts | 37 ++++++++++++++++++ 3 files changed, 176 insertions(+) diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index f07ad56735..5a5bb070bb 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -183,6 +183,11 @@ const tempVec3 = vec3.create(); const tempVec4 = vec4.create(); const tempMat4 = mat4.create(); +// Clamp range for the depth-based picking-indicator scale (relative to the base +// diameter at the focal plane). Keeps the ring from becoming extreme. +const PICKING_INDICATOR_MIN_DEPTH_SCALE = 0.6; +const PICKING_INDICATOR_MAX_DEPTH_SCALE = 1.7; + // Copy the OIT values to the main color buffer function defineTransparencyCopyShader(builder: ShaderBuilder) { builder.addOutputBuffer("vec4", "v4f_fragColor", null); @@ -575,6 +580,13 @@ export class PerspectivePanel extends RenderedDataPanel { this.registerDisposer( viewer.showAxisLines.changed.add(() => this.scheduleRedraw()), ); + this.registerDisposer( + viewer.mouseState.changed.add(() => { + // The picking indicator is a DOM overlay; moving the cursor only + // repositions it and never triggers a canvas redraw. + this.updatePickingIndicator(); + }), + ); this.registerDisposer( viewer.crossSectionBackgroundColor.changed.add(() => this.scheduleRedraw(), @@ -1506,6 +1518,65 @@ export class PerspectivePanel extends RenderedDataPanel { ); } + protected computePickingIndicatorPosition() { + const { mouseState } = this.viewer; + if (!mouseState.active) return undefined; + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = this.projectionParameters.value; + // mouseState.position is in global voxel space; extract display-space components. + const px = + displayDimensionIndices[0] >= 0 + ? mouseState.position[displayDimensionIndices[0]] + : 0; + const py = + displayDimensionIndices[1] >= 0 + ? mouseState.position[displayDimensionIndices[1]] + : 0; + const pz = + displayDimensionIndices[2] >= 0 + ? mouseState.position[displayDimensionIndices[2]] + : 0; + const displayPos = tempVec3; + displayPos[0] = px; + displayPos[1] = py; + displayPos[2] = pz; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + if (displayPos[2] < -1 || displayPos[2] > 1) return undefined; + + // Scale the indicator with depth to convey 3D position: the clip-space w is + // proportional to view-space depth for a perspective projection, so the + // ratio of the navigation center's w to the picked point's w is 1 at the + // focal plane, >1 nearer (larger ring), <1 farther (smaller ring). In + // orthographic mode m[3]=m[7]=m[11]=0, so both w values equal m[15] and the + // scale is 1 (constant size), needing no special-casing. + const m = viewProjectionMat; + const clipW = (x: number, y: number, z: number) => + m[3] * x + m[7] * y + m[11] * z + m[15]; + const pickedW = clipW(px, py, pz); + const center = this.navigationState.position.value; + const centerW = clipW( + displayDimensionIndices[0] >= 0 ? center[displayDimensionIndices[0]] : 0, + displayDimensionIndices[1] >= 0 ? center[displayDimensionIndices[1]] : 0, + displayDimensionIndices[2] >= 0 ? center[displayDimensionIndices[2]] : 0, + ); + let scale = 1; + if (pickedW > 1e-6 && centerW > 1e-6) { + scale = Math.min( + PICKING_INDICATOR_MAX_DEPTH_SCALE, + Math.max(PICKING_INDICATOR_MIN_DEPTH_SCALE, centerW / pickedW), + ); + } + return { + x: (displayPos[0] * 0.5 + 0.5) * logicalWidth, + y: (1 - (displayPos[1] * 0.5 + 0.5)) * logicalHeight, + scale, + }; + } + zoomByMouse(factor: number) { this.navigationState.zoomBy(factor); } diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index 3b9a440c6f..a64cdf11fd 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -117,6 +117,10 @@ export class PickRequest { const pickRequestInterval = 30; +// Base diameter (CSS px) of the picking-indicator ring. In the perspective view +// this is scaled by depth (see `computePickingIndicatorPosition`). +const PICKING_INDICATOR_DIAMETER = 14; + export abstract class RenderedDataPanel extends RenderedPanel { /** * Current mouse position within the viewport, or -1 if the mouse is not in the viewport. @@ -364,6 +368,9 @@ export abstract class RenderedDataPanel extends RenderedPanel { newPickingData.frameNumber = -1; return; } + // Keep the picking-indicator overlay in sync with the newly-rendered view + // (e.g. after a camera move the picked position projects to a new location). + this.updatePickingIndicator(); // For the new frame, allow new pick requests regardless of interval since last request. this.nextPickRequestTime = 0; if (this.mouseX >= 0) { @@ -373,6 +380,67 @@ export abstract class RenderedDataPanel extends RenderedPanel { abstract drawWithPicking(pickingData: FramePickingData): boolean; + /** + * DOM element overlaying this panel that shows the picking indicator (a ring + * at the cursor's picked position). It is a plain DOM element rather than a + * GL draw so that moving the cursor only repositions it and never triggers a + * canvas redraw. + */ + private pickingIndicatorElement = this.createPickingIndicatorElement(); + + private createPickingIndicatorElement(): HTMLElement { + const el = document.createElement("div"); + el.className = "neuroglancer-picking-indicator"; + // A white ring bordered by black on both sides for contrast on any + // background. Size and position are set in `updatePickingIndicator` (the + // size may vary with depth), so only the appearance is set here. + const s = el.style; + s.position = "absolute"; + s.left = "0"; + s.top = "0"; + s.boxSizing = "border-box"; + s.borderRadius = "50%"; + s.border = "2px solid rgba(255, 255, 255, 0.92)"; + s.boxShadow = + "0 0 0 1px rgba(0, 0, 0, 0.92), inset 0 0 0 1px rgba(0, 0, 0, 0.92)"; + s.pointerEvents = "none"; + s.zIndex = "10"; + s.willChange = "transform"; + s.display = "none"; + this.element.appendChild(el); + return el; + } + + /** + * Returns the picking indicator center in this panel's logical CSS pixels, or + * `undefined` if the indicator should be hidden. `scale` (default 1) scales + * the ring diameter to convey depth; panels without a depth cue omit it. + * Implemented per panel using its own projection. + */ + protected abstract computePickingIndicatorPosition(): + | { x: number; y: number; scale?: number } + | undefined; + + /** + * Repositions/resizes (or hides) the picking-indicator overlay for the current + * mouse selection. Cheap DOM-only update; does not touch the canvas. + */ + updatePickingIndicator() { + const el = this.pickingIndicatorElement; + const pos = this.visible + ? this.computePickingIndicatorPosition() + : undefined; + if (pos === undefined) { + if (el.style.display !== "none") el.style.display = "none"; + return; + } + const size = PICKING_INDICATOR_DIAMETER * (pos.scale ?? 1); + el.style.display = ""; + el.style.width = `${size}px`; + el.style.height = `${size}px`; + el.style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + } + private nextPickRequestTime = 0; private pendingPickRequestTimerId = -1; diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index 980a38dc7d..b6e5ac1a18 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -276,6 +276,13 @@ export class SliceViewPanel extends RenderedDataPanel { } }), ); + this.registerDisposer( + viewer.mouseState.changed.add(() => { + // The picking indicator is a DOM overlay; moving the cursor only + // repositions it and never triggers a canvas redraw. + this.updatePickingIndicator(); + }), + ); } translateByViewportPixels(deltaX: number, deltaY: number): void { @@ -532,6 +539,36 @@ export class SliceViewPanel extends RenderedDataPanel { setStateFromRelative(pickRadius, pickRadius, 0); } + protected computePickingIndicatorPosition() { + const { mouseState } = this.viewer; + if (!mouseState.active) return undefined; + const { + viewProjectionMat, + logicalWidth, + logicalHeight, + displayDimensionRenderInfo: { displayDimensionIndices }, + } = this.sliceView.projectionParameters.value; + const displayPos = tempVec3; + displayPos[0] = + displayDimensionIndices[0] >= 0 + ? mouseState.position[displayDimensionIndices[0]] + : 0; + displayPos[1] = + displayDimensionIndices[1] >= 0 + ? mouseState.position[displayDimensionIndices[1]] + : 0; + displayPos[2] = + displayDimensionIndices[2] >= 0 + ? mouseState.position[displayDimensionIndices[2]] + : 0; + vec3.transformMat4(displayPos, displayPos, viewProjectionMat); + if (displayPos[2] < -1 || displayPos[2] > 1) return undefined; + return { + x: (displayPos[0] * 0.5 + 0.5) * logicalWidth, + y: (1 - (displayPos[1] * 0.5 + 0.5)) * logicalHeight, + }; + } + /** * Zooms by the specified factor, maintaining the data position that projects to the current mouse * position. From 74d077b5a95337c621cd8deacd23581673f4b090 Mon Sep 17 00:00:00 2001 From: afonso pinto Date: Tue, 7 Jul 2026 17:29:28 +0100 Subject: [PATCH 12/17] feat: Tell CATMAID to keep the upstream annotations by default while giving the newly split downstream neuron an empty annotation map --- src/datasource/catmaid/api.spec.ts | 1 + src/datasource/catmaid/api.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/datasource/catmaid/api.spec.ts b/src/datasource/catmaid/api.spec.ts index 79707f766e..bdc3349e89 100644 --- a/src/datasource/catmaid/api.spec.ts +++ b/src/datasource/catmaid/api.spec.ts @@ -933,6 +933,7 @@ describe("CatmaidClient skeleton editing methods", () => { const requestBody = getFetchBody(fetchMock); expect(getFetchPath(fetchMock)).toBe("skeleton/split"); expect(requestBody.get("treenode_id")).toBe("202"); + expect(requestBody.get("downstream_annotation_map")).toBe("{}"); expect(requestBody.get("state")).toBe( JSON.stringify({ edition_time: "2026-03-29T12:05:00Z", diff --git a/src/datasource/catmaid/api.ts b/src/datasource/catmaid/api.ts index bc01c9b439..9360e445b8 100644 --- a/src/datasource/catmaid/api.ts +++ b/src/datasource/catmaid/api.ts @@ -2068,6 +2068,7 @@ export class CatmaidClient implements CatmaidSpatialSkeletonEditApi { ): Promise { const body = new URLSearchParams({ treenode_id: nodeId.toString(), + downstream_annotation_map: JSON.stringify({}), }); appendCatmaidState( body, From 0c5e38ea8291f3da749b8fa76ab977d11c1ca258 Mon Sep 17 00:00:00 2001 From: Afonso Pinto Date: Fri, 10 Jul 2026 02:16:05 +0100 Subject: [PATCH 13/17] Merge of MetaCell/feat/better-perf-skeleton-highlight feat: improve skeleton highlight perf --- src/display_context.ts | 56 ++++ src/layer/index.ts | 17 + src/layer/segmentation/index.ts | 19 +- src/layer/segmentation/selection.ts | 21 +- src/panel_overlay.css | 33 ++ src/panel_overlay.ts | 236 ++++++++++++++ src/perspective_view/panel.ts | 21 +- src/picking_indicator_overlay.css | 32 ++ src/picking_indicator_overlay.ts | 68 ++++ src/rendered_data_panel.ts | 117 ++++--- src/skeleton/frontend.css | 32 ++ src/skeleton/frontend.ts | 467 ++++++++++++++++++---------- src/sliceview/panel.ts | 25 +- src/viewer.ts | 7 + src/widget/accordion.ts | 308 ++++++++++++++++++ 15 files changed, 1206 insertions(+), 253 deletions(-) create mode 100644 src/panel_overlay.css create mode 100644 src/panel_overlay.ts create mode 100644 src/picking_indicator_overlay.css create mode 100644 src/picking_indicator_overlay.ts create mode 100644 src/skeleton/frontend.css create mode 100644 src/widget/accordion.ts diff --git a/src/display_context.ts b/src/display_context.ts index b15d64a68f..2219ac0870 100644 --- a/src/display_context.ts +++ b/src/display_context.ts @@ -17,6 +17,10 @@ import { debounce } from "lodash-es"; import type { FrameNumberCounter } from "#src/chunk_manager/frontend.js"; +import type { + PanelOverlaySource, + PanelOverlayTarget, +} from "#src/panel_overlay.js"; import { TrackableValue } from "#src/trackable_value.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed } from "#src/util/disposable.js"; @@ -302,6 +306,16 @@ export abstract class RenderedPanel extends RefCounted { abstract draw(): void; + // Repositions this panel's DOM overlays. Default no-op; overridden by panels + // that support overlays. + updateOverlays(): void {} + + scheduleOverlayUpdate(): void { + if (this.visible) { + this.context.scheduleOverlayUpdate(); + } + } + disposed() { this.context.unmonitorPanel(this.element, this.monitorState); this.context.removePanel(this); @@ -640,6 +654,45 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { animationFrameDebounce(() => this.draw()), ); + // Overlay sources shown on data panels, each with its optional panel-type + // target. Panels observe `panelOverlaysChanged` to add/remove their bindings. + readonly panelOverlays = new Map(); + readonly panelOverlaysChanged = new NullarySignal(); + + /** + * Registers an overlay source shown on the data panels matching `target` (every + * data panel by default). Returns a disposer that removes it. + */ + registerPanelOverlay( + source: PanelOverlaySource, + target: PanelOverlayTarget = {}, + ): () => void { + this.panelOverlays.set(source, target); + this.panelOverlaysChanged.dispatch(); + return () => { + if (this.panelOverlays.delete(source)) { + this.panelOverlaysChanged.dispatch(); + } + }; + } + + // Repositions DOM overlays across all panels, coalesced per animation frame + // and independent of `scheduleRedraw`. + readonly scheduleOverlayUpdate = this.registerCancellable( + animationFrameDebounce(() => this.updateOverlays()), + ); + + private updateOverlays() { + this.ensureBoundsUpdated(); + for (const panel of this.panels) { + if (!panel.shouldDraw) continue; + panel.ensureBoundsUpdated(); + const { renderViewport } = panel; + if (renderViewport.width === 0 || renderViewport.height === 0) continue; + panel.updateOverlays(); + } + } + ensureBoundsUpdated() { const { resizeGeneration } = this; if (this.boundsGeneration === resizeGeneration) return; @@ -684,6 +737,9 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { this.updateFinished.dispatch(); this.framerateMonitor.endLastTimeQuery(gl, ext); this.framerateMonitor.grabAnyFinishedQueryResults(gl); + // Each panel's draw() already updated its overlays, so drop any pending + // overlay-only update. + this.scheduleOverlayUpdate.cancel(); } getDepthArray(): Float32Array { diff --git a/src/layer/index.ts b/src/layer/index.ts index 7995083b43..e3c0359242 100644 --- a/src/layer/index.ts +++ b/src/layer/index.ts @@ -53,6 +53,8 @@ import { PlaybackManager, Position, } from "#src/navigation_state.js"; +import type { PanelOverlaySource } from "#src/panel_overlay.js"; +import { isPanelOverlaySource } from "#src/panel_overlay.js"; import type { RenderLayerTransform } from "#src/render_coordinate_transform.js"; import { RENDERED_VIEW_ADD_LAYER_RPC_ID, @@ -1678,6 +1680,21 @@ export function makeRenderedPanelVisibleLayerTracker< info.registerDisposer( layer.redrawNeeded.add(() => panel.scheduleRedraw()), ); + // Layers that contribute DOM panel overlays (e.g. skeleton + // selected/hovered node highlights) are bound to this panel; the binding + // (container + update wiring) is scoped to this per-(layer,panel) info. + const overlayPanel = panel as Partial<{ + bindOverlaySource( + source: PanelOverlaySource, + owner: RefCounted, + ): void; + }>; + if ( + isPanelOverlaySource(layer) && + typeof overlayPanel.bindOverlaySource === "function" + ) { + overlayPanel.bindOverlaySource(layer, info); + } const { backend } = layer; if (backend) { backend.rpc!.invoke(RENDERED_VIEW_ADD_LAYER_RPC_ID, { diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index 98dbf7f87a..98f5114aa8 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -941,7 +941,16 @@ export class SegmentationUserLayer extends Base { const requestedSegmentId = options.segmentId ?? selectedNodeInfo?.segmentId ?? undefined; const segmentId = normalizeOptionalPositiveSafeInteger(requestedSegmentId); - const selectedNodePosition = options.position ?? selectedNodeInfo?.position; + const previousSelectedInfo = this.selectedSpatialSkeletonNodeInfo.value; + // Keep a model-space position available even when the node isn't currently + // cached, so the highlight overlay can still be placed: prefer the explicit + // option / cache, else retain the position captured for the same node. + const selectedNodePosition = + options.position ?? + selectedNodeInfo?.position ?? + (previousSelectedInfo?.nodeId === normalizedNodeId + ? previousSelectedInfo.position + : undefined); const selectedGlobalPosition = this.getGlobalSelectionPositionFromModelPosition(selectedNodePosition); const sourceState = options.sourceState ?? selectedNodeInfo?.sourceState; @@ -1637,6 +1646,10 @@ export class SegmentationUserLayer extends Base { this.spatialSkeletonState.getPendingNodePosition(nodeId), getCachedNode: (nodeId) => this.spatialSkeletonState.getCachedNode(nodeId), + resolveGlobalPosition: (modelPosition) => + this.getGlobalSelectionPositionFromModelPosition( + modelPosition, + ), inspectionState: this.spatialSkeletonState, }, ); @@ -1673,6 +1686,10 @@ export class SegmentationUserLayer extends Base { this.spatialSkeletonState.getPendingNodePosition(nodeId), getCachedNode: (nodeId) => this.spatialSkeletonState.getCachedNode(nodeId), + resolveGlobalPosition: (modelPosition) => + this.getGlobalSelectionPositionFromModelPosition( + modelPosition, + ), inspectionState: this.spatialSkeletonState, }, ); diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index c843b0635d..26e7a433d7 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -32,6 +32,9 @@ interface SpatialSkeletonViewerHoverMouseStateLike { export interface SpatialSkeletonHoverInfo { readonly nodeId: number; readonly segmentId?: number; + // Model-space position of the picked node, carried so the highlight overlay can + // still be placed when the node's skeleton is not currently loaded/cached. + readonly position?: Float32Array; } interface SpatialSkeletonViewerHoverLayerLike { @@ -192,7 +195,17 @@ function getSpatialSkeletonHoverInfoFromViewerHover( if (nodeId === undefined) return undefined; const segmentId = pickedSpatialSkeleton?.segmentId; if (segmentId === undefined) return undefined; - return segmentId === undefined ? { nodeId } : { nodeId, segmentId }; + return { nodeId, segmentId, position: pickedSpatialSkeleton?.position }; +} + +function positionsEqual( + a: Float32Array | undefined, + b: Float32Array | undefined, +) { + if (a === b) return true; + if (a === undefined || b === undefined || a.length !== b.length) return false; + for (let i = 0; i < a.length; ++i) if (a[i] !== b[i]) return false; + return true; } function spatialSkeletonHoverInfoEqual( @@ -201,7 +214,11 @@ function spatialSkeletonHoverInfoEqual( ) { if (a === b) return true; if (a === undefined || b === undefined) return false; - return a.nodeId === b.nodeId && a.segmentId === b.segmentId; + return ( + a.nodeId === b.nodeId && + a.segmentId === b.segmentId && + positionsEqual(a.position, b.position) + ); } export class SpatialSkeletonHoverState extends RefCounted { diff --git a/src/panel_overlay.css b/src/panel_overlay.css new file mode 100644 index 0000000000..e8d7184c30 --- /dev/null +++ b/src/panel_overlay.css @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2024 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. + */ + +/* Per-panel overlay container: covers the panel, never intercepts pointer + events, and clips overlays to the panel bounds. */ +.neuroglancer-panel-overlay-container { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; + overflow: hidden; +} + +/* Per-source sub-container within a panel; z-index is set from the source's + overlayPriority. */ +.neuroglancer-panel-overlay-source { + position: absolute; + inset: 0; + pointer-events: none; +} diff --git a/src/panel_overlay.ts b/src/panel_overlay.ts new file mode 100644 index 0000000000..b4891677c8 --- /dev/null +++ b/src/panel_overlay.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2024 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. + */ + +/** + * DOM overlays positioned by projecting world-space positions to screen (e.g. + * the picking indicator and skeleton node highlights), updated on a coalesced, + * redraw-free pass independent of the WebGL render loop. + * + * The contract contains no neuroglancer-internal types, so render layers, + * built-ins, and external code implement it identically. Positions passed to + * `PanelOverlayContext.project` are in the global coordinate space. + */ + +import "#src/panel_overlay.css"; + +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import { RefCounted } from "#src/util/disposable.js"; +import type { NullarySignal } from "#src/util/signal.js"; + +export interface PanelOverlayContext { + /** + * Projects a global-coordinate position to this panel's logical CSS pixels, or + * returns `undefined` if it is off-screen / behind the camera / culled by the + * cross-section slab. `scale` (default 1) conveys depth (perspective view); + * `opacity` (default 1) is the cross-section fade in slice views (1 on the + * slice plane, falling to 0 at the slab edge). + */ + project( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; + + /** + * The source's container for this panel; the source reconciles its children. + * Created and removed by the panel. + */ + readonly container: HTMLElement; + + /** + * CSS pixels per render-viewport device pixel, for sizing overlays specified in + * device pixels. + */ + readonly cssPerDevicePixel: number; + + /** + * The panel's type tags (e.g. `"perspective"`, `"cross-section"`), so a source + * can adapt its rendering to the panel it is drawing in. + */ + readonly panelTypes: readonly string[]; +} + +export interface PanelOverlaySource { + /** Higher draws on top of lower. Default 0. (Picking indicator uses 100.) */ + readonly overlayPriority?: number; + + /** Dispatch to reposition the overlay on the next frame without a GL redraw. */ + readonly overlayUpdateNeeded: NullarySignal; + + /** + * Optional runtime show/hide. When present and `false`, the panel hides this + * source's container and skips its update; changes trigger a coalesced pass. + */ + readonly overlayVisible?: WatchableValueInterface; + + /** Cheap, DOM-only update for one panel. Must not touch the GL canvas. */ + updatePanelOverlays(ctx: PanelOverlayContext): void; +} + +export function isPanelOverlaySource(x: unknown): x is PanelOverlaySource { + return ( + typeof (x as Partial | null | undefined) + ?.updatePanelOverlays === "function" + ); +} + +/** + * Restricts a globally-registered source to a subset of panels. When + * `panelTypes` is omitted the source is shown on every data panel; otherwise it + * is shown on a panel iff one of its {@link PanelOverlayHost.panelTypes} tags is + * listed. (An empty `panelTypes` therefore matches no panel.) + */ +export interface PanelOverlayTarget { + readonly panelTypes?: readonly string[]; +} + +function panelMatchesTarget( + target: PanelOverlayTarget, + panelTypes: readonly string[], +): boolean { + const { panelTypes: wanted } = target; + return ( + wanted === undefined || wanted.some((type) => panelTypes.includes(type)) + ); +} + +/** The panel capabilities required by {@link PanelOverlayManager}. */ +export interface PanelOverlayHost { + readonly element: HTMLElement; + readonly visible: boolean; + readonly cssPerDevicePixel: number; + readonly panelTypes: readonly string[]; + project( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; +} + +/** + * Owns a panel's overlay DOM and drives its updates. Holds a per-panel + * container with one child per bound {@link PanelOverlaySource} (z-index from + * `overlayPriority`), binds the viewer-level sources registered on the + * DisplayContext, and repositions every source on `update()`. + */ +export class PanelOverlayManager extends RefCounted { + private readonly container = document.createElement("div"); + private readonly bindings = new Map(); + private readonly globalOwners = new Map(); + + constructor( + private readonly host: PanelOverlayHost, + // Viewer-level sources (with their optional panel-type target) applied to this + // panel when the target matches, and the signal fired when the map changes. + private readonly globalSources: ReadonlyMap< + PanelOverlaySource, + PanelOverlayTarget + >, + globalSourcesChanged: NullarySignal, + // Requests a coalesced, redraw-free overlay pass. + private readonly requestUpdate: () => void, + ) { + super(); + this.container.className = "neuroglancer-panel-overlay-container"; + host.element.appendChild(this.container); + this.registerDisposer(() => this.container.remove()); + this.registerDisposer( + globalSourcesChanged.add(() => this.syncGlobalSources()), + ); + this.registerDisposer(() => { + for (const owner of this.globalOwners.values()) owner.dispose(); + this.globalOwners.clear(); + }); + this.syncGlobalSources(); + } + + /** + * Binds `source`. `owner` scopes the binding's lifetime; the source's + * sub-container is removed when `owner` is disposed. + */ + bindSource(source: PanelOverlaySource, owner: RefCounted) { + const subContainer = document.createElement("div"); + subContainer.className = "neuroglancer-panel-overlay-source"; + subContainer.style.zIndex = `${source.overlayPriority ?? 0}`; + this.container.appendChild(subContainer); + this.bindings.set(source, subContainer); + owner.registerDisposer(() => { + subContainer.remove(); + this.bindings.delete(source); + this.requestUpdate(); + }); + owner.registerDisposer(source.overlayUpdateNeeded.add(this.requestUpdate)); + const { overlayVisible } = source; + if (overlayVisible !== undefined) { + owner.registerDisposer(overlayVisible.changed.add(this.requestUpdate)); + } + this.requestUpdate(); + } + + private syncGlobalSources() { + const { globalSources, globalOwners, host } = this; + for (const [source, owner] of globalOwners) { + const target = globalSources.get(source); + if ( + target === undefined || + !panelMatchesTarget(target, host.panelTypes) + ) { + owner.dispose(); + globalOwners.delete(source); + } + } + for (const [source, target] of globalSources) { + if ( + !globalOwners.has(source) && + panelMatchesTarget(target, host.panelTypes) + ) { + const owner = new RefCounted(); + globalOwners.set(source, owner); + this.bindSource(source, owner); + } + } + } + + /** Repositions every bound source. DOM only; does not touch the GL canvas. */ + update() { + const { host } = this; + if (!host.visible) return; + const { cssPerDevicePixel, panelTypes } = host; + const project = (p: Float32Array) => host.project(p); + for (const [source, container] of this.bindings) { + if (source.overlayVisible?.value === false) { + if (container.style.display !== "none") + container.style.display = "none"; + continue; + } + if (container.style.display === "none") container.style.display = ""; + source.updatePanelOverlays({ + project, + container, + cssPerDevicePixel, + panelTypes, + }); + } + } + + /** + * Hides every bound source's container without touching the GL canvas. Used + * when the panel failed to draw (so it rendered nothing this frame) to avoid + * leaving stale overlays over cleared canvas content. {@link update} + * restores visibility on the next successful frame. + */ + clear() { + for (const container of this.bindings.values()) { + if (container.style.display !== "none") container.style.display = "none"; + } + } +} diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index 5a5bb070bb..11d073d5c2 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -580,13 +580,6 @@ export class PerspectivePanel extends RenderedDataPanel { this.registerDisposer( viewer.showAxisLines.changed.add(() => this.scheduleRedraw()), ); - this.registerDisposer( - viewer.mouseState.changed.add(() => { - // The picking indicator is a DOM overlay; moving the cursor only - // repositions it and never triggers a canvas redraw. - this.updatePickingIndicator(); - }), - ); this.registerDisposer( viewer.crossSectionBackgroundColor.changed.add(() => this.scheduleRedraw(), @@ -1518,27 +1511,27 @@ export class PerspectivePanel extends RenderedDataPanel { ); } - protected computePickingIndicatorPosition() { - const { mouseState } = this.viewer; - if (!mouseState.active) return undefined; + readonly overlayPanelTypes = ["perspective"]; + + protected projectGlobalPosition(position: Float32Array) { const { viewProjectionMat, logicalWidth, logicalHeight, displayDimensionRenderInfo: { displayDimensionIndices }, } = this.projectionParameters.value; - // mouseState.position is in global voxel space; extract display-space components. + // `position` is in global voxel space; extract display-space components. const px = displayDimensionIndices[0] >= 0 - ? mouseState.position[displayDimensionIndices[0]] + ? position[displayDimensionIndices[0]] : 0; const py = displayDimensionIndices[1] >= 0 - ? mouseState.position[displayDimensionIndices[1]] + ? position[displayDimensionIndices[1]] : 0; const pz = displayDimensionIndices[2] >= 0 - ? mouseState.position[displayDimensionIndices[2]] + ? position[displayDimensionIndices[2]] : 0; const displayPos = tempVec3; displayPos[0] = px; diff --git a/src/picking_indicator_overlay.css b/src/picking_indicator_overlay.css new file mode 100644 index 0000000000..fa33b8e764 --- /dev/null +++ b/src/picking_indicator_overlay.css @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2024 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. + */ + +/* Ring drawn at the cursor's picked position. White, bordered by black on both + sides for contrast on any background. Size, opacity and position are set per + frame. */ +.neuroglancer-picking-indicator { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.92); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.92), + inset 0 0 0 1px rgba(0, 0, 0, 0.92); + pointer-events: none; + will-change: transform; +} diff --git a/src/picking_indicator_overlay.ts b/src/picking_indicator_overlay.ts new file mode 100644 index 0000000000..eb718f5f79 --- /dev/null +++ b/src/picking_indicator_overlay.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2024 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. + */ + +import "#src/picking_indicator_overlay.css"; + +import type { MouseSelectionState } from "#src/layer/index.js"; +import type { + PanelOverlayContext, + PanelOverlaySource, +} from "#src/panel_overlay.js"; +import type { NullarySignal } from "#src/util/signal.js"; + +// Base ring diameter in CSS pixels; scaled by the projection's depth `scale`. +const PICKING_INDICATOR_DIAMETER = 14; + +function createRing(): HTMLElement { + const element = document.createElement("div"); + element.className = "neuroglancer-picking-indicator"; + return element; +} + +/** A ring drawn at the cursor's picked position, driven by the mouse state. */ +export class PickingIndicatorOverlay implements PanelOverlaySource { + readonly overlayPriority = 100; + + constructor(private readonly mouseState: MouseSelectionState) {} + + get overlayUpdateNeeded(): NullarySignal { + return this.mouseState.changed; + } + + updatePanelOverlays(ctx: PanelOverlayContext): void { + const { container } = ctx; + const { mouseState } = this; + const pos = mouseState.active + ? ctx.project(mouseState.position) + : undefined; + let element = container.firstElementChild as HTMLElement | null; + if (pos === undefined) { + if (element !== null) element.style.display = "none"; + return; + } + if (element === null) { + element = createRing(); + container.appendChild(element); + } + const size = PICKING_INDICATOR_DIAMETER * (pos.scale ?? 1); + const { style } = element; + style.display = ""; + style.width = `${size}px`; + style.height = `${size}px`; + style.opacity = `${pos.opacity ?? 1}`; + style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + } +} diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index a64cdf11fd..e531acb309 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -24,6 +24,8 @@ import { RenderedPanel } from "#src/display_context.js"; import { hasSpatialSkeletonNodeSelection } from "#src/layer/segmentation/selection.js"; import type { NavigationState } from "#src/navigation_state.js"; import { PickIDManager } from "#src/object_picking.js"; +import type { PanelOverlaySource } from "#src/panel_overlay.js"; +import { PanelOverlayManager } from "#src/panel_overlay.js"; import { displayToLayerCoordinates, layerToDisplayCoordinates, @@ -36,7 +38,7 @@ import type { SpatialSkeletonSourceState } from "#src/skeleton/api.js"; import { StatusMessage } from "#src/status.js"; import type { TrackableValue } from "#src/trackable_value.js"; import { AutomaticallyFocusedElement } from "#src/util/automatic_focus.js"; -import type { Borrowed } from "#src/util/disposable.js"; +import type { Borrowed, RefCounted } from "#src/util/disposable.js"; import type { ActionEvent, EventActionMap, @@ -117,10 +119,6 @@ export class PickRequest { const pickRequestInterval = 30; -// Base diameter (CSS px) of the picking-indicator ring. In the perspective view -// this is scaled by depth (see `computePickingIndicatorPosition`). -const PICKING_INDICATOR_DIAMETER = 14; - export abstract class RenderedDataPanel extends RenderedPanel { /** * Current mouse position within the viewport, or -1 if the mouse is not in the viewport. @@ -366,11 +364,13 @@ export abstract class RenderedDataPanel extends RenderedPanel { newPickingData.pickIDs.clear(); if (!this.drawWithPicking(newPickingData)) { newPickingData.frameNumber = -1; + // The panel rendered nothing this frame; drop its overlays so stale + // markers don't linger over the cleared canvas region. + this.clearOverlays(); return; } - // Keep the picking-indicator overlay in sync with the newly-rendered view - // (e.g. after a camera move the picked position projects to a new location). - this.updatePickingIndicator(); + // Reposition overlays for the new view. + this.updateOverlays(); // For the new frame, allow new pick requests regardless of interval since last request. this.nextPickRequestTime = 0; if (this.mouseX >= 0) { @@ -381,64 +381,36 @@ export abstract class RenderedDataPanel extends RenderedPanel { abstract drawWithPicking(pickingData: FramePickingData): boolean; /** - * DOM element overlaying this panel that shows the picking indicator (a ring - * at the cursor's picked position). It is a plain DOM element rather than a - * GL draw so that moving the cursor only repositions it and never triggers a - * canvas redraw. + * Projects a global-coordinate position to this panel's logical CSS pixels, or + * returns `undefined` if it is off-screen / behind the camera / culled by the + * cross-section slab. `scale` (default 1) conveys depth (perspective); + * `opacity` (default 1) is the cross-section fade in slice views. Implemented + * per panel using its own projection. */ - private pickingIndicatorElement = this.createPickingIndicatorElement(); - - private createPickingIndicatorElement(): HTMLElement { - const el = document.createElement("div"); - el.className = "neuroglancer-picking-indicator"; - // A white ring bordered by black on both sides for contrast on any - // background. Size and position are set in `updatePickingIndicator` (the - // size may vary with depth), so only the appearance is set here. - const s = el.style; - s.position = "absolute"; - s.left = "0"; - s.top = "0"; - s.boxSizing = "border-box"; - s.borderRadius = "50%"; - s.border = "2px solid rgba(255, 255, 255, 0.92)"; - s.boxShadow = - "0 0 0 1px rgba(0, 0, 0, 0.92), inset 0 0 0 1px rgba(0, 0, 0, 0.92)"; - s.pointerEvents = "none"; - s.zIndex = "10"; - s.willChange = "transform"; - s.display = "none"; - this.element.appendChild(el); - return el; - } + protected abstract projectGlobalPosition( + position: Float32Array, + ): { x: number; y: number; scale?: number; opacity?: number } | undefined; /** - * Returns the picking indicator center in this panel's logical CSS pixels, or - * `undefined` if the indicator should be hidden. `scale` (default 1) scales - * the ring diameter to convey depth; panels without a depth cue omit it. - * Implemented per panel using its own projection. + * Type tags used to target overlays (see {@link PanelOverlayTarget}), e.g. + * `["perspective"]` or `["cross-section"]`. */ - protected abstract computePickingIndicatorPosition(): - | { x: number; y: number; scale?: number } - | undefined; + abstract readonly overlayPanelTypes: readonly string[]; - /** - * Repositions/resizes (or hides) the picking-indicator overlay for the current - * mouse selection. Cheap DOM-only update; does not touch the canvas. - */ - updatePickingIndicator() { - const el = this.pickingIndicatorElement; - const pos = this.visible - ? this.computePickingIndicatorPosition() - : undefined; - if (pos === undefined) { - if (el.style.display !== "none") el.style.display = "none"; - return; - } - const size = PICKING_INDICATOR_DIAMETER * (pos.scale ?? 1); - el.style.display = ""; - el.style.width = `${size}px`; - el.style.height = `${size}px`; - el.style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + private overlays: PanelOverlayManager; + + // Called by the visible-layer tracker to bind a layer's overlay source; `owner` + // is the per-(layer,panel) attachment. + bindOverlaySource(source: PanelOverlaySource, owner: RefCounted) { + this.overlays.bindSource(source, owner); + } + + override updateOverlays() { + this.overlays.update(); + } + + clearOverlays() { + this.overlays.clear(); } private nextPickRequestTime = 0; @@ -528,6 +500,29 @@ export abstract class RenderedDataPanel extends RenderedPanel { super(context, element, viewer.visibility); this.inputEventMap = viewer.inputEventMap; + const self = this; + this.overlays = this.registerDisposer( + new PanelOverlayManager( + { + element, + get visible() { + return self.visible; + }, + get cssPerDevicePixel() { + const { width, logicalWidth } = self.renderViewport; + return width > 0 ? logicalWidth / width : 1; + }, + get panelTypes() { + return self.overlayPanelTypes; + }, + project: (p) => self.projectGlobalPosition(p), + }, + context.panelOverlays, + context.panelOverlaysChanged, + () => this.scheduleOverlayUpdate(), + ), + ); + element.classList.add("neuroglancer-rendered-data-panel"); element.classList.add("neuroglancer-panel"); element.classList.add("neuroglancer-noselect"); diff --git a/src/skeleton/frontend.css b/src/skeleton/frontend.css new file mode 100644 index 0000000000..bf18ff0892 --- /dev/null +++ b/src/skeleton/frontend.css @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2016 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. + */ + +/* Ring outlining a selected/hovered skeleton node. A pure border around the + node with a single 1px contrast halo on the outside, so it never covers the + node point. Size, border width/color and the halo color are set per marker; + the halo color adapts to the ring color's luminance (white for dark rings, + black for light ones). */ +.neuroglancer-skeleton-node-highlight { + position: absolute; + left: 0; + top: 0; + box-sizing: border-box; + border-radius: 50%; + border-style: solid; + box-shadow: 0 0 0 1px var(--ng-node-highlight-outline, rgba(0, 0, 0, 0.75)); + pointer-events: none; + will-change: transform; +} diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index aef38d5e53..c95a1a34d8 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import "#src/skeleton/frontend.css"; + import { ChunkState, LayerChunkProgressInfo } from "#src/chunk_manager/base.js"; import type { ChunkManager } from "#src/chunk_manager/frontend.js"; import { @@ -30,6 +32,10 @@ import type { PickState, VisibleLayerInfo, } from "#src/layer/index.js"; +import type { + PanelOverlayContext, + PanelOverlaySource, +} from "#src/panel_overlay.js"; import type { PerspectivePanel } from "#src/perspective_view/panel.js"; import type { PerspectiveViewReadyRenderContext, @@ -56,6 +62,8 @@ import { forEachVisibleSegment, getVisibleSegments, getObjectKey, + onTemporaryVisibleSegmentsStateChanged, + onVisibleSegmentsStateChanged, } from "#src/segmentation_display_state/base.js"; import type { SegmentationDisplayState3D } from "#src/segmentation_display_state/frontend.js"; import { @@ -119,6 +127,7 @@ import { import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; import { + getRelativeLuminance, getSaturation, pickHighestContrastColor, saturateColor, @@ -196,11 +205,22 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; -// If use values like 8.0, need to ensure JS keeps the decimal place for GLSL -const ACTIVE_NODE_BORDER_MIN_WIDTH = 3.5; -const ACTIVE_NODE_BORDER_MAX_WIDTH = 8.5; -const ACTIVE_NODE_BORDER_DIAMETER_FRACTION = 0.5; -const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; +const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); + +// Converts a linear 0..1 RGB triple to a CSS `rgb(...)` string for DOM markers. +function vec3ToCssColor(color: vec3): string { + return `rgb(${Math.round(color[0] * 255)}, ${Math.round( + color[1] * 255, + )}, ${Math.round(color[2] * 255)})`; +} +const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "3.5"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "8.0"; +const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "3.0"; +const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "7.0"; +// Fraction of the node diameter used as the highlight outline width before +// clamping to the min/max above. Nodes are small (~5-6px), so this mostly hits +// the min for typical nodes and scales up the ring for larger nodes. +const SELECTED_NODE_OUTLINE_DIAMETER_FRACTION = "0.5"; // Saturation adjustment factor and threshold for the highlighted (hovered) node border: each // moves the segment's color away from (>1) or towards (<1) the perceptual-grey @@ -212,10 +232,7 @@ const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; const HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR = 0.5; const HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD = 0.5; -const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; -const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "0.0, 0.0, 0.0"; -const ACTIVE_NODE_BORDER_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -// Muted colors for the selected (pinned) node +// Muted colors for the selected (pinned) node -- less vibrant. const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ vec3.fromValues(0.1, 0.1, 0.1), // near-black vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) @@ -309,7 +326,6 @@ class RenderHelper extends RefCounted { private vertexIdHelper; private segmentAttributeIndex: number | undefined; private segmentColorAttributeIndex: number | undefined; - private nodeIdAttributeIndex: number | undefined; private visibleSegmentsShaderManager = new HashSetShaderManager( "visibleSegments", ); @@ -392,7 +408,7 @@ void spatialChunkCull() { ); } for (let i = 1; i < numAttributes; ++i) { - if (i === this.segmentAttributeIndex || i === this.nodeIdAttributeIndex) { + if (i === this.segmentAttributeIndex) { continue; } const info = vertexAttributes[i]; @@ -619,11 +635,6 @@ vec4 getSegmentAppearance(highp uint segmentValue) { this.segmentAttributeIndex = segmentAttrIndex >= 0 ? segmentAttrIndex : undefined; this.segmentColorAttributeIndex = base.segmentColorAttributeIndex; - const nodeIdAttrIndex = this.vertexAttributes.findIndex( - (x) => x.name === nodeIdAttribute.name, - ); - this.nodeIdAttributeIndex = - nodeIdAttrIndex >= 0 ? nodeIdAttrIndex : undefined; const segmentationGroupState = base.displayState.segmentationGroupState.value; @@ -784,24 +795,6 @@ void emitDefault() { /*crossSectionFade=*/ this.targetIsSliceView, ); builder.addUniform("highp float", "uNodeDiameter"); - let selectedOutlineWidthExpression = "0.0"; - let borderOutlineWidthExpression = "0.0"; - if (this.nodeIdAttributeIndex !== undefined) { - builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); - builder.addUniform("highp int", "uSelectedNodeId"); - builder.addVarying("highp float", "vSelectedNode", "flat"); - builder.addUniform("highp vec3", "uHighlightedNodeOutlineColor"); - builder.addUniform("highp int", "uHighlightedNodeId"); - builder.addVarying("highp float", "vHighlightedNode", "flat"); - selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_BORDER_DIAMETER_FRACTION} * uNodeDiameter, ${ACTIVE_NODE_BORDER_MIN_WIDTH}, ${ACTIVE_NODE_BORDER_MAX_WIDTH}))`; - const borderOutlineMinWidth = - ACTIVE_NODE_BORDER_MIN_WIDTH * - ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; - const borderOutlineMaxWidth = - ACTIVE_NODE_BORDER_MAX_WIDTH * - ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; - borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; - } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); highp uint pickOffset = vertexIndex * uPickInstanceStride; @@ -811,10 +804,6 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); if (skeletonParams.spatialChunkCulling) { vertexMain += `vCullPos = vertexPosition;\n`; } - if (this.nodeIdAttributeIndex !== undefined) { - vertexMain += `vSelectedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uSelectedNodeId);\n`; - vertexMain += `vHighlightedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uHighlightedNodeId);\n`; - } if ( skeletonParams.dynamicSegmentAppearance && this.segmentAttributeIndex !== undefined @@ -822,12 +811,7 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); vertexMain += `vSegmentValue = toRaw(readAttribute${this.segmentAttributeIndex}(vertexIndex));\n`; } vertexMain += ` -emitCircle( - uProjection * vec4(vertexPosition, 1.0), - uNodeDiameter, - ${selectedOutlineWidthExpression}, - ${borderOutlineWidthExpression} -); +emitCircle(uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, 0.0); `; const segmentColorExpression = this.getSegmentColorExpression(); if ( @@ -836,17 +820,9 @@ emitCircle( ) { // Dynamic path (spatial skeletons): per-segment color, visibility, // saturation and hover highlight all resolved in the shader via - // getSegmentAppearance(). uColor is unused in this path. + // getSegmentAppearance(). uColor is unused in this path. Selected and + // hovered node highlights are drawn as DOM overlays, not in-shader. const segmentExpression = `vSegmentValue`; - const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; - // Apply the selected outline first, then the hovered outline, so the - // hovered color wins when a node is both selected and hovered. - const borderColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` - : "renderColor"; - const borderOutlineColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` - : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return getSegmentAppearance(${segmentExpression}); @@ -856,9 +832,7 @@ void emitRGBA(vec4 color) { highp float alpha = color.a * baseColor.a; if (alpha <= 0.0) discard; vec4 renderColor = vec4(color.rgb, alpha); - vec4 borderColor = ${borderColorExpression}; - vec4 borderOutlineColor = ${borderOutlineColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); + vec4 circleColor = getCircleColor(renderColor, renderColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -890,24 +864,13 @@ void emitDefault() { } else { // Per-vertex color attribute path: color comes from a per-vertex // attribute; alpha is taken from the attribute's alpha component. - const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; - // Apply the selected outline first, then the hovered outline, so the - // hovered color wins when a node is both selected and hovered. - const borderColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` - : "renderColor"; - const borderOutlineColorExpression = hasNodeIdSelection - ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` - : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return ${segmentColorExpression}; } void emitRGBA(vec4 color) { vec4 renderColor = color; - vec4 borderColor = ${borderColorExpression}; - vec4 borderOutlineColor = ${borderOutlineColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); + vec4 circleColor = getCircleColor(renderColor, renderColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -1174,6 +1137,82 @@ function getSkeletonNodeDiameter( return lineWidth; } +// A selected/hovered node highlight to draw as a DOM ring overlay. `diameter` +// and `borderWidth` are in render-viewport device px (matching the node's +// on-screen size); the panel converts them to CSS px via `cssPerDevicePixel`. +interface HighlightMarker { + position: Float32Array; // global coordinate space + kind: "selected" | "hovered"; + color: string; // CSS ring color, derived from the node's segment color + outlineColor: string; // CSS halo color, contrasting with `color` + diameter: number; + borderWidth: number; +} + +// Reconciles the ring child elements of an overlay source's per-panel container +// to `markers`, projecting each via the panel context. Reuses/pools children. +function updateSkeletonHighlightOverlay( + markers: HighlightMarker[], + ctx: PanelOverlayContext, +) { + const { container, cssPerDevicePixel } = ctx; + let count = 0; + for (const marker of markers) { + const pos = ctx.project(marker.position); + if (pos === undefined) continue; + let element = container.children[count] as HTMLElement | undefined; + if (element === undefined) { + element = document.createElement("div"); + element.className = "neuroglancer-skeleton-node-highlight"; + container.appendChild(element); + } + ++count; + const size = marker.diameter * cssPerDevicePixel; + const { style } = element; + style.display = ""; + style.width = `${size}px`; + style.height = `${size}px`; + style.borderWidth = `${Math.max(1, marker.borderWidth * cssPerDevicePixel)}px`; + style.borderColor = marker.color; + style.setProperty("--ng-node-highlight-outline", marker.outlineColor); + style.opacity = `${pos.opacity ?? 1}`; + style.transform = `translate(${pos.x - size / 2}px, ${pos.y - size / 2}px)`; + } + const { children } = container; + for (let i = count; i < children.length; ++i) { + (children[i] as HTMLElement).style.display = "none"; + } +} + +// On-screen size (render-viewport device px) of a node's selection ring, +// matching the old in-shader outline: a band of `borderWidth` sitting just +// outside the node, so the outer `diameter` = nodeDiameter + 2 * outline. +function getSkeletonNodeHighlightRing( + renderMode: SkeletonRenderMode, + lineWidth: number, + targetIsSliceView: boolean, +): { diameter: number; borderWidth: number } { + const nodeDiameter = getSkeletonNodeDiameter(renderMode, lineWidth); + const minWidth = Number( + targetIsSliceView + ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D + : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D, + ); + const maxWidth = Number( + targetIsSliceView + ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D + : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D, + ); + const outline = Math.min( + maxWidth, + Math.max( + minWidth, + Number(SELECTED_NODE_OUTLINE_DIAMETER_FRACTION) * nodeDiameter, + ), + ); + return { diameter: nodeDiameter + 2 * outline, borderWidth: outline }; +} + function setMouseStatePositionFromSpatialSkeletonNode( mouseState: MouseSelectionState, nodePosition: Float32Array, @@ -1600,14 +1639,6 @@ const segmentAttribute: VertexAttributeRenderInfo = { glslDataType: getShaderType(DataType.UINT32, 1), }; -const nodeIdAttribute: VertexAttributeRenderInfo = { - dataType: DataType.INT32, - numComponents: 1, - name: "nodeId", - webglDataType: WebGL2RenderingContext.INT, - glslDataType: getShaderType(DataType.INT32, 1), -}; - interface SkeletonChunkBase extends SkeletonGPUGeometry { vertexAttributes: Uint8Array; vertexAttributeOffsets: Uint32Array; @@ -1723,31 +1754,6 @@ export class SpatiallyIndexedSkeletonChunk copyToGPU(gl: GL) { super.copyToGPU(gl); uploadSkeletonChunkToGPU(gl, this); - // Upload nodeIds as the 3rd vertex attribute texture (index 2). - // vertexAttributeOffsets only covers position (0) and segment (1), so we - // handle nodeId separately here since it is stored outside the packed buffer. - const nodeIdFormat = this.source.attributeTextureFormats[2]; - if ( - nodeIdFormat !== undefined && - this.nodeIds.length === this.numVertices && - this.numVertices > 0 - ) { - const texture = gl.createTexture(); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); - setOneDimensionalTextureData( - gl, - nodeIdFormat, - new Uint8Array( - this.nodeIds.buffer, - this.nodeIds.byteOffset, - this.nodeIds.byteLength, - ), - ); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); - this.vertexAttributeTextures[2] = texture; - } else { - this.vertexAttributeTextures[2] = null; - } } freeGPUMemory(gl: GL) { @@ -1764,7 +1770,6 @@ type SpatiallyIndexedSkeletonChunkListener = ( const spatiallyIndexedSkeletonTextureAttributeSpecs = Object.freeze([ { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, - { name: "nodeId", dataType: DataType.INT32, numComponents: 1 }, ]); export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< @@ -1777,11 +1782,7 @@ export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< constructor(chunkManager: ChunkManager, options: any) { super(chunkManager, options); - this.vertexAttributes = [ - vertexPositionAttribute, - segmentAttribute, - nodeIdAttribute, - ]; + this.vertexAttributes = [vertexPositionAttribute, segmentAttribute]; } get attributeTextureFormats() { @@ -1871,6 +1872,7 @@ type SpatiallyIndexedSkeletonSourceEntry = interface SelectedSkeletonNodeInfo { readonly nodeId: number; readonly segmentId?: number; + readonly position?: Float32Array; } interface SpatiallyIndexedSkeletonLayerOptions { @@ -1887,6 +1889,11 @@ interface SpatiallyIndexedSkeletonLayerOptions { pendingNodePositionVersion?: WatchableValueInterface; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; + // Transforms a node's model-space position into the global coordinate space + // used by the panels, so node highlights can be projected to screen. + resolveGlobalPosition?: ( + modelPosition: ArrayLike, + ) => Float32Array | undefined; inspectionState?: SpatiallyIndexedSkeletonInspectionState; maxRetainedOverlaySegments?: number; } @@ -1930,11 +1937,6 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { geometry.segmentIds.byteOffset, geometry.segmentIds.byteLength, ), - new Uint8Array( - geometry.nodeIds.buffer, - geometry.nodeIds.byteOffset, - geometry.nodeIds.byteLength, - ), ]; const overlayTextures: (WebGLTexture | null)[] = (this.vertexAttributeTextures = []); @@ -2089,7 +2091,6 @@ export class SpatiallyIndexedSkeletonLayer redrawNeeded = new NullarySignal(); vertexAttributes: VertexAttributeRenderInfo[]; segmentColorAttributeIndex: number | undefined; - nodeIdAttributeIndex: number | undefined; readonly browsePassLayerView: SkeletonShaderContext; readonly skeletonShaderParameters: WatchableValue; readonly browsePassSkeletonShaderParameters: WatchableValueInterface; @@ -2128,6 +2129,12 @@ export class SpatiallyIndexedSkeletonLayer private getCachedNodeInfo: | ((nodeId: number) => SpatiallyIndexedSkeletonNode | undefined) | undefined; + private resolveGlobalPosition: + | ((modelPosition: ArrayLike) => Float32Array | undefined) + | undefined; + // Fires when the set of highlighted nodes (selected/hovered) changes, so panels + // can reposition their DOM node-highlight markers without a full canvas redraw. + readonly highlightMarkersChanged = new NullarySignal(); private inspectionState: SpatiallyIndexedSkeletonInspectionState | undefined; private overlayChunk: SkeletonOverlayChunk | undefined; private overlayGeometryKey: string | undefined; @@ -2217,7 +2224,14 @@ export class SpatiallyIndexedSkeletonLayer return getBaseObjectColor(this.displayState, segmentId); } - private updateNodeOutlineColors() { + // Updates `selectedNodeOutlineColor` and `highlightedNodeOutlineColor` in + // place. Each outline is chosen, independently of the other, for high contrast + // against its own node's segment color: the selected node uses the muted + // palette, and the hovered node uses its own segment color pushed away from + // (or, if already very saturated, towards) grey. Because the two are computed + // independently, a given segment color always yields the same selected color + // and the same hovered color. + private updateNodeOutlineColorPair() { const currentGeneration = this.nodeOutlineColorGeneration; if (this.cachedNodeOutlineColorGeneration === currentGeneration) { return; @@ -2380,8 +2394,8 @@ export class SpatiallyIndexedSkeletonLayer if (this.overlayChunk !== undefined) { if (this.overlayGeometryKey === overlayGeometryKey) { - // Geometry unchanged — selection is driven by uSelectedNodeId uniform - // at draw time, so no GPU rebuild is needed when selection changes. + // Geometry unchanged — selection/hover highlights are DOM overlays, so no + // GPU rebuild is needed when selection changes. return this.overlayChunk; } } @@ -2469,6 +2483,7 @@ export class SpatiallyIndexedSkeletonLayer this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; this.getCachedNodeInfo = options.getCachedNode; + this.resolveGlobalPosition = options.resolveGlobalPosition; this.inspectionState = options.inspectionState; this.maxRetainedOverlaySegments = Math.max( 1, @@ -2568,36 +2583,43 @@ export class SpatiallyIndexedSkeletonLayer displayState: this.displayState, skeletonShaderParameters: this.browsePassSkeletonShaderParameters, }; - const nodeIdIndex = this.vertexAttributes.findIndex( - (x) => x.name === nodeIdAttribute.name, - ); - this.nodeIdAttributeIndex = nodeIdIndex >= 0 ? nodeIdIndex : undefined; const requestRedraw = () => this.redrawNeeded.dispatch(); + // Node highlights are DOM overlays, so a selected/hovered change repositions + // the markers without a canvas redraw. if (this.selectedNodeInfo?.changed) { this.registerDisposer( this.selectedNodeInfo.changed.add(() => { + // Recompute the marker's contrast color for the new node. invalidateNodeOutlineColors(); - requestRedraw(); + this.highlightMarkersChanged.dispatch(); }), ); } if (this.suppressSelectedNodeHighlight?.changed) { this.registerDisposer( - this.suppressSelectedNodeHighlight.changed.add(requestRedraw), + // The selected-node ring is a DOM overlay, so toggling suppression must + // refresh the markers (not just request a canvas redraw). + this.suppressSelectedNodeHighlight.changed.add(() => { + this.highlightMarkersChanged.dispatch(); + }), ); } if (this.hoveredNodeInfo?.changed) { this.registerDisposer( this.hoveredNodeInfo.changed.add(() => { invalidateNodeOutlineColors(); - requestRedraw(); + this.highlightMarkersChanged.dispatch(); }), ); } const pendingNodePositionVersion = options.pendingNodePositionVersion; if (pendingNodePositionVersion?.changed) { this.registerDisposer( - pendingNodePositionVersion.changed.add(requestRedraw), + pendingNodePositionVersion.changed.add(() => { + // A node's position moved: redraw geometry and reposition markers. + requestRedraw(); + this.highlightMarkersChanged.dispatch(); + }), ); } const inspectionState = this.inspectionState; @@ -2606,9 +2628,37 @@ export class SpatiallyIndexedSkeletonLayer inspectionState.nodeDataVersion.changed.add(() => { invalidateNodeOutlineColors(); this.redrawNeeded.dispatch(); + // A highlighted node's cached position may now be available. + this.highlightMarkersChanged.dispatch(); }), ); } + // A marker is emitted only when its node's skeleton would be drawn (see + // computeHighlightMarkers), so its visibility depends on the object alphas + // and the visible-segment set. Refresh the overlay when any of those change. + const refreshHighlightVisibility = () => { + this.highlightMarkersChanged.dispatch(); + }; + this.registerDisposer( + this.displayState.objectAlpha.changed.add(refreshHighlightVisibility), + ); + this.registerDisposer( + this.displayState.hiddenObjectAlpha.changed.add( + refreshHighlightVisibility, + ), + ); + const segmentationGroupState = + this.displayState.segmentationGroupState.value; + onVisibleSegmentsStateChanged( + this, + segmentationGroupState, + refreshHighlightVisibility, + ); + onTemporaryVisibleSegmentsStateChanged( + this, + segmentationGroupState, + refreshHighlightVisibility, + ); // Create backend for perspective view chunk management const sharedObject = this.registerDisposer( new ChunkRenderLayerFrontend(this.layerChunkProgressInfo), @@ -2684,6 +2734,95 @@ export class SpatiallyIndexedSkeletonLayer }; } + /** + * Builds highlight markers for the selected/hovered nodes. `diameter` and + * `borderWidth` are the node's on-screen ring size (device px) for the calling + * view, so the marker matches the node's size — the old in-shader outline sat + * just outside the node with the same thickness. Positions are resolved from + * the stored info (model space) or the node cache, then transformed to global + * space; entries whose position is unavailable are omitted. + */ + computeHighlightMarkers( + diameter: number, + borderWidth: number, + ): HighlightMarker[] { + const { resolveGlobalPosition } = this; + if (resolveGlobalPosition === undefined) return []; + // Refresh the per-node contrast colors (selected uses the muted palette, + // hovered uses its saturated segment color) so markers match the previous + // in-shader outline colors. + this.updateNodeOutlineColorPair(); + // Mirror the shader's per-segment visibility so a ring is never drawn over a + // skeleton that isn't rendered: a segment draws at `objectAlpha` when it is + // visible/selected and at `hiddenObjectAlpha` otherwise. + const visibleSegments = getVisibleSegments( + this.displayState.segmentationGroupState.value, + ); + const objectAlpha = this.displayState.objectAlpha.value; + const hiddenObjectAlpha = this.displayState.hiddenObjectAlpha.value; + const markers: HighlightMarker[] = []; + const add = ( + info: SelectedSkeletonNodeInfo | undefined, + kind: HighlightMarker["kind"], + color: vec3, + ) => { + const nodeId = info?.nodeId; + if (nodeId === undefined) return; + const segmentId = info?.segmentId; + if (segmentId !== undefined) { + const effectiveAlpha = visibleSegments.has(BigInt(segmentId)) + ? objectAlpha + : hiddenObjectAlpha; + if (effectiveAlpha <= 0) return; + } else if (objectAlpha <= 0 && hiddenObjectAlpha <= 0) { + // Unknown segment: fall back to the whole-layer invisibility test. + return; + } + // Prefer the live cached position (which applies any pending move) so the + // marker stays in sync when the node moves; fall back to the position + // captured at selection time if the node isn't currently cached. + const modelPosition = + this.getCachedNodeSnapshot(nodeId)?.position ?? info?.position; + if (modelPosition === undefined) return; + const global = resolveGlobalPosition(modelPosition); + if (global === undefined) return; + // Halo contrasts with the ring color: white around a dark ring, black + // around a light one (WCAG black/white crossover luminance ~0.179). + const outlineColor = + getRelativeLuminance(color) < 0.179 + ? "rgba(255, 255, 255, 0.85)" + : "rgba(0, 0, 0, 0.75)"; + markers.push({ + position: global, + kind, + color: vec3ToCssColor(color), + outlineColor, + diameter, + borderWidth, + }); + }; + const selectedNodeId = this.suppressSelectedNodeHighlight?.value + ? undefined + : this.selectedNodeInfo?.value?.nodeId; + const hoveredNodeId = this.hoveredNodeInfo?.value?.nodeId; + // When the same node is both selected and hovered, show only the hovered + // marker (as the old shader did — hovered won over selected), avoiding an + // overlapping ring. + if (selectedNodeId !== undefined && selectedNodeId !== hoveredNodeId) { + add( + this.selectedNodeInfo?.value, + "selected", + this.selectedNodeOutlineColor, + ); + } + add( + this.hoveredNodeInfo?.value, + "hovered", + this.highlightedNodeOutlineColor, + ); + return markers; + } + invalidateSourceCellsForPositions( positions: Iterable | undefined>, ) { @@ -3042,23 +3181,6 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColors(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.selectedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.getHighlightedSelectedNodeId(), - ); - gl.uniform3fv( - nodeShader.uniform("uHighlightedNodeOutlineColor"), - this.highlightedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uHighlightedNodeId"), - this.hoveredNodeInfo?.value?.nodeId ?? -1, - ); const chunkOrigin = vec3.create(); const chunkBound = vec3.create(); @@ -3178,23 +3300,6 @@ export class SpatiallyIndexedSkeletonLayer const { gl, edgeShader, nodeShader, skeletonParams } = passState; nodeShader.bind(); - this.updateNodeOutlineColors(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.selectedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.getHighlightedSelectedNodeId(), - ); - gl.uniform3fv( - nodeShader.uniform("uHighlightedNodeOutlineColor"), - this.highlightedNodeOutlineColor, - ); - gl.uniform1i( - nodeShader.uniform("uHighlightedNodeId"), - this.hoveredNodeInfo?.value?.nodeId ?? -1, - ); if (renderContext.emitPickID) { const edgePickId = @@ -3461,7 +3566,10 @@ function attachSpatiallyIndexedSkeletonLayer( ); } -export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveViewRenderLayer { +export class PerspectiveViewSpatiallyIndexedSkeletonLayer + extends PerspectiveViewRenderLayer + implements PanelOverlaySource +{ private renderHelper: RenderHelper; private browseRenderHelper: RenderHelper; private renderOptions: ViewSpecificSkeletonRenderingOptions; @@ -3495,6 +3603,23 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie this.registerDisposer(histogram3d.visibility.add(this.visibility)); } + readonly overlayPriority = 0; + get overlayUpdateNeeded() { + return this.base.highlightMarkersChanged; + } + updatePanelOverlays(ctx: PanelOverlayContext) { + const { renderOptions } = this; + const ring = getSkeletonNodeHighlightRing( + renderOptions.mode.value, + renderOptions.lineWidth.value, + /*targetIsSliceView=*/ false, + ); + updateSkeletonHighlightOverlay( + this.base.computeHighlightMarkers(ring.diameter, ring.borderWidth), + ctx, + ); + } + attach( attachment: VisibleLayerInfo< PerspectivePanel, @@ -3636,7 +3761,10 @@ export class PerspectiveViewSpatiallyIndexedSkeletonLayer extends PerspectiveVie } } -export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelRenderLayer { +export class SliceViewPanelSpatiallyIndexedSkeletonLayer + extends SliceViewPanelRenderLayer + implements PanelOverlaySource +{ private renderHelper: RenderHelper; private browseRenderHelper: RenderHelper; private renderOptions: ViewSpecificSkeletonRenderingOptions; @@ -3673,6 +3801,23 @@ export class SliceViewPanelSpatiallyIndexedSkeletonLayer extends SliceViewPanelR return this.base.gl; } + readonly overlayPriority = 0; + get overlayUpdateNeeded() { + return this.base.highlightMarkersChanged; + } + updatePanelOverlays(ctx: PanelOverlayContext) { + const { renderOptions } = this; + const ring = getSkeletonNodeHighlightRing( + renderOptions.mode.value, + renderOptions.lineWidth.value, + /*targetIsSliceView=*/ true, + ); + updateSkeletonHighlightOverlay( + this.base.computeHighlightMarkers(ring.diameter, ring.borderWidth), + ctx, + ); + } + getValueAt(_position: Float32Array) { return undefined; } diff --git a/src/sliceview/panel.ts b/src/sliceview/panel.ts index b6e5ac1a18..7782a73b75 100644 --- a/src/sliceview/panel.ts +++ b/src/sliceview/panel.ts @@ -276,13 +276,6 @@ export class SliceViewPanel extends RenderedDataPanel { } }), ); - this.registerDisposer( - viewer.mouseState.changed.add(() => { - // The picking indicator is a DOM overlay; moving the cursor only - // repositions it and never triggers a canvas redraw. - this.updatePickingIndicator(); - }), - ); } translateByViewportPixels(deltaX: number, deltaY: number): void { @@ -539,9 +532,9 @@ export class SliceViewPanel extends RenderedDataPanel { setStateFromRelative(pickRadius, pickRadius, 0); } - protected computePickingIndicatorPosition() { - const { mouseState } = this.viewer; - if (!mouseState.active) return undefined; + readonly overlayPanelTypes = ["cross-section"]; + + protected projectGlobalPosition(position: Float32Array) { const { viewProjectionMat, logicalWidth, @@ -551,21 +544,25 @@ export class SliceViewPanel extends RenderedDataPanel { const displayPos = tempVec3; displayPos[0] = displayDimensionIndices[0] >= 0 - ? mouseState.position[displayDimensionIndices[0]] + ? position[displayDimensionIndices[0]] : 0; displayPos[1] = displayDimensionIndices[1] >= 0 - ? mouseState.position[displayDimensionIndices[1]] + ? position[displayDimensionIndices[1]] : 0; displayPos[2] = displayDimensionIndices[2] >= 0 - ? mouseState.position[displayDimensionIndices[2]] + ? position[displayDimensionIndices[2]] : 0; vec3.transformMat4(displayPos, displayPos, viewProjectionMat); - if (displayPos[2] < -1 || displayPos[2] > 1) return undefined; + const ndcZ = displayPos[2]; + if (ndcZ < -1 || ndcZ > 1) return undefined; return { x: (displayPos[0] * 0.5 + 0.5) * logicalWidth, y: (1 - (displayPos[1] * 0.5 + 0.5)) * logicalHeight, + // Cross-section fade: match the node's on-screen alpha (1 on the slice + // plane, → 0 at the slab edge). See getCircleAlphaMultiplier in circles.ts. + opacity: 1 - Math.abs(ndcZ), }; } diff --git a/src/viewer.ts b/src/viewer.ts index 0a8a2e283a..1dc73d2b38 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -70,6 +70,7 @@ import { WatchableDisplayDimensionRenderInfo, } from "#src/navigation_state.js"; import { overlaysOpen } from "#src/overlay.js"; +import { PickingIndicatorOverlay } from "#src/picking_indicator_overlay.js"; import { ScreenshotHandler } from "#src/python_integration/screenshots.js"; import { allRenderLayerRoles, RenderLayerRole } from "#src/renderlayer.js"; import { @@ -543,6 +544,12 @@ export class Viewer extends RefCounted implements ViewerState { options: Partial = {}, ) { super(); + // Show the picking indicator on every data panel. + this.registerDisposer( + display.registerPanelOverlay( + new PickingIndicatorOverlay(this.mouseState), + ), + ); this.screenshotHandler = this.registerDisposer(new ScreenshotHandler(this)); this.screenshotManager = this.registerDisposer(new ScreenshotManager(this)); const { diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts new file mode 100644 index 0000000000..b1da32cad9 --- /dev/null +++ b/src/widget/accordion.ts @@ -0,0 +1,308 @@ +/** + * @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. + */ + +import { TrackableBoolean } from "#src/trackable_boolean.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; +import svg_chevron_down from "#src/ui/images/chevron_down.svg?raw"; +import { RefCounted } from "#src/util/disposable.js"; +import { NullarySignal } from "#src/util/signal.js"; +import "#src/widget/accordion.css"; +import { Tab } from "#src/widget/tab_view.js"; + +declare let NEUROGLANCER_USE_ACCORDIONS: boolean | undefined; +declare let NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: boolean | undefined; + +export interface AccordionOptions { + accordionJsonKey: string; + sections: AccordionSectionOptions[]; +} + +interface AccordionSectionOptions { + jsonKey: string; + displayName: string; + defaultExpanded?: boolean; + isDefaultKey?: boolean; +} + +interface AccordionSection { + name: string; + jsonKey: string; + container: HTMLElement; + header: HTMLElement; + body: HTMLElement; + chevron: HTMLElement; +} + +function getGlobalAccordionDefaultExpanded(): boolean { + return typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" + ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED + : false; +} + +function getGlobalUseAccordions(): boolean { + return typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; +} + +export class AccordionSectionState extends RefCounted { + isExpanded: WatchableValueInterface; + + constructor( + public jsonKey: string, + private defaultExpanded: boolean, + onChangeCallback: () => void, + ) { + super(); + this.isExpanded = new TrackableBoolean(defaultExpanded, defaultExpanded); + this.registerDisposer(this.isExpanded.changed.add(onChangeCallback)); + } + + toJSON() { + if (this.isExpanded.value === this.defaultExpanded) return undefined; + return { [this.jsonKey]: this.isExpanded.value }; + } +} + +export class AccordionState extends RefCounted { + sectionStates: AccordionSectionState[] = []; + specificationChanged = new NullarySignal(); + + constructor(public accordionOptions: AccordionOptions) { + super(); + for (const sectionOptions of accordionOptions.sections) { + this.getOrCreateSectionState(sectionOptions); + } + } + + getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { + const { jsonKey, defaultExpanded } = sectionOptions; + let sectionState = this.getSectionState(jsonKey); + if (sectionState === undefined) { + sectionState = this.registerDisposer( + new AccordionSectionState( + jsonKey, + defaultExpanded ?? getGlobalAccordionDefaultExpanded(), + this.specificationChanged.dispatch, + ), + ); + this.sectionStates.push(sectionState); + } + return sectionState; + } + + getSectionState(jsonKey: string): AccordionSectionState | undefined { + return this.sectionStates.find((s) => s.jsonKey === jsonKey); + } + + setSectionExpanded(jsonKey: string, expand?: boolean): void { + const section = this.getSectionState(jsonKey); + if (section !== undefined) { + section.isExpanded.value = expand ?? !section.isExpanded.value; + } + } + + restoreState(obj: unknown) { + if (obj === undefined || obj === null || typeof obj !== "object") { + return; + } + for (const [jsonKey, isExpanded] of Object.entries(obj)) { + if (typeof isExpanded !== "boolean") continue; + this.setSectionExpanded(jsonKey, isExpanded); + } + } + + toJSON() { + const sectionsData = this.sectionStates + .map((section) => section.toJSON()) + .filter((data) => data !== undefined); + + return sectionsData.length === 0 + ? undefined + : Object.assign({}, ...sectionsData); + } +} + +export class AccordionTab extends Tab { + sections: AccordionSection[] = []; + defaultKey: string | undefined; + + constructor(protected accordionState: AccordionState) { + super(); + const options = accordionState.accordionOptions; + this.element.classList.add("neuroglancer-accordion"); + this.registerDisposer( + this.accordionState.specificationChanged.add(() => + this.updateSectionsExpanded(), + ), + ); + options.sections.forEach((option) => { + this.createAccordionSection(option); + }); + if (this.defaultKey === undefined && options.sections.length > 0) { + this.defaultKey = options.sections[0].jsonKey; + } + this.updateSectionsExpanded(); + if (!getGlobalUseAccordions()) { + this.setAccordionHeadersHidden(true); + } + } + + private setSectionExpanded(jsonKey: string, expand?: boolean): void { + this.accordionState.setSectionExpanded(jsonKey, expand); + } + + private updateSectionsExpanded() { + const accordionsDisabled = !getGlobalUseAccordions(); + this.accordionState.sectionStates.forEach((state) => { + const section = this.getSectionByKey(state.jsonKey); + if (section === undefined) return; + const { container, header, chevron } = section; + const expand = accordionsDisabled || state.isExpanded.value; + container.dataset.expanded = String(expand); + header.setAttribute("aria-expanded", String(expand)); + chevron.title = expand + ? "Collapse accordion section" + : "Expand accordion section"; + }); + } + + private createAccordionSection( + option: AccordionSectionOptions, + ): AccordionSection | undefined { + const newSection: AccordionSection = { + name: option.displayName, + jsonKey: option.jsonKey, + container: document.createElement("div"), + header: document.createElement("div"), + body: document.createElement("div"), + chevron: document.createElement("span"), + }; + this.sections.push(newSection); + const { container, header, body, chevron } = newSection; + container.classList.add("neuroglancer-accordion-item"); + body.classList.add("neuroglancer-accordion-body"); + header.classList.add("neuroglancer-accordion-header"); + container.appendChild(newSection.header); + container.appendChild(newSection.body); + this.element.appendChild(container); + + chevron.classList.add("neuroglancer-accordion-chevron"); + chevron.classList.add("neuroglancer-icon"); + chevron.innerHTML = svg_chevron_down; + const headerText = document.createElement("span"); + headerText.classList.add("neuroglancer-accordion-header-text"); + headerText.textContent = option.displayName; + header.appendChild(headerText); + header.appendChild(chevron); + + container.dataset.expanded = String(option.defaultExpanded ?? false); + // Adding a child element automatically sets the hidden attribute to false + // so this hides empty sections + container.dataset.hidden = "true"; + + if (option.isDefaultKey) { + this.defaultKey = option.jsonKey; + } + + this.registerEventListener(newSection.header, "click", () => + this.setSectionExpanded(option.jsonKey), + ); + + const useAccordions = + typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" + ? NEUROGLANCER_USE_ACCORDIONS + : true; + if (!useAccordions) { + container.classList.add("neuroglancer-accordion-no-border"); + } + + // Usually, the state is pre-propulated with all the relevant sections. + // However, because appendChild is public and can be called with + // a jsonKey that is not in the initial accordionOptions, we need to + // add the section into the state if that happens + // This state wouldn't get properly restored if that occurs, + // but in case there is some unforeseen section added, at least + // the controls to expand/collapse it will still work because of this + this.accordionState.getOrCreateSectionState(option); + return newSection; + } + + private getSectionByKey( + jsonKey: string | undefined, + ): AccordionSection | undefined { + return this.sections.find((e) => e.jsonKey === jsonKey); + } + + private getSectionWithFallback(jsonKey?: string): AccordionSection { + const section = + this.getSectionByKey(jsonKey ?? this.defaultKey) ?? + this.getSectionByKey(this.defaultKey); + if (section === undefined) { + throw new Error( + `Accordion section with key "${jsonKey ?? this.defaultKey}" not found.`, + ); + } + return section; + } + + // Usually adding a child automatically shows the section + // but skipShow can be used to avoid this behaviour + appendChild( + content: HTMLElement, + jsonKey?: string, + skipShow?: boolean, + ): void { + const section = this.getSectionWithFallback(jsonKey); + section.body.appendChild(content); + if (!skipShow) { + this.showSection(section.jsonKey); + } + } + + /** + * Set the visibility of the section with the given jsonKey. + * This is different to expanding/collapsing the section. + */ + setSectionHidden(jsonKey: string, hidden: boolean): void { + const section = this.getSectionByKey(jsonKey); + if (section !== undefined) { + section.container.dataset.hidden = hidden ? "true" : "false"; + } + } + + /** + * Show the section with the given jsonKey. + * This is different to expanding the section, it is only about visibility. + */ + showSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, false); + } + + /** + * Hide the section with the given jsonKey. + * This is different to collapsing the section, it is only about visibility. + */ + hideSection(jsonKey: string): void { + this.setSectionHidden(jsonKey, true); + } + + setAccordionHeadersHidden(hidden: boolean): void { + this.sections.forEach((section) => { + section.header.style.display = hidden ? "none" : ""; + }); + } +} From abd84d34fecd865c4d6de85af6d948be1d632afa Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 10 Jul 2026 15:36:58 +0200 Subject: [PATCH 14/17] Merge of MetaCell/feat/improve-skeleton-ui feat: improve ui for skeleton tab --- src/ui/skeleton_tab.css | 13 ++-- src/ui/skeleton_tab.ts | 150 +++++++++++++++++++++++++++++----------- 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/src/ui/skeleton_tab.css b/src/ui/skeleton_tab.css index 5503b3e738..a05017ae1c 100644 --- a/src/ui/skeleton_tab.css +++ b/src/ui/skeleton_tab.css @@ -81,7 +81,6 @@ } .neuroglancer-skeleton-section { - --neuroglancer-skeleton-actions-width: 44px; --neuroglancer-skeleton-type-width: 21px; border: 1px solid #2f2f2f; display: flex; @@ -182,7 +181,6 @@ .neuroglancer-skeleton-tree-row { display: grid; grid-template-columns: - var(--neuroglancer-skeleton-actions-width) var(--neuroglancer-skeleton-type-width) minmax(58px, 104px) minmax(0, 1fr); @@ -207,10 +205,6 @@ display: block; } -.neuroglancer-skeleton-list-header-actions { - width: var(--neuroglancer-skeleton-actions-width); -} - .neuroglancer-skeleton-list-header-type { width: var(--neuroglancer-skeleton-type-width); } @@ -225,6 +219,7 @@ } .neuroglancer-skeleton-tree-row { + position: relative; min-height: 1.6em; color: #d2d2d2; font-family: monospace; @@ -342,13 +337,15 @@ } .neuroglancer-skeleton-node-actions { + position: absolute; + right: 4px; + top: 0; + bottom: 0; display: inline-flex; align-items: center; gap: 0.1em; - width: var(--neuroglancer-skeleton-actions-width); visibility: hidden; pointer-events: none; - justify-content: flex-start; } .neuroglancer-skeleton-tree-entry[data-list-hovered="true"] diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index b63c1c6a7c..8eb3ff53aa 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -374,6 +374,11 @@ export class SpatialSkeletonEditTab extends Tab { let nodeDeletionAllowed = false; let nodeRerootAllowed = false; let pendingScrollToSelectedNode = false; + const MAX_SCROLL_RETRY_FRAMES = 6; + const SCROLL_IN_VIEW_EPSILON = 1; + let scrollRetryHandle: number | undefined; + let scrollRetriesRemaining = 0; + let scrollRetryNodeId: number | undefined; let loadedNodeSummarySuffix = ""; let hoveredViewerNodeId: number | undefined; let hoveredListNodeId: number | undefined; @@ -600,14 +605,111 @@ export class SpatialSkeletonEditTab extends Tab { } }; - const scrollListItemIntoView = (index: number) => { - if (nodesList.getItemElement(index) !== undefined) { - nodesList.scrollItemIntoView(index); + const cancelScrollRetry = () => { + if (scrollRetryHandle !== undefined) { + cancelAnimationFrame(scrollRetryHandle); + scrollRetryHandle = undefined; + } + }; + + // True when the row is fully visible below the sticky header (or is simply + // taller than the available viewport, in which case aligning its top is the + // best we can do). + const isRowFullyInView = (element: HTMLElement) => { + const listRect = nodesList.element.getBoundingClientRect(); + const viewportTop = listRect.top + nodesList.header.offsetHeight; + const viewportBottom = listRect.bottom; + const rowRect = element.getBoundingClientRect(); + const topVisible = rowRect.top >= viewportTop - SCROLL_IN_VIEW_EPSILON; + const bottomVisible = + rowRect.bottom <= viewportBottom + SCROLL_IN_VIEW_EPSILON; + const tallerThanViewport = rowRect.height > viewportBottom - viewportTop; + return topVisible && (bottomVisible || tallerThanViewport); + }; + + // Reveal the currently selected node's row in the virtual list. The virtual + // list renders asynchronously (animation-frame debounced) and positions + // unrendered rows using size *estimates*, so a single synchronous attempt is + // unreliable. We keep `pendingScrollToSelectedNode` set until the target row + // is genuinely rendered and fully in view, correcting the scroll position + // against the real measured geometry across a bounded number of frames. + const attemptScrollToSelectedNode = () => { + scrollRetryHandle = undefined; + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) { + pendingScrollToSelectedNode = false; + return; + } + // A newer selection superseded this loop. + if (selectedNodeId !== scrollRetryNodeId) return; + + const index = listIndexByNodeId.get(selectedNodeId); + if (index === undefined) { + // The node is not in the current list yet (async load, or a different + // segment). Leave the pending flag set without scheduling a frame or + // consuming the retry budget; `updateList` re-triggers this once the + // list is rebuilt with the node present. + return; + } + + const renderedElement = nodesList.getItemElement(index); + if (renderedElement !== undefined && isRowFullyInView(renderedElement)) { + pendingScrollToSelectedNode = false; + return; + } + if (scrollRetriesRemaining <= 0) { + // Found and rendered but still won't fit after several corrections; stop + // retrying so `updateList` doesn't loop forever. + pendingScrollToSelectedNode = false; return; } + scrollRetriesRemaining--; + + const headerHeight = nodesList.header.offsetHeight; nodesList.state.anchorIndex = index; - nodesList.state.anchorClientOffset = 0; + if (renderedElement === undefined) { + // Not rendered: anchor its top just below the sticky header and let the + // next frame render + measure it. + nodesList.state.anchorClientOffset = headerHeight; + } else { + // Rendered but out of view: correct using the real measured rect. + const listRect = nodesList.element.getBoundingClientRect(); + const rowRect = renderedElement.getBoundingClientRect(); + const relTop = rowRect.top - listRect.top; + if (relTop < headerHeight) { + nodesList.state.anchorClientOffset = headerHeight; + } else { + nodesList.state.anchorClientOffset = listRect.height - rowRect.height; + } + } + // Drives VirtualList's own debouncedUpdateView; its rAF is registered + // before ours below, so it runs first and our next attempt measures the + // freshly rendered row. virtualListRenderChanged.dispatch(); + scrollRetryHandle = requestAnimationFrame(attemptScrollToSelectedNode); + }; + + const scrollSelectedNodeIntoView = () => { + const selectedNodeId = + layer.selectedSpatialSkeletonNodeInfo.value?.nodeId; + if (selectedNodeId === undefined) { + pendingScrollToSelectedNode = false; + cancelScrollRetry(); + return; + } + // A loop is already converging on this node; let it continue rather than + // restarting (and resetting) it on every list rebuild / hover update. + if ( + scrollRetryHandle !== undefined && + scrollRetryNodeId === selectedNodeId + ) { + return; + } + cancelScrollRetry(); + scrollRetryNodeId = selectedNodeId; + scrollRetriesRemaining = MAX_SCROLL_RETRY_FRAMES; + attemptScrollToSelectedNode(); }; const applyRowInteractionState = ( @@ -627,14 +729,7 @@ export class SpatialSkeletonEditTab extends Tab { entry.dataset.listHovered = String(isListHovered); }); if (options.scrollSelectedIntoView) { - pendingScrollToSelectedNode = false; - const selectedIndex = - selectedNodeId === undefined - ? undefined - : listIndexByNodeId.get(selectedNodeId); - if (selectedIndex !== undefined) { - scrollListItemIntoView(selectedIndex); - } + scrollSelectedNodeIntoView(); } }; @@ -1179,9 +1274,6 @@ export class SpatialSkeletonEditTab extends Tab { const makeListHeader = () => { const listHeader = document.createElement("div"); listHeader.className = "neuroglancer-skeleton-list-header"; - const headerActionsSpacer = document.createElement("span"); - headerActionsSpacer.className = - "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-actions"; const headerTypeSpacer = document.createElement("span"); headerTypeSpacer.className = "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-type"; @@ -1197,7 +1289,6 @@ export class SpatialSkeletonEditTab extends Tab { dimSpan.textContent = dimLabel; headerCoordinates.appendChild(dimSpan); } - listHeader.appendChild(headerActionsSpacer); listHeader.appendChild(headerTypeSpacer); listHeader.appendChild(headerId); listHeader.appendChild(headerCoordinates); @@ -1218,9 +1309,6 @@ export class SpatialSkeletonEditTab extends Tab { const segmentRow = document.createElement("div"); segmentRow.className = "neuroglancer-skeleton-tree-row neuroglancer-skeleton-segment-row"; - const segmentActionsSpacer = document.createElement("span"); - segmentActionsSpacer.className = - "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-actions"; const segmentTypeSpacer = document.createElement("span"); segmentTypeSpacer.className = "neuroglancer-skeleton-list-header-spacer neuroglancer-skeleton-list-header-type"; @@ -1249,7 +1337,6 @@ export class SpatialSkeletonEditTab extends Tab { segmentMetaLine.appendChild(segmentName); segmentMetaLine.appendChild(segmentRatio); segmentMeta.appendChild(segmentMetaLine); - segmentRow.appendChild(segmentActionsSpacer); segmentRow.appendChild(segmentTypeSpacer); segmentRow.appendChild(segmentIdCell); segmentRow.appendChild(segmentMeta); @@ -1414,26 +1501,6 @@ export class SpatialSkeletonEditTab extends Tab { const actions = document.createElement("div"); actions.className = "neuroglancer-skeleton-node-actions"; - let rerootActionTitle = - 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"; - } - actions.appendChild( - makeRowActionButton( - svg_origin, - rerootActionTitle, - () => rerootNode(node), - !nodeRerootAllowed || - pendingRerootNodes.has(node.nodeId) || - node.parentNodeId === undefined || - nodeIsTrueEnd, - ), - ); let deleteActionTitle = "Delete node"; if (pendingDeleteNodes.has(node.nodeId)) { deleteActionTitle = "Deleting node"; @@ -1447,10 +1514,10 @@ export class SpatialSkeletonEditTab extends Tab { ), ); - row.appendChild(actions); row.appendChild(typeIcon); row.appendChild(idCell); row.appendChild(coordinatesCell); + row.appendChild(actions); entry.appendChild(row); return entry; }; @@ -1806,6 +1873,7 @@ export class SpatialSkeletonEditTab extends Tab { ); }, layer.displayState.segmentationColorGroupState), ); + this.registerDisposer(() => cancelScrollRetry()); this.registerDisposer( layer.selectedSpatialSkeletonNodeInfo.changed.add(() => { pendingScrollToSelectedNode = true; From 4bb33377cc82f39921c6881dc2a276002124119e Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 13 Aug 2026 15:54:11 +0200 Subject: [PATCH 15/17] feat: only use cursors in a mode --- rspack.config.ts | 2 +- src/rendered_data_panel.css | 4 +--- .../images/{metacell => }/add_node_cursor.svg | 0 .../images/{metacell => }/crosshair_cursor.svg | 0 src/ui/images/{metacell => }/delete_cursor.svg | 0 src/ui/images/{metacell => }/drag_cursor.svg | 0 src/ui/images/{metacell => }/merge_cursor.svg | 0 .../{metacell => }/new_skeleton_cursor.svg | 0 src/ui/images/{metacell => }/split_cursor.svg | 0 src/ui/skeleton_edit_tools.css | 17 +++++++++++------ src/ui/skeleton_edit_tools.ts | 2 +- 11 files changed, 14 insertions(+), 11 deletions(-) rename src/ui/images/{metacell => }/add_node_cursor.svg (100%) rename src/ui/images/{metacell => }/crosshair_cursor.svg (100%) rename src/ui/images/{metacell => }/delete_cursor.svg (100%) rename src/ui/images/{metacell => }/drag_cursor.svg (100%) rename src/ui/images/{metacell => }/merge_cursor.svg (100%) rename src/ui/images/{metacell => }/new_skeleton_cursor.svg (100%) rename src/ui/images/{metacell => }/split_cursor.svg (100%) diff --git a/rspack.config.ts b/rspack.config.ts index e881c94d91..555c8e672e 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -60,7 +60,7 @@ export default defineConfig((env, args) => { // emits a separate fetched file regardless of size, which caused the // custom cursor to only appear starting from the second activation. { - test: /src[\\/]ui[\\/]images[\\/]metacell[\\/](add_node_cursor|merge_cursor|new_skeleton_cursor|split_cursor|delete_cursor|drag_cursor)\.svg$/, + test: /src[\\/]ui[\\/]images[\\/](.*_cursor)\.svg$/, type: "asset/inline", }, // Needed for .html assets used for auth redirect pages for the diff --git a/src/rendered_data_panel.css b/src/rendered_data_panel.css index 73afdeb48f..626df55c14 100644 --- a/src/rendered_data_panel.css +++ b/src/rendered_data_panel.css @@ -15,9 +15,7 @@ */ .neuroglancer-rendered-data-panel { - cursor: - url("./ui/images/metacell/crosshair_cursor.svg") 20 20, - crosshair; + cursor: crosshair; position: relative; outline: 0; touch-action: none; diff --git a/src/ui/images/metacell/add_node_cursor.svg b/src/ui/images/add_node_cursor.svg similarity index 100% rename from src/ui/images/metacell/add_node_cursor.svg rename to src/ui/images/add_node_cursor.svg diff --git a/src/ui/images/metacell/crosshair_cursor.svg b/src/ui/images/crosshair_cursor.svg similarity index 100% rename from src/ui/images/metacell/crosshair_cursor.svg rename to src/ui/images/crosshair_cursor.svg diff --git a/src/ui/images/metacell/delete_cursor.svg b/src/ui/images/delete_cursor.svg similarity index 100% rename from src/ui/images/metacell/delete_cursor.svg rename to src/ui/images/delete_cursor.svg diff --git a/src/ui/images/metacell/drag_cursor.svg b/src/ui/images/drag_cursor.svg similarity index 100% rename from src/ui/images/metacell/drag_cursor.svg rename to src/ui/images/drag_cursor.svg diff --git a/src/ui/images/metacell/merge_cursor.svg b/src/ui/images/merge_cursor.svg similarity index 100% rename from src/ui/images/metacell/merge_cursor.svg rename to src/ui/images/merge_cursor.svg diff --git a/src/ui/images/metacell/new_skeleton_cursor.svg b/src/ui/images/new_skeleton_cursor.svg similarity index 100% rename from src/ui/images/metacell/new_skeleton_cursor.svg rename to src/ui/images/new_skeleton_cursor.svg diff --git a/src/ui/images/metacell/split_cursor.svg b/src/ui/images/split_cursor.svg similarity index 100% rename from src/ui/images/metacell/split_cursor.svg rename to src/ui/images/split_cursor.svg diff --git a/src/ui/skeleton_edit_tools.css b/src/ui/skeleton_edit_tools.css index e069d5d9df..a6f15e5ae5 100644 --- a/src/ui/skeleton_edit_tools.css +++ b/src/ui/skeleton_edit_tools.css @@ -59,34 +59,39 @@ } /* Per-mode cursor indicators — driven by data-skeleton-edit-mode on the panel element */ +.neuroglancer-rendered-data-panel[data-skeleton-edit-mode="default"] { + cursor: + url("./images/crosshair_cursor.svg") 20 20, + crosshair; +} .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="add"] { cursor: - url("./images/metacell/add_node_cursor.svg") 20 20, + url("./images/add_node_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="merge"] { cursor: - url("./images/metacell/merge_cursor.svg") 20 20, + url("./images/merge_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="create"] { cursor: - url("./images/metacell/new_skeleton_cursor.svg") 20 20, + url("./images/new_skeleton_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="split"] { cursor: - url("./images/metacell/split_cursor.svg") 20 20, + url("./images/split_cursor.svg") 20 20, crosshair; } .neuroglancer-rendered-data-panel[data-skeleton-edit-mode="delete"] { cursor: - url("./images/metacell/delete_cursor.svg") 20 20, + url("./images/delete_cursor.svg") 20 20, crosshair; } @@ -101,6 +106,6 @@ .neuroglancer-rendered-data-panel[data-skeleton-press-mode="move"] { cursor: - url("./images/metacell/drag_cursor.svg") 20 20, + url("./images/drag_cursor.svg") 20 20, grabbing; } diff --git a/src/ui/skeleton_edit_tools.ts b/src/ui/skeleton_edit_tools.ts index d6a4c1a8e1..fb98733803 100644 --- a/src/ui/skeleton_edit_tools.ts +++ b/src/ui/skeleton_edit_tools.ts @@ -575,7 +575,7 @@ export class SpatialSkeletonEditTool extends SpatialSkeletonToolBase { } else if (this.shiftHeld && !this.ctrlHeld) { this.setModeAttribute("add"); } else { - this.setModeAttribute(undefined); + this.setModeAttribute("default"); } } From 5940920fc29ff3625a0216463f87ffa1b5004dc7 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 13 Aug 2026 15:57:47 +0200 Subject: [PATCH 16/17] fix: correct license and included files --- src/panel_overlay.css | 2 +- src/panel_overlay.ts | 2 +- src/picking_indicator_overlay.css | 2 +- src/picking_indicator_overlay.ts | 2 +- src/skeleton/frontend.css | 2 +- src/widget/accordion.ts | 308 ------------------------------ 6 files changed, 5 insertions(+), 313 deletions(-) delete mode 100644 src/widget/accordion.ts diff --git a/src/panel_overlay.css b/src/panel_overlay.css index e8d7184c30..e83787d330 100644 --- a/src/panel_overlay.css +++ b/src/panel_overlay.css @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * 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 diff --git a/src/panel_overlay.ts b/src/panel_overlay.ts index b4891677c8..15caf5922e 100644 --- a/src/panel_overlay.ts +++ b/src/panel_overlay.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * 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 diff --git a/src/picking_indicator_overlay.css b/src/picking_indicator_overlay.css index fa33b8e764..979f0a20f5 100644 --- a/src/picking_indicator_overlay.css +++ b/src/picking_indicator_overlay.css @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * 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 diff --git a/src/picking_indicator_overlay.ts b/src/picking_indicator_overlay.ts index eb718f5f79..454bf4555c 100644 --- a/src/picking_indicator_overlay.ts +++ b/src/picking_indicator_overlay.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2024 Google Inc. + * 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 diff --git a/src/skeleton/frontend.css b/src/skeleton/frontend.css index bf18ff0892..03b57b14d6 100644 --- a/src/skeleton/frontend.css +++ b/src/skeleton/frontend.css @@ -1,6 +1,6 @@ /** * @license - * Copyright 2016 Google Inc. + * 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 diff --git a/src/widget/accordion.ts b/src/widget/accordion.ts deleted file mode 100644 index b1da32cad9..0000000000 --- a/src/widget/accordion.ts +++ /dev/null @@ -1,308 +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. - */ - -import { TrackableBoolean } from "#src/trackable_boolean.js"; -import type { WatchableValueInterface } from "#src/trackable_value.js"; -import svg_chevron_down from "#src/ui/images/chevron_down.svg?raw"; -import { RefCounted } from "#src/util/disposable.js"; -import { NullarySignal } from "#src/util/signal.js"; -import "#src/widget/accordion.css"; -import { Tab } from "#src/widget/tab_view.js"; - -declare let NEUROGLANCER_USE_ACCORDIONS: boolean | undefined; -declare let NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED: boolean | undefined; - -export interface AccordionOptions { - accordionJsonKey: string; - sections: AccordionSectionOptions[]; -} - -interface AccordionSectionOptions { - jsonKey: string; - displayName: string; - defaultExpanded?: boolean; - isDefaultKey?: boolean; -} - -interface AccordionSection { - name: string; - jsonKey: string; - container: HTMLElement; - header: HTMLElement; - body: HTMLElement; - chevron: HTMLElement; -} - -function getGlobalAccordionDefaultExpanded(): boolean { - return typeof NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED !== "undefined" - ? NEUROGLANCER_ACCORDION_DEFAULT_EXPANDED - : false; -} - -function getGlobalUseAccordions(): boolean { - return typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" - ? NEUROGLANCER_USE_ACCORDIONS - : true; -} - -export class AccordionSectionState extends RefCounted { - isExpanded: WatchableValueInterface; - - constructor( - public jsonKey: string, - private defaultExpanded: boolean, - onChangeCallback: () => void, - ) { - super(); - this.isExpanded = new TrackableBoolean(defaultExpanded, defaultExpanded); - this.registerDisposer(this.isExpanded.changed.add(onChangeCallback)); - } - - toJSON() { - if (this.isExpanded.value === this.defaultExpanded) return undefined; - return { [this.jsonKey]: this.isExpanded.value }; - } -} - -export class AccordionState extends RefCounted { - sectionStates: AccordionSectionState[] = []; - specificationChanged = new NullarySignal(); - - constructor(public accordionOptions: AccordionOptions) { - super(); - for (const sectionOptions of accordionOptions.sections) { - this.getOrCreateSectionState(sectionOptions); - } - } - - getOrCreateSectionState(sectionOptions: AccordionSectionOptions) { - const { jsonKey, defaultExpanded } = sectionOptions; - let sectionState = this.getSectionState(jsonKey); - if (sectionState === undefined) { - sectionState = this.registerDisposer( - new AccordionSectionState( - jsonKey, - defaultExpanded ?? getGlobalAccordionDefaultExpanded(), - this.specificationChanged.dispatch, - ), - ); - this.sectionStates.push(sectionState); - } - return sectionState; - } - - getSectionState(jsonKey: string): AccordionSectionState | undefined { - return this.sectionStates.find((s) => s.jsonKey === jsonKey); - } - - setSectionExpanded(jsonKey: string, expand?: boolean): void { - const section = this.getSectionState(jsonKey); - if (section !== undefined) { - section.isExpanded.value = expand ?? !section.isExpanded.value; - } - } - - restoreState(obj: unknown) { - if (obj === undefined || obj === null || typeof obj !== "object") { - return; - } - for (const [jsonKey, isExpanded] of Object.entries(obj)) { - if (typeof isExpanded !== "boolean") continue; - this.setSectionExpanded(jsonKey, isExpanded); - } - } - - toJSON() { - const sectionsData = this.sectionStates - .map((section) => section.toJSON()) - .filter((data) => data !== undefined); - - return sectionsData.length === 0 - ? undefined - : Object.assign({}, ...sectionsData); - } -} - -export class AccordionTab extends Tab { - sections: AccordionSection[] = []; - defaultKey: string | undefined; - - constructor(protected accordionState: AccordionState) { - super(); - const options = accordionState.accordionOptions; - this.element.classList.add("neuroglancer-accordion"); - this.registerDisposer( - this.accordionState.specificationChanged.add(() => - this.updateSectionsExpanded(), - ), - ); - options.sections.forEach((option) => { - this.createAccordionSection(option); - }); - if (this.defaultKey === undefined && options.sections.length > 0) { - this.defaultKey = options.sections[0].jsonKey; - } - this.updateSectionsExpanded(); - if (!getGlobalUseAccordions()) { - this.setAccordionHeadersHidden(true); - } - } - - private setSectionExpanded(jsonKey: string, expand?: boolean): void { - this.accordionState.setSectionExpanded(jsonKey, expand); - } - - private updateSectionsExpanded() { - const accordionsDisabled = !getGlobalUseAccordions(); - this.accordionState.sectionStates.forEach((state) => { - const section = this.getSectionByKey(state.jsonKey); - if (section === undefined) return; - const { container, header, chevron } = section; - const expand = accordionsDisabled || state.isExpanded.value; - container.dataset.expanded = String(expand); - header.setAttribute("aria-expanded", String(expand)); - chevron.title = expand - ? "Collapse accordion section" - : "Expand accordion section"; - }); - } - - private createAccordionSection( - option: AccordionSectionOptions, - ): AccordionSection | undefined { - const newSection: AccordionSection = { - name: option.displayName, - jsonKey: option.jsonKey, - container: document.createElement("div"), - header: document.createElement("div"), - body: document.createElement("div"), - chevron: document.createElement("span"), - }; - this.sections.push(newSection); - const { container, header, body, chevron } = newSection; - container.classList.add("neuroglancer-accordion-item"); - body.classList.add("neuroglancer-accordion-body"); - header.classList.add("neuroglancer-accordion-header"); - container.appendChild(newSection.header); - container.appendChild(newSection.body); - this.element.appendChild(container); - - chevron.classList.add("neuroglancer-accordion-chevron"); - chevron.classList.add("neuroglancer-icon"); - chevron.innerHTML = svg_chevron_down; - const headerText = document.createElement("span"); - headerText.classList.add("neuroglancer-accordion-header-text"); - headerText.textContent = option.displayName; - header.appendChild(headerText); - header.appendChild(chevron); - - container.dataset.expanded = String(option.defaultExpanded ?? false); - // Adding a child element automatically sets the hidden attribute to false - // so this hides empty sections - container.dataset.hidden = "true"; - - if (option.isDefaultKey) { - this.defaultKey = option.jsonKey; - } - - this.registerEventListener(newSection.header, "click", () => - this.setSectionExpanded(option.jsonKey), - ); - - const useAccordions = - typeof NEUROGLANCER_USE_ACCORDIONS !== "undefined" - ? NEUROGLANCER_USE_ACCORDIONS - : true; - if (!useAccordions) { - container.classList.add("neuroglancer-accordion-no-border"); - } - - // Usually, the state is pre-propulated with all the relevant sections. - // However, because appendChild is public and can be called with - // a jsonKey that is not in the initial accordionOptions, we need to - // add the section into the state if that happens - // This state wouldn't get properly restored if that occurs, - // but in case there is some unforeseen section added, at least - // the controls to expand/collapse it will still work because of this - this.accordionState.getOrCreateSectionState(option); - return newSection; - } - - private getSectionByKey( - jsonKey: string | undefined, - ): AccordionSection | undefined { - return this.sections.find((e) => e.jsonKey === jsonKey); - } - - private getSectionWithFallback(jsonKey?: string): AccordionSection { - const section = - this.getSectionByKey(jsonKey ?? this.defaultKey) ?? - this.getSectionByKey(this.defaultKey); - if (section === undefined) { - throw new Error( - `Accordion section with key "${jsonKey ?? this.defaultKey}" not found.`, - ); - } - return section; - } - - // Usually adding a child automatically shows the section - // but skipShow can be used to avoid this behaviour - appendChild( - content: HTMLElement, - jsonKey?: string, - skipShow?: boolean, - ): void { - const section = this.getSectionWithFallback(jsonKey); - section.body.appendChild(content); - if (!skipShow) { - this.showSection(section.jsonKey); - } - } - - /** - * Set the visibility of the section with the given jsonKey. - * This is different to expanding/collapsing the section. - */ - setSectionHidden(jsonKey: string, hidden: boolean): void { - const section = this.getSectionByKey(jsonKey); - if (section !== undefined) { - section.container.dataset.hidden = hidden ? "true" : "false"; - } - } - - /** - * Show the section with the given jsonKey. - * This is different to expanding the section, it is only about visibility. - */ - showSection(jsonKey: string): void { - this.setSectionHidden(jsonKey, false); - } - - /** - * Hide the section with the given jsonKey. - * This is different to collapsing the section, it is only about visibility. - */ - hideSection(jsonKey: string): void { - this.setSectionHidden(jsonKey, true); - } - - setAccordionHeadersHidden(hidden: boolean): void { - this.sections.forEach((section) => { - section.header.style.display = hidden ? "none" : ""; - }); - } -} From eae0a9fd5aa7f5c7d7faa6d67b0d5561aaa6e6e2 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Thu, 13 Aug 2026 16:00:57 +0200 Subject: [PATCH 17/17] chore: lint and format --- src/skeleton/frontend.ts | 1 - src/ui/skeleton_edit_tool_messages.ts | 7 ++----- src/util/color.browser_test.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index c95a1a34d8..0fcb81959c 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -205,7 +205,6 @@ const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; -const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); // Converts a linear 0..1 RGB triple to a CSS `rgb(...)` string for DOM markers. function vec3ToCssColor(color: vec3): string { diff --git a/src/ui/skeleton_edit_tool_messages.ts b/src/ui/skeleton_edit_tool_messages.ts index 8a10359ea3..83c714698d 100644 --- a/src/ui/skeleton_edit_tool_messages.ts +++ b/src/ui/skeleton_edit_tool_messages.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { isMacPlatform } from "#src/util/platform.js"; - export interface SpatialSkeletonToolPointInfo { nodeId: number; segmentId?: number; @@ -107,9 +105,8 @@ export interface SpatialSkeletonToolStatusText { } export const SPATIAL_SKELETON_EDIT_TOOL_NAME = "Skeleton editing"; -export const SPATIAL_SKELETON_ROTATE_PAN_HINT = `middle-click or ${ - isMacPlatform() ? "cmd" : "ctrl" -}+click to rotate/pan`; +export const SPATIAL_SKELETON_ROTATE_PAN_HINT = + "middle-click or ctrl+click to rotate/pan"; export type SpatialSkeletonDefaultSelectionState = | "none" diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts index 90b60f53fb..4915ddde3a 100644 --- a/src/util/color.browser_test.ts +++ b/src/util/color.browser_test.ts @@ -80,4 +80,4 @@ describe("color", () => { expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); }); -}); \ No newline at end of file +});