diff --git a/src/ui/command_catalog.spec.ts b/src/ui/command_catalog.spec.ts index 19480e561a..6ca0ffbc7e 100644 --- a/src/ui/command_catalog.spec.ts +++ b/src/ui/command_catalog.spec.ts @@ -14,12 +14,13 @@ * limitations under the License. */ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { collectActionBindings, CommandCatalog, type CommandCatalogContext, } from "#src/ui/command_catalog.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; import { EventActionMap } from "#src/util/event_action_map.js"; import { Signal } from "#src/util/signal.js"; import type { InputEventBindings } from "#src/viewer.js"; @@ -42,9 +43,18 @@ function makeInputEventBindings( const noopSignal = { add: () => () => {} }; +// Registries created by makeContext, disposed after each test. +const activeRegistries: CommandRegistry[] = []; + +afterEach(() => { + while (activeRegistries.length > 0) activeRegistries.pop()!.dispose(); +}); + function makeContext( inputEventBindings = makeInputEventBindings(new EventActionMap()), + commandRegistry = new CommandRegistry(), ): CommandCatalogContext { + activeRegistries.push(commandRegistry); return { globalToolBinder: { changed: noopSignal, @@ -59,6 +69,7 @@ function makeContext( }, selectedLayer: {}, inputEventBindings, + commandRegistry, } as unknown as CommandCatalogContext; } @@ -111,10 +122,15 @@ describe("collectActionBindings", () => { }); describe("CommandCatalog.filter", () => { - // With empty bindings the catalog contains only the two supplemental commands: - // "Edit JSON State" and "Screenshot". + // Seed the registry with two commands so the catalog surfaces exactly + // "Edit JSON State" and "Screenshot" as its flat entries. function makeCatalog() { - return new CommandCatalog(makeContext()); + const registry = new CommandRegistry(); + registry.registerAction({ id: "edit-json-state", label: "Edit JSON State" }); + registry.registerAction({ id: "screenshot", label: "Screenshot" }); + return new CommandCatalog( + makeContext(makeInputEventBindings(new EventActionMap()), registry), + ); } it("returns all commands for an empty query", () => { diff --git a/src/ui/command_catalog.ts b/src/ui/command_catalog.ts index aae27d53a1..4c3b64fd6d 100644 --- a/src/ui/command_catalog.ts +++ b/src/ui/command_catalog.ts @@ -16,6 +16,7 @@ import type { LayerManager, SelectedLayerState } from "#src/layer/index.js"; import { UserLayer } from "#src/layer/index.js"; +import type { CommandRegistry } from "#src/ui/command_registry.js"; import { getMatchingTools, restoreTool, @@ -39,16 +40,14 @@ export interface CommandCatalogContext { layerManager: LayerManager; selectedLayer: SelectedLayerState; inputEventBindings: InputEventBindings; + /** + * Authoritative source of the flat command set. Its command-kind entries are + * enumerated directly; the input bindings are consulted only to annotate each + * command with its current shortcut, not to discover which commands exist. + */ + commandRegistry: CommandRegistry; } -const SUPPLEMENTAL_COMMANDS: readonly { - actionId: ActionIdentifier; - label: string; -}[] = [ - { actionId: "edit-json-state", label: "Edit JSON State" }, - { actionId: "screenshot", label: "Screenshot" }, -]; - export interface ActionBinding { readonly actionId: ActionIdentifier; readonly eventAction: EventAction; @@ -57,6 +56,8 @@ export interface ActionBinding { interface CommandPaletteEntryBase { readonly label: string; readonly shortcut: string; + /** Optional grouping section, carried through from a registered command. */ + readonly category?: string; } // Dispatched as an `action:` DOM event, exactly as the keyboard @@ -66,7 +67,16 @@ export interface ActionCommandEntry extends CommandPaletteEntryBase { readonly actionId: ActionIdentifier; } -// Runs a callback directly (no DOM action exists for it). +// A registered command that runs a callback. Unlike `execute`, it carries the +// registry's stable `id` so consumers can correlate it back to the registry. +export interface CommandEntry extends CommandPaletteEntryBase { + readonly kind: "command"; + readonly id: ActionIdentifier; + readonly invoke: () => void; +} + +// Runs an anonymous callback directly (no DOM action and no registry identity — +// e.g. a per-layer toggle or an unbound tool activation). export interface ExecuteCommandEntry extends CommandPaletteEntryBase { readonly kind: "execute"; readonly execute: () => void; @@ -80,6 +90,7 @@ export interface GroupCommandEntry extends CommandPaletteEntryBase { export type CommandPaletteEntry = | ActionCommandEntry + | CommandEntry | ExecuteCommandEntry | GroupCommandEntry; @@ -95,13 +106,6 @@ function formatKeyStroke(stroke: string): string { .join("+"); } -function actionIdToLabel(actionId: ActionIdentifier): string { - return actionId - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - function isKeyboardEvent(normalizedId: NormalizedEventIdentifier): boolean { return ( !normalizedId.includes("mouse") && @@ -270,6 +274,9 @@ export class CommandCatalog extends RefCounted { this.registerDisposer( context.layerManager.layersChanged.add(debouncedRebuild), ); + this.registerDisposer( + context.commandRegistry.changed.add(debouncedRebuild), + ); this.rebuild(); } @@ -279,17 +286,10 @@ export class CommandCatalog extends RefCounted { layerManager, selectedLayer, inputEventBindings, + commandRegistry, } = this.context; const commands: CommandPaletteEntry[] = []; - // "Deactivate Active Tool" is always present — harmless no-op when nothing is active. - commands.push({ - kind: "action", - label: "Deactivate Active Tool", - shortcut: "", - actionId: "deactivate-active-tool", - }); - // Hierarchical layer actions — each group entry opens a sub-palette of layers. // The first 9 layers carry their digit-key shortcuts so users can see they // still work directly from the keyboard without opening the sub-palette. @@ -347,20 +347,42 @@ export class CommandCatalog extends RefCounted { ); } - for (const { actionId, eventAction } of bindings) { - if (/^tool-[A-Z]$/.test(actionId)) continue; - // Layer-index actions are replaced by hierarchical group entries above. - if (/^(toggle|select|toggle-pick)-layer-\d+$/.test(actionId)) continue; - - const label = actionIdToLabel(actionId); - const shortcut = formatKeyStroke( - friendlyEventIdentifier(eventAction.originalEventIdentifier ?? ""), - ); - commands.push({ kind: "action", label, shortcut, actionId }); - } - - for (const { actionId, label } of SUPPLEMENTAL_COMMANDS) { - commands.push({ kind: "action", label, shortcut: "", actionId }); + // Flat commands come from the registry. A command's shortcut is whatever + // binding is currently installed for its id (or its suggested default), + // shown for reference only. + for (const command of commandRegistry.values()) { + if (command.isAvailable !== undefined && !command.isAvailable.value) { + continue; + } + const shortcut = + shortcutByAction.get(command.id) ?? + (command.defaultBinding !== undefined + ? formatKeyStroke(friendlyEventIdentifier(command.defaultBinding)) + : ""); + const { label, category } = command; + switch (command.type) { + case "action": + commands.push({ + kind: "action", + label, + shortcut, + category, + actionId: command.id, + }); + break; + case "callback": { + const invoke = command.invoke; + commands.push({ + kind: "command", + label, + shortcut, + category, + id: command.id, + invoke: () => invoke(), + }); + break; + } + } } const toolQueryResult = parseToolQuery("+"); diff --git a/src/ui/command_palette.ts b/src/ui/command_palette.ts index bee4a0743e..e84c6aa3fd 100644 --- a/src/ui/command_palette.ts +++ b/src/ui/command_palette.ts @@ -279,6 +279,8 @@ export class CommandPalette extends Overlay { if (command.kind === "execute") { command.execute(); + } else if (command.kind === "command") { + command.invoke(); } else if (command.kind === "action") { this.actionDispatchTarget.dispatchEvent( new CustomEvent(`action:${command.actionId}`, { diff --git a/src/ui/command_registry.spec.ts b/src/ui/command_registry.spec.ts new file mode 100644 index 0000000000..ee7c5a197b --- /dev/null +++ b/src/ui/command_registry.spec.ts @@ -0,0 +1,122 @@ +/** + * @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 { describe, expect, it } from "vitest"; +import { CommandRegistry } from "#src/ui/command_registry.js"; +import { WatchableValue } from "#src/trackable_value.js"; + +describe("CommandRegistry", () => { + it("registers and retrieves a command by id", () => { + const registry = new CommandRegistry(); + registry.registerAction({ + id: "screenshot", + label: "Screenshot", + description: "Capture a screenshot.", + }); + expect(registry.has("screenshot")).toBe(true); + expect(registry.get("screenshot")?.label).toBe("Screenshot"); + registry.dispose(); + }); + + it("stamps the command type explicitly per registrar", () => { + const registry = new CommandRegistry(); + registry.registerAction({ id: "a", label: "A" }); + registry.registerCallback({ id: "c", label: "C", invoke: () => {} }); + expect(registry.get("a")?.type).toBe("action"); + expect(registry.get("c")?.type).toBe("callback"); + registry.dispose(); + }); + + it("invokes a callback command's callback", () => { + const registry = new CommandRegistry(); + let ran = false; + registry.registerCallback({ + id: "c", + label: "C", + invoke: () => { + ran = true; + }, + }); + const command = registry.get("c"); + if (command?.type === "callback") command.invoke(); + expect(ran).toBe(true); + registry.dispose(); + }); + + it("enumerates commands independent of any binding", () => { + const registry = new CommandRegistry(); + registry.registerAction({ id: "a", label: "A" }); + registry.registerAction({ id: "b", label: "B" }); + expect([...registry.values()].map((c) => c.id)).toStrictEqual(["a", "b"]); + registry.dispose(); + }); + + it("throws on duplicate id", () => { + const registry = new CommandRegistry(); + registry.registerAction({ id: "dup", label: "First" }); + expect(() => + registry.registerAction({ id: "dup", label: "Second" }), + ).toThrow(/already registered/); + registry.dispose(); + }); + + it("unregisters via the returned disposer", () => { + const registry = new CommandRegistry(); + const dispose = registry.registerAction({ id: "temp", label: "Temp" }); + expect(registry.has("temp")).toBe(true); + dispose(); + expect(registry.has("temp")).toBe(false); + registry.dispose(); + }); + + it("dispatches changed on register and unregister", () => { + const registry = new CommandRegistry(); + let count = 0; + registry.changed.add(() => ++count); + const dispose = registry.registerAction({ id: "x", label: "X" }); + expect(count).toBe(1); + dispose(); + expect(count).toBe(2); + registry.dispose(); + }); + + it("dispatches changed when a command's availability changes", () => { + const registry = new CommandRegistry(); + const isAvailable = new WatchableValue(true); + registry.registerAction({ id: "x", label: "X", isAvailable }); + let count = 0; + registry.changed.add(() => ++count); + isAvailable.value = false; + expect(count).toBe(1); + registry.dispose(); + }); + + it("stops observing availability after unregister", () => { + const registry = new CommandRegistry(); + const isAvailable = new WatchableValue(true); + const dispose = registry.registerAction({ + id: "x", + label: "X", + isAvailable, + }); + dispose(); + let count = 0; + registry.changed.add(() => ++count); + isAvailable.value = false; + expect(count).toBe(0); + registry.dispose(); + }); +}); diff --git a/src/ui/command_registry.ts b/src/ui/command_registry.ts new file mode 100644 index 0000000000..d8b59438d6 --- /dev/null +++ b/src/ui/command_registry.ts @@ -0,0 +1,160 @@ +/** + * @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. + */ + +/** + * @file Global, binding-independent registry of viewer commands. + * + * A command is declared once, with a stable id, a pretty label, and an optional + * help description. Any key binding is looked up separately and shown alongside; + * it is not the source of truth for which commands exist. + * + * The registry is owned by the viewer so commands can be registered before — or + * without — any UI chrome. The command palette and help panel are consumers of + * the same surface; see {@link CommandCatalog}. + */ + +import type { ActionIdentifier } from "#src/util/event_action_map.js"; +import { RefCounted } from "#src/util/disposable.js"; +import { Signal } from "#src/util/signal.js"; +import type { WatchableValueInterface } from "#src/trackable_value.js"; + +interface CommandInfoBase { + /** Stable, serialisable identifier, e.g. "toggle-scale-bar". */ + readonly id: ActionIdentifier; + /** Human-readable name shown in the palette / help panel. */ + readonly label: string; + /** + * Optional longer help text describing what the command does. Surfaced by + * hosts that can afford more than a label (help panel, tooltips). + */ + readonly description?: string; + /** Optional flat category / section for grouping in a host surface. */ + readonly category?: string; + /** + * Optional suggested key binding, e.g. "shift+keyc". Purely informational at + * the registry level — installing it into the input bindings is a consumer + * concern. A command with no `defaultBinding` and no live binding is still a + * first-class member of the registry. + */ + readonly defaultBinding?: string; + /** + * Optional observable of whether the command is currently usable. When it + * changes the registry dispatches `changed`, so consumers can re-enumerate + * "what's usable now" without polling. + */ + readonly isAvailable?: WatchableValueInterface; +} + +/** + * A command backed by a DOM action: invoking it dispatches `action:`, + * exactly as the equivalent keyboard shortcut would. `id` doubles as the action + * id, so existing actions need no extra wiring. + */ +export interface ActionCommandInfo extends CommandInfoBase { + readonly type: "action"; +} + +/** + * A command that runs a callback directly, for commands with no corresponding + * DOM action (e.g. host-registered commands). + */ +export interface CallbackCommandInfo extends CommandInfoBase { + readonly type: "callback"; + readonly invoke: (payload?: unknown) => unknown; +} + +/** + * A registered command. The `type` discriminant is stated explicitly by the + * registrant rather than inferred from which optional fields are present, so + * new command types can be added without changing how existing ones are read. + */ +export type CommandInfo = ActionCommandInfo | CallbackCommandInfo; + +export type CommandType = CommandInfo["type"]; + +/** + * Per-viewer registry of {@link CommandInfo}. Registration returns a disposer + * that unregisters the command, so feature code can add commands for the + * lifetime of a layer / control and clean up automatically. + */ +export class CommandRegistry extends RefCounted { + private readonly commands = new Map(); + private readonly availabilityDisposers = new Map< + ActionIdentifier, + () => void + >(); + + /** Dispatched when a command is added/removed, or its availability changes. */ + readonly changed = new Signal(); + + /** Registers an action-backed command. See {@link ActionCommandInfo}. */ + registerAction(options: Omit): () => void { + return this.register({ type: "action", ...options }); + } + + /** Registers a callback command. See {@link CallbackCommandInfo}. */ + registerCallback(options: Omit): () => void { + return this.register({ type: "callback", ...options }); + } + + /** Registers a command. Throws on duplicate `id`. Returns a disposer. */ + register(command: CommandInfo): () => void { + const { id } = command; + if (this.commands.has(id)) { + throw new Error(`Command already registered: ${JSON.stringify(id)}`); + } + this.commands.set(id, command); + const { isAvailable } = command; + if (isAvailable !== undefined) { + this.availabilityDisposers.set( + id, + isAvailable.changed.add(() => this.changed.dispatch()), + ); + } + this.changed.dispatch(); + return () => this.unregister(id); + } + + unregister(id: ActionIdentifier): void { + if (!this.commands.delete(id)) return; + const disposer = this.availabilityDisposers.get(id); + if (disposer !== undefined) { + disposer(); + this.availabilityDisposers.delete(id); + } + this.changed.dispatch(); + } + + get(id: ActionIdentifier): CommandInfo | undefined { + return this.commands.get(id); + } + + has(id: ActionIdentifier): boolean { + return this.commands.has(id); + } + + /** Iterates every registered command, regardless of current availability. */ + values(): IterableIterator { + return this.commands.values(); + } + + disposed() { + for (const disposer of this.availabilityDisposers.values()) disposer(); + this.availabilityDisposers.clear(); + this.commands.clear(); + super.disposed(); + } +} diff --git a/src/ui/default_commands.ts b/src/ui/default_commands.ts new file mode 100644 index 0000000000..3c3a92c1b2 --- /dev/null +++ b/src/ui/default_commands.ts @@ -0,0 +1,283 @@ +/** + * @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. + */ + +/** + * @file Declarations of the built-in viewer commands. + * + * Each entry names an existing DOM action (`id` === the `action:` id dispatched + * by the default input-event bindings) and gives it an explicit, human-readable + * label and help description. The registry — not the bindings — is now the + * authoritative list of commands; the shortcut shown for each command is looked + * up from whatever binding happens to be installed (see {@link CommandCatalog}). + * + * Commands whose behaviour is per-entity or otherwise dynamic (layer toggles, + * tool activation) are contributed by the catalog at enumeration time and are + * intentionally *not* declared here. + * + * This is the built-in *seed* set, not a required registry. It exists only + * because these commands correspond to DOM actions that predate the registry. + * Feature code should NOT add entries here; instead register commands + * colocated with the feature, for its own lifetime, e.g. + * + * this.registerDisposer( + * viewer.commandRegistry.registerCallback({ + * id: "clip.addPlane", + * label: "Add Clip Plane", + * invoke: () => this.addPlane(), + * }), + * ); + * + * `registerAction` / `registerCallback` each return a disposer, so commands may + * come and go with the feature (e.g. per-layer). `CommandRegistry` — not this + * file — is the authoritative, runtime-enumerable list. + */ + +import type { + ActionCommandInfo, + CommandRegistry, +} from "#src/ui/command_registry.js"; + +// Every built-in command is action-backed (dispatches `action:`); the type +// is stamped by `registerAction` at registration time. +type BuiltinCommand = Omit; + +const CATEGORY_VIEW = "View"; +const CATEGORY_NAVIGATION = "Navigation"; +const CATEGORY_ANNOTATION = "Annotation"; +const CATEGORY_LAYERS = "Layers"; +const CATEGORY_STATE = "State"; +const CATEGORY_TOOLS = "Tools"; + +const AXES = ["X", "Y", "Z"] as const; + +// Directional position nudges (arrow keys / , . / [ ] in the data panels). +function axisMoveCommands(): BuiltinCommand[] { + const commands: BuiltinCommand[] = []; + for (const axis of AXES) { + const lower = axis.toLowerCase(); + commands.push( + { + id: `${lower}-`, + label: `Move −${axis}`, + description: `Move the view one step in the −${axis} direction.`, + category: CATEGORY_NAVIGATION, + }, + { + id: `${lower}+`, + label: `Move +${axis}`, + description: `Move the view one step in the +${axis} direction.`, + category: CATEGORY_NAVIGATION, + }, + ); + } + return commands; +} + +// Relative rotations about each axis (r / e and shift+arrow keys). +function axisRotateCommands(): BuiltinCommand[] { + const commands: BuiltinCommand[] = []; + for (const axis of AXES) { + const lower = axis.toLowerCase(); + commands.push( + { + id: `rotate-relative-${lower}-`, + label: `Rotate −${axis}`, + description: `Rotate the view a small amount about the ${axis} axis (negative direction).`, + category: CATEGORY_NAVIGATION, + }, + { + id: `rotate-relative-${lower}+`, + label: `Rotate +${axis}`, + description: `Rotate the view a small amount about the ${axis} axis (positive direction).`, + category: CATEGORY_NAVIGATION, + }, + ); + } + return commands; +} + +const STATIC_COMMANDS: readonly BuiltinCommand[] = [ + // View toggles. + { + id: "toggle-show-slices", + label: "Toggle Slices in 3D", + description: "Show or hide the cross-section slices in the 3D view.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-scale-bar", + label: "Toggle Scale Bar", + description: "Show or hide the scale bar overlay.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-axis-lines", + label: "Toggle Axis Lines", + description: "Show or hide the axis line indicators.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-orthographic-projection", + label: "Toggle Orthographic Projection", + description: + "Switch the 3D view between perspective and orthographic projection.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-default-annotations", + label: "Toggle Bounding Box", + description: "Show or hide the default bounding-box annotations.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-show-statistics", + label: "Toggle Statistics", + description: "Show or hide the rendering statistics panel.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-layout", + label: "Toggle Layout", + description: "Cycle the data panel layout.", + category: CATEGORY_VIEW, + }, + { + id: "toggle-layout-alternative", + label: "Toggle Alternative Layout", + description: "Cycle the alternative data panel layout.", + category: CATEGORY_VIEW, + }, + { + id: "help", + label: "Show Help", + description: "Open the keyboard and mouse bindings help panel.", + category: CATEGORY_VIEW, + }, + // Navigation. + { + id: "snap", + label: "Snap to Axis", + description: + "Snap the view orientation to the nearest axis-aligned orientation.", + category: CATEGORY_NAVIGATION, + }, + { + id: "zoom-in", + label: "Zoom In", + description: "Zoom the view in.", + category: CATEGORY_NAVIGATION, + }, + { + id: "zoom-out", + label: "Zoom Out", + description: "Zoom the view out.", + category: CATEGORY_NAVIGATION, + }, + { + id: "depth-range-decrease", + label: "Decrease Depth Range", + description: "Decrease the visible depth range of the 3D projection.", + category: CATEGORY_NAVIGATION, + }, + { + id: "depth-range-increase", + label: "Increase Depth Range", + description: "Increase the visible depth range of the 3D projection.", + category: CATEGORY_NAVIGATION, + }, + { + id: "t-", + label: "Previous Timestep", + description: "Step backward one frame along the time axis.", + category: CATEGORY_NAVIGATION, + }, + { + id: "t+", + label: "Next Timestep", + description: "Step forward one frame along the time axis.", + category: CATEGORY_NAVIGATION, + }, + // Layers / segmentation. + { + id: "add-layer", + label: "Add Layer", + description: "Add a new layer to the viewer.", + category: CATEGORY_LAYERS, + }, + { + id: "recolor", + label: "Randomize Colors", + description: "Assign a new random color seed to segmentation layers.", + category: CATEGORY_LAYERS, + }, + { + id: "clear-segments", + label: "Clear Selected Segments", + description: "Deselect all currently selected segments.", + category: CATEGORY_LAYERS, + }, + // Annotation. + { + id: "finish-annotation", + label: "Finish Annotation", + description: "Complete the annotation currently being drawn.", + category: CATEGORY_ANNOTATION, + }, + { + id: "undo-annotation-step", + label: "Undo Annotation Step", + description: "Undo the last point added to the in-progress annotation.", + category: CATEGORY_ANNOTATION, + }, + // State — these have no default key binding; before the registry they were + // special-cased so the palette could surface them at all. + { + id: "edit-json-state", + label: "Edit JSON State", + description: "Open an editor for the raw viewer JSON state.", + category: CATEGORY_STATE, + }, + { + id: "screenshot", + label: "Screenshot", + description: "Capture a screenshot of the current view.", + category: CATEGORY_STATE, + }, + // Tools. + { + id: "deactivate-active-tool", + label: "Deactivate Active Tool", + description: "Deactivate whichever tool is currently active.", + category: CATEGORY_TOOLS, + }, +]; + +/** + * Registers the built-in commands into `registry`. Called once during default + * viewer setup. The registry is owned (and disposed) by the viewer, so no + * disposers are returned here — the commands live for the viewer's lifetime. + */ +export function registerDefaultCommands(registry: CommandRegistry): void { + for (const command of STATIC_COMMANDS) { + registry.registerAction(command); + } + for (const command of axisMoveCommands()) { + registry.registerAction(command); + } + for (const command of axisRotateCommands()) { + registry.registerAction(command); + } +} diff --git a/src/ui/default_viewer_setup.ts b/src/ui/default_viewer_setup.ts index 8adeb5e8b3..062b40c424 100644 --- a/src/ui/default_viewer_setup.ts +++ b/src/ui/default_viewer_setup.ts @@ -17,6 +17,7 @@ import { StatusMessage } from "#src/status.js"; import { CommandCatalog } from "#src/ui/command_catalog.js"; import { bindCommandPalette } from "#src/ui/command_palette.js"; +import { registerDefaultCommands } from "#src/ui/default_commands.js"; import { bindDefaultCopyHandler, bindDefaultPasteHandler, @@ -64,6 +65,7 @@ export function setupDefaultViewer(options?: Partial) { bindDefaultCopyHandler(viewer); bindDefaultPasteHandler(viewer); + registerDefaultCommands(viewer.commandRegistry); const catalog = viewer.registerDisposer(new CommandCatalog(viewer)); bindCommandPalette(viewer, catalog); diff --git a/src/viewer.ts b/src/viewer.ts index ce9b115946..14d8c5953d 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -94,6 +94,7 @@ import { SelectionDetailsPanel } from "#src/ui/selection_details.js"; import { SidePanelManager } from "#src/ui/side_panel.js"; import { StateEditorDialog } from "#src/ui/state_editor.js"; import { StatisticsDisplayState, StatisticsPanel } from "#src/ui/statistics.js"; +import { CommandRegistry } from "#src/ui/command_registry.js"; import { GlobalToolBinder, LocalToolBinder } from "#src/ui/tool.js"; import { MultiToolPaletteDropdownButton, @@ -1175,6 +1176,11 @@ export class Viewer extends RefCounted implements ViewerState { new GlobalToolBinder(this.toolInputEventMapBinder, this.toolPalettes), ); + // Global, binding-independent registry of viewer commands. Populated with the + // built-in commands during default viewer setup; feature code and hosts may + // register additional commands against it. + public commandRegistry = this.registerDisposer(new CommandRegistry()); + public toolBinder = this.registerDisposer( new LocalToolBinder(this, this.globalToolBinder), );