diff --git a/frontend/javascripts/libs/input.ts b/frontend/javascripts/libs/input.ts index 8926287e02..629dd63148 100644 --- a/frontend/javascripts/libs/input.ts +++ b/frontend/javascripts/libs/input.ts @@ -1,13 +1,19 @@ +import { + type BrowserKeyComboEventProps, + bindKeyCombo, + type KeyEvent, + unbindKeyCombo, +} from "@rwh/keystrokes"; import Hammer from "hammerjs"; import { Keyboard } from "keyboardjs"; import { us } from "keyboardjs/locales/us"; import Date from "libs/date"; import window, { document } from "libs/window"; import extend from "lodash-es/extend"; -import noop from "lodash-es/noop"; import { createNanoEvents, type Emitter } from "nanoevents"; -import type { Point2 } from "viewer/constants"; -import constants, { isMac } from "viewer/constants"; +import type { ValueOf } from "types/type_utils"; +import type { OrthoView, Point2 } from "viewer/constants"; +import constants from "viewer/constants"; import { addEventListenerWithDelegation, isNoElementFocused } from "./utils"; // This is the main Input implementation. @@ -24,33 +30,97 @@ import { addEventListenerWithDelegation, isNoElementFocused } from "./utils"; export const KEYBOARD_BUTTON_LOOP_INTERVAL = 1000 / constants.FPS; const MOUSE_MOVE_DELTA_THRESHOLD = 5; +// Keyboard related types export type ModifierKeys = "alt" | "shift" | "ctrlOrMeta"; -type KeyboardKey = string; -type MouseButton = string; -type KeyboardHandler = (event: KeyboardEvent) => void | Promise; +export type KeyboardKeyOrCombo = string; +export type KeyboardHandlerFn = (event: KeyboardEvent) => void | Promise; // Callable Object, see https://www.typescriptlang.org/docs/handbook/2/functions.html#call-signatures -type KeyboardLoopHandler = { +export type KeyboardNoLoopHandler = { + onPressed: KeyboardHandlerFn; + onReleased?: KeyboardHandlerFn; +}; +export type KeyboardLoopFn = { (arg0: number, isOriginalEvent: boolean, event: KeyboardEvent): void; delayed?: boolean; lastTime?: number | null | undefined; customAdditionalDelayFn?: () => number; }; -type KeyboardBindingPress = [KeyboardKey, KeyboardHandler, KeyboardHandler, boolean]; -type KeyboardBindingDownUp = [KeyboardKey, KeyboardHandler, KeyboardHandler, boolean]; -type KeyBindingMap = Record; -type KeyBindingLoopMap = Record; -export type MouseBindingMap = Record; +export type KeyboardLoopHandler = { + onPressedWithRepeat: KeyboardLoopFn; + onReleased?: KeyboardLoopFn; +}; +// TODO: Improve type naming. +export type KeyBindingMap = Record; +export type KeyBindingLoopMap = Record; +type KeystrokesHandlerArgs = { + keyCombo: string; + keyEvents: KeyEvent[]; + finalKeyEvent: KeyEvent; +}; +type KeystrokesHandler = (event: KeystrokesHandlerArgs) => void; +type KeyboardBindingPress = { + keyCombo: KeyboardKeyOrCombo; + onPressed?: KeystrokesHandler; + onReleased?: KeystrokesHandler; + preventRepeatByDefault: boolean; +}; +type KeyboardBindingLoopPress = { + keyCombo: KeyboardKeyOrCombo; + onPressedWithRepeat: KeystrokesHandler; + onReleased?: KeystrokesHandler; +}; + +// Mouse related types +type MouseClickEvents = + | "leftClick" + | "rightClick" + | "leftDoubleClick" + | "middleClick" + | "leftMouseDown" + | "rightMouseDown" + | "leftMouseUp" + | "rightMouseUp"; +type MouseMoveEvents = "mouseMove" | "leftDownMove" | "middleDownMove" | "rightDownMove"; +type MouseScrollEvents = "scroll"; +type MouseHoverEvents = "over" | "out"; type MouseButtonWhich = 1 | 2 | 3; type MouseButtonString = "left" | "middle" | "right"; +type HammerJSEvents = "pinch"; +type MouseMoveEventHandler = ( + delta: Point2, + position: Point2, + id: OrthoView, + event: MouseEvent, +) => void; +type MouseClickEventHandler = ( + position: Point2, + id: OrthoView, + event: MouseEvent, + isTouch: boolean, +) => void; +type MouseScrollEventHandler = ( + deltaYorX: number, + modifier: ModifierKeys | null | undefined, +) => void; +type MouseHoverEventHandler = () => void; export type MouseHandler = - | ((deltaYorX: number, modifier: ModifierKeys | null | undefined) => void) - | ((position: Point2, id: string, event: MouseEvent, isTouch: boolean) => void) - | ((delta: Point2, position: Point2, id: string, event: MouseEvent) => void); + | MouseMoveEventHandler + | MouseClickEventHandler + | MouseScrollEventHandler + | MouseHoverEventHandler; +export type HammerJSHandler = (delta: number, center: Point2) => void; +type FullMouseBindingMap = Record & + Record & + Record & + Record & + Record; +export type MouseEventHandler = ValueOf; +export type MouseBindingMap = Partial; // Workaround: KeyboardJS fires event for "C" even if you press // "Ctrl + C". -function shouldIgnore(event: KeyboardEvent, key: KeyboardKey) { - const bindingHasCtrl = key.toLowerCase().indexOf("ctrl") !== -1; +function shouldIgnore(event: KeyboardEvent, key: KeyboardKeyOrCombo) { + const bindingHasCtrl = key.toLowerCase().indexOf("control") !== -1; const bindingHasShift = key.toLowerCase().indexOf("shift") !== -1; const bindingHasSuper = key.toLowerCase().indexOf("super") !== -1; const bindingHasCommand = key.toLowerCase().indexOf("command") !== -1; @@ -67,8 +137,6 @@ function shouldIgnore(event: KeyboardEvent, key: KeyboardKey) { // This keyboard hook directly passes a keycombo and callback // to the underlying KeyboadJS library to do its dirty work. // Pressing a button will only fire an event once. -const EXTENDED_COMMAND_KEYS = isMac ? "command + k" : "ctrl + k"; -const EXTENDED_COMMAND_DURATION = 3000; const keyboard = new Keyboard( window, @@ -79,64 +147,46 @@ const keyboard = new Keyboard( keyboard.setLocale("us", us); keyboard.setContext("default"); // do not use global context as that is shared between all keycombos +function findEventInKeystrokeComboEvent( + keyEvents: KeyEvent[], + finalKeyEvent: KeyEvent, +): KeyboardEvent | undefined { + if (finalKeyEvent.originalEvent) { + return finalKeyEvent.originalEvent; + } + return keyEvents.find((event) => event.originalEvent)?.originalEvent; +} + export class InputKeyboardNoLoop { bindings: KeyboardBindingPress[] = []; isStarted: boolean = true; supportInputElements: boolean = false; - hasExtendedBindings: boolean = false; cancelExtendedModeTimeoutId: ReturnType | null = null; + isPreventBrowserSearchbarShortcutActive: boolean = false; constructor( initialBindings: KeyBindingMap, options?: { supportInputElements?: boolean; }, - extendedCommands?: KeyBindingMap, - keyUpBindings?: KeyBindingMap, ) { if (options) { this.supportInputElements = options.supportInputElements || this.supportInputElements; } - if (extendedCommands != null && initialBindings[EXTENDED_COMMAND_KEYS] != null) { - console.warn( - `Extended commands are enabled, but the keybinding for it is already in use. Please change the keybinding for '${EXTENDED_COMMAND_KEYS}'.`, - ); - } - - if (extendedCommands) { - this.hasExtendedBindings = true; + const hasLegacyExtendedKeyboardShortcut = Object.keys(initialBindings).some((keyCombo) => + keyCombo.includes("control + k"), + ); + if (hasLegacyExtendedKeyboardShortcut) { document.addEventListener("keydown", this.preventBrowserSearchbarShortcut); - this.attach(EXTENDED_COMMAND_KEYS, this.toggleExtendedMode); - // Add empty callback in extended mode to deactivate the extended mode via the same EXTENDED_COMMAND_KEYS. - this.attach(EXTENDED_COMMAND_KEYS, noop, noop, true); - for (const key of Object.keys(extendedCommands)) { - const callback = extendedCommands[key]; - this.attach(key, callback, noop, true); - } + this.isPreventBrowserSearchbarShortcutActive = true; } - for (const key of Object.keys(initialBindings)) { - const callback = initialBindings[key]; - const keyUpCallback = keyUpBindings != null ? keyUpBindings[key] : noop; - this.attach(key, callback, keyUpCallback); + for (const [keyCombo, handlers] of Object.entries(initialBindings)) { + this.attach(keyCombo, handlers); } } - toggleExtendedMode = (evt: KeyboardEvent) => { - evt.preventDefault(); - const isInExtendedMode = keyboard.getContext() === "extended"; - if (isInExtendedMode) { - this.cancelExtendedModeTimeout(); - keyboard.setContext("default"); - return; - } - keyboard.setContext("extended"); - this.cancelExtendedModeTimeoutId = setTimeout(() => { - keyboard.setContext("default"); - }, EXTENDED_COMMAND_DURATION); - }; - preventBrowserSearchbarShortcut = (evt: KeyboardEvent) => { if ((evt.ctrlKey || evt.metaKey) && evt.key === "k") { evt.preventDefault(); @@ -144,73 +194,58 @@ export class InputKeyboardNoLoop { } }; - cancelExtendedModeTimeout() { - if (this.cancelExtendedModeTimeoutId != null) { - clearTimeout(this.cancelExtendedModeTimeoutId); - this.cancelExtendedModeTimeoutId = null; - } - } + attach(combo: KeyboardKeyOrCombo, { onPressed, onReleased: onRelease }: KeyboardNoLoopHandler) { + const onPressedGuarded = ({ keyEvents, finalKeyEvent }: KeystrokesHandlerArgs) => { + if (!this.isStarted) { + return; + } - attach( - key: KeyboardKey, - keyDownCallback: KeyboardHandler, - keyUpCallback: KeyboardHandler = noop, - isExtendedCommand: boolean = false, - ) { - const binding: KeyboardBindingPress = [ - key, - (event: KeyboardEvent) => { - if (!this.isStarted) { - return; - } + if (!this.supportInputElements && !isNoElementFocused()) { + return; + } + const event = findEventInKeystrokeComboEvent(keyEvents, finalKeyEvent); + if (!event) { + return; + } - if (!this.supportInputElements && !isNoElementFocused()) { - return; - } + if (shouldIgnore(event, combo)) { + return; + } - if (shouldIgnore(event, key)) { - return; + if (!event.repeat) { + onPressed(event); + } else { + event.preventDefault(); + event.stopPropagation(); + } + }; + const onReleasedInterfaceAdjusted = onRelease + ? ({ keyEvents, finalKeyEvent }: KeystrokesHandlerArgs) => { + const event = findEventInKeystrokeComboEvent(keyEvents, finalKeyEvent); + if (!event) { + return; + } + onRelease(event); } - const isInExtendedMode = keyboard.getContext() === "extended"; + : () => {}; - if (isInExtendedMode) { - this.cancelExtendedModeTimeout(); - keyboard.setContext("default"); - } + const binding: KeyboardBindingPress = { + keyCombo: combo, + onPressed: onPressedGuarded, + onReleased: onReleasedInterfaceAdjusted, + preventRepeatByDefault: false, + }; - if (!event.repeat) { - keyDownCallback(event); - } else { - event.preventDefault(); - event.stopPropagation(); - } - }, - (event: KeyboardEvent) => { - keyUpCallback(event); - }, - false, - ]; - - if (isExtendedCommand) { - keyboard.withContext("extended", () => { - keyboard.bind(...binding); - }); - } else { - keyboard.withContext("default", () => { - keyboard.bind(...binding); - }); - } - return this.bindings.push(binding); + bindKeyCombo(combo, binding); + this.bindings.push(binding); } destroy() { this.isStarted = false; - for (const binding of this.bindings) { - const [keyCombo, pressHandler, releaseHandler] = binding; - keyboard.unbind(keyCombo, pressHandler, releaseHandler); + unbindKeyCombo(binding.keyCombo, binding); } - if (this.hasExtendedBindings) { + if (this.isPreventBrowserSearchbarShortcutActive) { document.removeEventListener("keydown", this.preventBrowserSearchbarShortcut); } } @@ -221,7 +256,7 @@ export class InputKeyboardNoLoop { export class InputKeyboard { keyCallbackMap: KeyBindingLoopMap = {}; keyPressedCount: number = 0; - bindings: KeyboardBindingDownUp[] = []; + bindings: KeyboardBindingLoopPress[] = []; isStarted: boolean = true; delay: number = 0; supportInputElements: boolean = false; @@ -238,82 +273,94 @@ export class InputKeyboard { this.supportInputElements = options.supportInputElements || this.supportInputElements; } - for (const key of Object.keys(initialBindings)) { - const callback = initialBindings[key]; - this.attach(key, callback); + for (const [keyCombo, handlers] of Object.entries(initialBindings)) { + this.attach(keyCombo, handlers); } } - attach(key: KeyboardKey, callback: KeyboardLoopHandler) { + attach(keyCombo: KeyboardKeyOrCombo, handler: KeyboardLoopHandler) { let delayTimeoutId: ReturnType | null = null; - const binding: KeyboardBindingDownUp = [ - key, - (event: KeyboardEvent) => { - // When first pressed, insert the callback into - // keyCallbackMap and start the buttonLoop. - // Then, ignore any other events fired from the operating - // system, because we're using our own loop. - // When control key is pressed, everything is ignored, because - // if there is any browser action attached to this (as with Ctrl + S) - // KeyboardJS does not receive the up event. - if (!this.isStarted) { - return; - } + const { onPressedWithRepeat, onReleased } = handler; + const onPressedWithRepeatGuarded = ({ keyEvents, finalKeyEvent }: KeystrokesHandlerArgs) => { + const event = findEventInKeystrokeComboEvent(keyEvents, finalKeyEvent); + if (!event) { + return; + } + // When first pressed, insert the callback into + // keyCallbackMap and start the buttonLoop. + // Then, ignore any other events fired from the operating + // system, because we're using our own loop. + // When control key is pressed, everything is ignored, because + // if there is any browser action attached to this (as with Ctrl + S) + // KeyboardJS does not receive the up event. + if (!this.isStarted) { + return; + } - if (this.keyCallbackMap[key] != null) { - return; - } + if (this.keyCallbackMap[keyCombo] != null) { + return; + } - if (!this.supportInputElements && !isNoElementFocused()) { - return; - } + if (!this.supportInputElements && !isNoElementFocused()) { + return; + } - if (shouldIgnore(event, key)) { - return; - } + if (shouldIgnore(event, keyCombo)) { + return; + } - callback(1, true, event); - // reset lastTime - callback.lastTime = null; - callback.delayed = true; - this.keyCallbackMap[key] = callback; - this.keyPressedCount++; + onPressedWithRepeat(1, true, event); + // reset lastTime + onPressedWithRepeat.lastTime = null; + onPressedWithRepeat.delayed = true; + this.keyCallbackMap[keyCombo] = handler; + this.keyPressedCount++; - if (this.keyPressedCount === 1) { - this.buttonLoop(event); - } + if (this.keyPressedCount === 1) { + this.buttonLoop(event); + } - const totalDelay = - this.delay + - (callback.customAdditionalDelayFn != null ? callback.customAdditionalDelayFn() : 0); + const totalDelay = + this.delay + + (onPressedWithRepeat.customAdditionalDelayFn != null + ? onPressedWithRepeat.customAdditionalDelayFn() + : 0); + + if (totalDelay >= 0) { + delayTimeoutId = setTimeout(() => { + onPressedWithRepeat.delayed = false; + onPressedWithRepeat.lastTime = Date.now(); + }, totalDelay); + } + }; - if (totalDelay >= 0) { - delayTimeoutId = setTimeout(() => { - callback.delayed = false; - callback.lastTime = Date.now(); - }, totalDelay); - } - }, - () => { - if (!this.isStarted) { - return; - } + // TODOM: find better name. + const onReleaseGuarded = ({ keyEvents, finalKeyEvent }: KeystrokesHandlerArgs) => { + if (!this.isStarted) { + return; + } - if (this.keyCallbackMap[key] != null) { - this.keyPressedCount--; - delete this.keyCallbackMap[key]; - } + if (this.keyCallbackMap[keyCombo] != null) { + this.keyPressedCount--; + delete this.keyCallbackMap[keyCombo]; + } - if (delayTimeoutId != null) { - clearTimeout(delayTimeoutId); - delayTimeoutId = null; - } - }, - false, // preventRepeatByDefault - ]; - keyboard.withContext("default", () => { - keyboard.bind(...binding); - }); + if (delayTimeoutId != null) { + clearTimeout(delayTimeoutId); + delayTimeoutId = null; + } + const event = findEventInKeystrokeComboEvent(keyEvents, finalKeyEvent); + if (onReleased != null && event != null) { + onReleased(1, true, event); + } + }; + + const binding: KeyboardBindingLoopPress = { + keyCombo, + onPressedWithRepeat: onPressedWithRepeatGuarded, + onReleased: onReleaseGuarded, + }; + bindKeyCombo(keyCombo, binding); this.bindings.push(binding); } @@ -326,15 +373,15 @@ export class InputKeyboard { if (this.keyPressedCount > 0) { for (const key of Object.keys(this.keyCallbackMap)) { - const callback = this.keyCallbackMap[key]; + const { onPressedWithRepeat } = this.keyCallbackMap[key]; - if (!callback.delayed) { + if (!onPressedWithRepeat.delayed) { const curTime = Date.now(); // If no lastTime, assume that desired FPS is met - const lastTime = callback.lastTime || curTime - 1000 / constants.FPS; + const lastTime = onPressedWithRepeat.lastTime || curTime - 1000 / constants.FPS; const elapsed = curTime - lastTime; - callback.lastTime = curTime; - callback((elapsed / 1000) * constants.FPS, false, originalEvent); + onPressedWithRepeat.lastTime = curTime; + onPressedWithRepeat((elapsed / 1000) * constants.FPS, false, originalEvent); } } @@ -346,8 +393,8 @@ export class InputKeyboard { this.isStarted = false; for (const binding of this.bindings) { - const [keyCombo, pressHandler, releaseHandler] = binding; - keyboard.unbind(keyCombo, pressHandler, releaseHandler); + const { keyCombo } = binding; + unbindKeyCombo(keyCombo, binding); } } } diff --git a/frontend/javascripts/libs/shortcut_component.ts b/frontend/javascripts/libs/shortcut_component.ts index e2ff8b1480..a00dbeceff 100644 --- a/frontend/javascripts/libs/shortcut_component.ts +++ b/frontend/javascripts/libs/shortcut_component.ts @@ -3,27 +3,23 @@ import { useEffect } from "react"; // This component provides a lightweight wrapper around the input library. // It leverages reacts lifecycle hooks to allow rendering-sensitive activation of shortcuts. +// TODOM maybe replace with new keystrokes hooks type Props = { keys: string; onTrigger: () => any; supportLoop?: boolean; supportInputElements?: boolean; }; -export default function Shortcut(props: Props) { +export default function Shortcut({ keys, onTrigger, supportLoop, supportInputElements }: Props) { useEffect(() => { - const keyboard = new (props.supportLoop ? InputKeyboard : InputKeyboardNoLoop)( - { - [props.keys]: props.onTrigger, - }, - { - supportInputElements: props.supportInputElements, - }, - ); + const keyboard = supportLoop + ? new InputKeyboard({ [keys]: { onPressedWithRepeat: onTrigger } }, { supportInputElements }) + : new InputKeyboardNoLoop({ [keys]: { onPressed: onTrigger } }, { supportInputElements }); return () => { keyboard.destroy(); }; - }, [props.keys, props.onTrigger, props.supportLoop, props.supportInputElements]); + }, [keys, onTrigger, supportLoop, supportInputElements]); return null; } diff --git a/frontend/javascripts/test/api/api_skeleton_latest.spec.ts b/frontend/javascripts/test/api/api_skeleton_latest.spec.ts index d03e22807d..daac041fe4 100644 --- a/frontend/javascripts/test/api/api_skeleton_latest.spec.ts +++ b/frontend/javascripts/test/api/api_skeleton_latest.spec.ts @@ -221,7 +221,7 @@ describe("API Skeleton", () => { const bindSpy = vi.spyOn(Keyboard.prototype, "bind").mockReturnThis(); const unbindSpy = vi.spyOn(Keyboard.prototype, "unbind").mockReturnThis(); - const binding = api.utils.registerKeyHandler("g", () => {}); + const binding = api.utils.registerKeyHandler("g", { onPressed: () => {} }); expect(bindSpy).toHaveBeenCalled(); binding.unregister(); diff --git a/frontend/javascripts/viewer/api/api_latest.ts b/frontend/javascripts/viewer/api/api_latest.ts index 7a16d770f1..b7d7524aa7 100644 --- a/frontend/javascripts/viewer/api/api_latest.ts +++ b/frontend/javascripts/viewer/api/api_latest.ts @@ -6,7 +6,7 @@ import { sendAnalyticsEvent, } from "admin/rest_api"; import PriorityQueue from "js-priority-queue"; -import { InputKeyboardNoLoop } from "libs/input"; +import { InputKeyboardNoLoop, type KeyboardNoLoopHandler } from "libs/input"; import { M4x4, type Matrix4x4, V3 } from "libs/mjs"; import Request from "libs/request"; import type { ToastStyle } from "libs/toast"; @@ -3017,7 +3017,7 @@ class UtilsApi { /** * Sets a custom handler function for a keyboard shortcut. */ - registerKeyHandler(key: string, handler: () => void): UnregisterHandler { + registerKeyHandler(key: string, handler: KeyboardNoLoopHandler): UnregisterHandler { const keyboard = new InputKeyboardNoLoop({ [key]: handler, }); diff --git a/frontend/javascripts/viewer/controller.tsx b/frontend/javascripts/viewer/controller.tsx index bd9f9f2c3a..1fc1d6868c 100644 --- a/frontend/javascripts/viewer/controller.tsx +++ b/frontend/javascripts/viewer/controller.tsx @@ -7,7 +7,6 @@ import { getUrlParamValue, hasUrlParam, isNoElementFocused } from "libs/utils"; import window, { document } from "libs/window"; import { type WithBlockerProps, withBlocker } from "libs/with_blocker_hoc"; import { type RouteComponentProps, withRouter } from "libs/with_router_hoc"; -import extend from "lodash-es/extend"; import messages from "messages"; import { PureComponent } from "react"; import { connect } from "react-redux"; @@ -21,16 +20,23 @@ import { initializeSceneController } from "viewer/controller/scene_controller"; import UrlManager from "viewer/controller/url_manager"; import ArbitraryController from "viewer/controller/viewmodes/arbitrary_controller"; import PlaneController from "viewer/controller/viewmodes/plane_controller"; -import { AnnotationTool } from "viewer/model/accessors/tool_accessor"; import { wkInitializedAction } from "viewer/model/actions/actions"; import { redoAction, saveNowAction, undoAction } from "viewer/model/actions/save_actions"; -import { setViewModeAction, updateLayerSettingAction } from "viewer/model/actions/settings_actions"; import { setIsInAnnotationViewAction } from "viewer/model/actions/ui_actions"; import { HANDLED_ERROR } from "viewer/model_initialization"; import { Model } from "viewer/singletons"; import type { TraceOrViewCommand, WebknossosState } from "viewer/store"; import Store from "viewer/store"; +import { AnnotationTool } from "./model/accessors/tool_accessor"; +import { setViewModeAction, updateLayerSettingAction } from "./model/actions/settings_actions"; import type DataLayer from "./model/data_layer"; +import { + GeneralEditingKeyboardShortcuts, + GeneralKeyboardShortcuts, +} from "./view/keyboard_shortcuts/keyboard_shortcut_constants"; +import { loadKeyboardShortcuts } from "./view/keyboard_shortcuts/keyboard_shortcut_persistence"; +import type { KeyboardShortcutNoLoopedHandlerMap } from "./view/keyboard_shortcuts/keyboard_shortcut_types"; +import { buildKeyBindingsFromConfigAndMapping } from "./view/keyboard_shortcuts/keyboard_shortcut_utils"; export type ControllerStatus = "loading" | "loaded" | "failedLoading"; type OwnProps = { @@ -52,6 +58,15 @@ type State = { organizationToSwitchTo: APIOrganization | null | undefined; }; +type ControllerEditAllowedKeyboardHandlerIdMap = KeyboardShortcutNoLoopedHandlerMap< + GeneralKeyboardShortcuts | GeneralEditingKeyboardShortcuts +>; +type ControllerViewOnlyKeyboardHandlerIdMap = + KeyboardShortcutNoLoopedHandlerMap; +type ControllerKeyboardHandlerIdMap = + | ControllerEditAllowedKeyboardHandlerIdMap + | ControllerViewOnlyKeyboardHandlerIdMap; + class Controller extends PureComponent { keyboardNoLoop?: InputKeyboardNoLoop; _isMounted: boolean = false; @@ -59,6 +74,7 @@ class Controller extends PureComponent { gotUnhandledError: false, organizationToSwitchTo: null, }; + unsubscribeKeyboardListener: any = () => {}; // Main controller, responsible for setting modes and everything // that has to be controlled in any mode. @@ -91,6 +107,7 @@ class Controller extends PureComponent { this.props.setBlocking({ shouldBlock: false, }); + this.unsubscribeKeyboardListener(); } tryFetchingModel() { @@ -208,26 +225,85 @@ class Controller extends PureComponent { ); } - initKeyboard() { - // avoid scrolling while pressing space - document.addEventListener("keydown", (event: KeyboardEvent) => { - if ( - (event.which === 32 || event.which === 18 || (event.which >= 37 && event.which <= 40)) && - isNoElementFocused() - ) { - event.preventDefault(); + getKeyboardShortcutsHandlerMap(): ControllerKeyboardHandlerIdMap { + let leastRecentlyUsedSegmentationLayer: DataLayer | null = null; + function toggleSegmentationOpacity() { + let segmentationLayer = Model.getVisibleSegmentationLayer(); + + if (segmentationLayer != null) { + // If there is a visible segmentation layer, disable and remember it. + leastRecentlyUsedSegmentationLayer = segmentationLayer; + } else if (leastRecentlyUsedSegmentationLayer != null) { + // If no segmentation layer is visible, use the least recently toggled + // layer (note that toggling the layer via the switch-button won't update + // the local variable here). + segmentationLayer = leastRecentlyUsedSegmentationLayer; + } else { + // As a fallback, simply use some segmentation layer + segmentationLayer = Model.getSomeSegmentationLayer(); } - }); - const { controlMode } = Store.getState().temporaryConfiguration; - const keyboardControls = {}; - - if (controlMode !== ControlModeEnum.VIEW) { - extend(keyboardControls, { - // Set Mode, outcomment for release - "shift + 1": () => Store.dispatch(setViewModeAction(constants.MODE_PLANE_TRACING)), - "shift + 2": () => Store.dispatch(setViewModeAction(constants.MODE_ARBITRARY)), - "shift + 3": () => Store.dispatch(setViewModeAction(constants.MODE_ARBITRARY_PLANE)), - m: () => { + + if (segmentationLayer == null) { + return; + } + + const segmentationLayerName = segmentationLayer.name; + const isSegmentationDisabled = + Store.getState().datasetConfiguration.layers[segmentationLayerName].isDisabled; + Store.dispatch( + updateLayerSettingAction(segmentationLayerName, "isDisabled", !isSegmentationDisabled), + ); + } + + const isInViewMode = + Store.getState().temporaryConfiguration.controlMode === ControlModeEnum.VIEW; + + const editRelatedHandlers: KeyboardShortcutNoLoopedHandlerMap = + { + [GeneralEditingKeyboardShortcuts.SAVE]: { + onPressed: (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + Model.forceSave(); + }, + }, + // Undo + [GeneralEditingKeyboardShortcuts.UNDO]: { + onPressed: (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + Store.dispatch(undoAction()); + }, + }, + [GeneralEditingKeyboardShortcuts.REDO]: { + onPressed: (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + Store.dispatch(redoAction()); + }, + }, + }; + + // Wrapped in a function to ensure each time the map is used, a new instance of getHandleToggleSegmentation + // is created and thus "leastRecentlyUsedSegmentationLayer" not being shared between key binding maps. + const keyboardShortcutsHandlerMapForController: ControllerKeyboardHandlerIdMap = { + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_PLANE]: { + onPressed: () => { + Store.dispatch(setViewModeAction(constants.MODE_PLANE_TRACING)); + }, + }, + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY]: { + onPressed: () => { + Store.dispatch(setViewModeAction(constants.MODE_ARBITRARY)); + }, + }, + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY_PLANE]: { + onPressed: () => { + Store.dispatch(setViewModeAction(constants.MODE_ARBITRARY_PLANE)); + }, + }, + [GeneralKeyboardShortcuts.CYCLE_VIEWMODE]: { + onPressed: () => { // rotate allowed modes const state = Store.getState(); const isProofreadingActive = state.uiInformation.activeTool === AnnotationTool.PROOFREAD; @@ -240,65 +316,40 @@ class Controller extends PureComponent { const index = (allowedModes.indexOf(currentViewMode) + 1) % allowedModes.length; Store.dispatch(setViewModeAction(allowedModes[index])); }, - "super + s": (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - Model.forceSave(); - }, - "ctrl + s": (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - Model.forceSave(); - }, - // Undo - "super + z": (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - Store.dispatch(undoAction()); - }, - "ctrl + z": () => Store.dispatch(undoAction()), - // Redo - "super + y": (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - Store.dispatch(redoAction()); - }, - "ctrl + y": () => Store.dispatch(redoAction()), - }); - } - - let leastRecentlyUsedSegmentationLayer: DataLayer | null = null; - - extend(keyboardControls, { - // In the long run this should probably live in a user script - "3": function toggleSegmentationOpacity() { - let segmentationLayer = Model.getVisibleSegmentationLayer(); - - if (segmentationLayer != null) { - // If there is a visible segmentation layer, disable and remember it. - leastRecentlyUsedSegmentationLayer = segmentationLayer; - } else if (leastRecentlyUsedSegmentationLayer != null) { - // If no segmentation layer is visible, use the least recently toggled - // layer (note that toggling the layer via the switch-button won't update - // the local variable here). - segmentationLayer = leastRecentlyUsedSegmentationLayer; - } else { - // As a fallback, simply use some segmentation layer - segmentationLayer = Model.getSomeSegmentationLayer(); - } - - if (segmentationLayer == null) { - return; - } - - const segmentationLayerName = segmentationLayer.name; - const isSegmentationDisabled = - Store.getState().datasetConfiguration.layers[segmentationLayerName].isDisabled; - Store.dispatch( - updateLayerSettingAction(segmentationLayerName, "isDisabled", !isSegmentationDisabled), - ); }, + [GeneralKeyboardShortcuts.TOGGLE_SEGMENTATION]: { + onPressed: toggleSegmentationOpacity, + ...(isInViewMode ? {} : editRelatedHandlers), + }, + }; + return keyboardShortcutsHandlerMapForController; + } + + initKeyboard() { + // avoid scrolling while pressing space + document.addEventListener("keydown", (event: KeyboardEvent) => { + if ( + (event.which === 32 || event.which === 18 || (event.which >= 37 && event.which <= 40)) && + isNoElementFocused() + ) { + event.preventDefault(); + } }); + this.reloadKeyboardShortcuts(); + this.unsubscribeKeyboardListener = app.vent.on("refreshKeyboardShortcuts", () => + this.reloadKeyboardShortcuts(), + ); + } + + reloadKeyboardShortcuts() { + if (this.keyboardNoLoop) { + this.keyboardNoLoop.destroy(); + } + const keybindingConfig = loadKeyboardShortcuts(); + const keyboardControls = buildKeyBindingsFromConfigAndMapping( + keybindingConfig, + this.getKeyboardShortcutsHandlerMap(), + ); this.keyboardNoLoop = new InputKeyboardNoLoop(keyboardControls); } diff --git a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts index ee7a5a3875..7cd89c5bf1 100644 --- a/frontend/javascripts/viewer/controller/combinations/tool_controls.ts +++ b/frontend/javascripts/viewer/controller/combinations/tool_controls.ts @@ -1,9 +1,11 @@ import features from "features"; -import type { ModifierKeys } from "libs/input"; +import type { ModifierKeys, MouseBindingMap } from "libs/input"; import { V3 } from "libs/mjs"; +import Toast from "libs/toast"; import { clamp } from "libs/utils"; import { document } from "libs/window"; import { Color } from "three"; +import { userSettings } from "types/schemas/user_settings.schema"; import { ContourModeEnum, type OrthoView, @@ -41,7 +43,10 @@ import { handleOpenContextMenu, handleSelectNode, maybeGetNodeIdFromPosition, + moveAlongDirection, moveNode, + toPrecedingNode, + toSubsequentNode, } from "viewer/controller/combinations/skeleton_handlers"; import { changeBrushSizeIfBrushIsActiveBy, @@ -55,21 +60,35 @@ import { handlePickCell, } from "viewer/controller/combinations/volume_handlers"; import getSceneController from "viewer/controller/scene_controller_provider"; +import { getActiveMagIndexForLayer } from "viewer/model/accessors/flycam_accessor"; import { AnnotationTool, isBrushTool } from "viewer/model/accessors/tool_accessor"; import { calculateGlobalPos } from "viewer/model/accessors/view_mode_accessor"; import { enforceActiveVolumeTracing, getActiveSegmentationTracing, getContourTracingMode, + getMaximumBrushSize, getSegmentColorAsHSLA, } from "viewer/model/accessors/volumetracing_accessor"; -import { finishedResizingUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; +import { + addUserBoundingBoxAction, + finishedResizingUserBoundingBoxAction, +} from "viewer/model/actions/annotation_actions"; import { minCutAgglomerateWithPositionAction, proofreadAtPosition, proofreadMergeAction, toggleSegmentInPartitionAction, } from "viewer/model/actions/proofread_actions"; +import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; +import { + createBranchPointAction, + createTreeAction, + requestDeleteBranchPointAction, + toggleAllTreesAction, + toggleInactiveTreesAction, +} from "viewer/model/actions/skeletontracing_actions"; +import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; import { hideMeasurementTooltipAction, setActiveUserBoundingBoxId, @@ -82,11 +101,29 @@ import { computeQuickSelectForPointAction, computeQuickSelectForRectAction, confirmQuickSelectAction, + createCellAction, hideBrushAction, + interpolateSegmentationLayerAction, } from "viewer/model/actions/volumetracing_actions"; -import { api } from "viewer/singletons"; +import { api, Model } from "viewer/singletons"; import Store, { type UserConfiguration } from "viewer/store"; +import { getDefaultBrushSizes } from "viewer/view/action_bar/tools/brush_presets"; import type ArbitraryView from "viewer/view/arbitrary_view"; +import type { + KeyboardShortcutLoopedHandlerMap, + KeyboardShortcutNoLoopedHandlerMap, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_types"; +import { PlaneBoundingBoxToolNoLoopedKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/plane_mode/bounding_box_tool_shortcut_constants"; +import { PlaneProofreadingToolNoLoopedKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/plane_mode/proofreading_tool_shortcut_constants"; +import { + PlaneSkeletonToolLoopedKeyboardShortcuts, + PlaneSkeletonToolNoLoopedKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/plane_mode/skeleton_tool_shortcut_constants"; +import { + PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts, + PlaneVolumeToolNoLoopedKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/plane_mode/volume_tools_shortcut_constants"; +import { showToastWarningForLargestSegmentIdMissing } from "viewer/view/largest_segment_id_modal"; import type PlaneView from "viewer/view/plane_view"; export type ActionDescriptor = { @@ -114,8 +151,32 @@ export type ActionDescriptor = { so that the returned hint of class X is only rendered if `adaptActiveToolToShortcuts` returns X. Therefore, the returned actions of a tool class should only refer to the actions of that tool class. */ -export class MoveToolController { - static getMouseControls(planeId: OrthoView, planeView: PlaneView): Record { + +abstract class ToolController { + static getMouseControls(_planeId: OrthoView, _planeView: PlaneView): MouseBindingMap { + return {}; + } + static getActionDescriptors( + _activeTool: AnnotationTool, + _userConfiguration: UserConfiguration, + _shiftKey: boolean, + _ctrlOrMetaKey: boolean, + _altKey: boolean, + _isTDViewportActive: boolean, + ): ActionDescriptor { + return { rightClick: "Context menu" }; + } + static getNoLoopedKeyboardControls(): KeyboardShortcutNoLoopedHandlerMap { + return {}; + } + static getLoopDelayedKeyboardControls(): KeyboardShortcutLoopedHandlerMap { + return {}; + } + + static onToolDeselected() {} +} +export class MoveToolController extends ToolController { + static getMouseControls(planeId: OrthoView, planeView: PlaneView): MouseBindingMap { return { scroll: (delta: number, type: ModifierKeys | null | undefined) => { switch (type) { @@ -249,11 +310,9 @@ export class MoveToolController { }; return { ...leftClickInfo, leftDrag: "Move", rightClick: "Context Menu" }; } - - static onToolDeselected() {} } -export class SkeletonToolController { - static getMouseControls(planeView: PlaneView) { +export class SkeletonToolController extends ToolController { + static getMouseControls(_planeId: OrthoView, planeView: PlaneView): MouseBindingMap { const legacyRightClick = ( position: Point2, plane: OrthoView, @@ -390,6 +449,79 @@ export class SkeletonToolController { } } + static getNoLoopedKeyboardControls(): KeyboardShortcutNoLoopedHandlerMap { + return { + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_ALL_TREES_PLANE]: { + onPressed: () => { + Store.dispatch(toggleAllTreesAction()); + }, + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_INACTIVE_TREES_PLANE]: { + onPressed: () => { + Store.dispatch(toggleInactiveTreesAction()); + }, + }, + // Delete active node + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_ACTIVE_NODE_PLANE]: { + onPressed: () => { + Store.dispatch(deleteNodeAsUserAction(Store.getState())); + }, + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_TREE_PLANE]: { + onPressed: () => { + Store.dispatch(createTreeAction()); + }, + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION]: { + onPressed: () => moveAlongDirection(), + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION_REVERSED]: { + onPressed: () => moveAlongDirection(true), + }, + // Branches + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_BRANCH_POINT_PLANE]: { + onPressed: () => { + Store.dispatch(createBranchPointAction()); + }, + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_BRANCH_POINT_PLANE]: { + onPressed: () => { + Store.dispatch(requestDeleteBranchPointAction()); + }, + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.RECENTER_ACTIVE_NODE_PLANE]: { + onPressed: () => { + api.tracing.centerNode(); + api.tracing.centerTDView(); + }, + }, + // navigate nodes + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_BACKWARD_PLANE]: { + onPressed: () => toPrecedingNode(), + }, + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_FORWARD_PLANE]: { + onPressed: () => toSubsequentNode(), + }, + }; + } + + static getLoopDelayedKeyboardControls(): KeyboardShortcutLoopedHandlerMap { + return { + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_LEFT]: { + onPressedWithRepeat: () => moveNode(-1, 0), + }, + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_RIGHT]: { + onPressedWithRepeat: () => moveNode(1, 0), + }, + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_UP]: { + onPressedWithRepeat: () => moveNode(0, -1), + }, + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_DOWN]: { + onPressedWithRepeat: () => moveNode(0, 1), + }, + }; + } + static getActionDescriptors( _activeTool: AnnotationTool, userConfiguration: UserConfiguration, @@ -442,8 +574,114 @@ export class SkeletonToolController { static onToolDeselected() {} } -export class DrawToolController { - static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): any { + +class VolumeToolController extends ToolController { + static getNoLoopedKeyboardControls(): KeyboardShortcutNoLoopedHandlerMap { + return { + [PlaneVolumeToolNoLoopedKeyboardShortcuts.CREATE_NEW_CELL]: { + onPressed: () => { + const volumeTracing = getActiveSegmentationTracing(Store.getState()); + + if (volumeTracing == null || volumeTracing.tracingId == null) { + return; + } + + if (volumeTracing.largestSegmentId != null) { + Store.dispatch( + createCellAction(volumeTracing.activeCellId, volumeTracing.largestSegmentId), + ); + } else { + showToastWarningForLargestSegmentIdMissing(volumeTracing); + } + }, + }, + [PlaneVolumeToolNoLoopedKeyboardShortcuts.INTERPOLATE_SEGMENTATION]: { + onPressed: () => { + Store.dispatch(interpolateSegmentationLayerAction()); + }, + }, + [PlaneVolumeToolNoLoopedKeyboardShortcuts.COPY_SEGMENT_ID]: { + onPressed: (event: KeyboardEvent) => { + const segmentationLayer = Model.getVisibleSegmentationLayer(); + const { additionalCoordinates } = Store.getState().flycam; + + if (!segmentationLayer) { + return; + } + + const { mousePosition } = Store.getState().temporaryConfiguration; + + if (mousePosition) { + const [x, y] = mousePosition; + const globalMousePositionRounded = calculateGlobalPos(Store.getState(), { + x, + y, + }).rounded; + const { cube } = segmentationLayer; + const mapping = event.altKey ? cube.getMapping() : null; + const hoveredId = cube.getDataValue( + globalMousePositionRounded, + additionalCoordinates, + mapping, + getActiveMagIndexForLayer(Store.getState(), segmentationLayer.name), + ); + navigator.clipboard + .writeText(String(hoveredId)) + .then(() => Toast.success(`Segment id ${hoveredId} copied to clipboard.`)); + } else { + Toast.warning("No segment under cursor."); + } + }, + }, + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_SMALL]: { + onPressed: () => { + let brushPresets = Store.getState().userConfiguration.presetBrushSizes; + if (brushPresets == null) { + const maximumBrushSize = getMaximumBrushSize(Store.getState()); + brushPresets = getDefaultBrushSizes(maximumBrushSize, userSettings.brushSize.minimum); + Store.dispatch(updateUserSettingAction("presetBrushSizes", brushPresets)); + } + Store.dispatch(updateUserSettingAction("brushSize", brushPresets.small)); + }, + }, + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_MEDIUM]: { + onPressed: () => { + let brushPresets = Store.getState().userConfiguration.presetBrushSizes; + if (brushPresets == null) { + const maximumBrushSize = getMaximumBrushSize(Store.getState()); + brushPresets = getDefaultBrushSizes(maximumBrushSize, userSettings.brushSize.minimum); + Store.dispatch(updateUserSettingAction("presetBrushSizes", brushPresets)); + } + Store.dispatch(updateUserSettingAction("brushSize", brushPresets.medium)); + }, + }, + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_LARGE]: { + onPressed: () => { + let brushPresets = Store.getState().userConfiguration.presetBrushSizes; + if (brushPresets == null) { + const maximumBrushSize = getMaximumBrushSize(Store.getState()); + brushPresets = getDefaultBrushSizes(maximumBrushSize, userSettings.brushSize.minimum); + Store.dispatch(updateUserSettingAction("presetBrushSizes", brushPresets)); + } + Store.dispatch(updateUserSettingAction("brushSize", brushPresets.large)); + }, + }, + }; + } + + static getLoopDelayedKeyboardControls(): KeyboardShortcutLoopedHandlerMap { + return { + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.DECREASE_BRUSH_SIZE]: { + onPressedWithRepeat: () => changeBrushSizeIfBrushIsActiveBy(-1), + }, + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.INCREASE_BRUSH_SIZE]: { + onPressedWithRepeat: () => changeBrushSizeIfBrushIsActiveBy(1), + }, + }; + } +} +export class DrawToolController extends VolumeToolController { + static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): MouseBindingMap { return { leftDownMove: (_delta: Point2, pos: Point2) => { handleMoveForDrawOrErase(pos); @@ -552,8 +790,8 @@ export class DrawToolController { static onToolDeselected() {} } -export class EraseToolController { - static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): any { +export class EraseToolController extends VolumeToolController { + static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): MouseBindingMap { return { leftDownMove: (_delta: Point2, pos: Point2) => { handleMoveForDrawOrErase(pos); @@ -603,8 +841,8 @@ export class EraseToolController { static onToolDeselected() {} } -export class VoxelPipetteToolController { - static getPlaneMouseControls(_planeId: OrthoView): any { +export class VoxelPipetteToolController extends ToolController { + static getPlaneMouseControls(_planeId: OrthoView): MouseBindingMap { return { mouseMove: ( _delta: Point2, @@ -651,8 +889,8 @@ export class VoxelPipetteToolController { }; } } -export class FillCellToolController { - static getPlaneMouseControls(_planeId: OrthoView): any { +export class FillCellToolController extends ToolController { + static getPlaneMouseControls(_planeId: OrthoView): MouseBindingMap { return { leftClick: (pos: Point2, plane: OrthoView, event: MouseEvent) => { const shouldPickCell = event.shiftKey && !(event.ctrlKey || event.metaKey); @@ -682,8 +920,8 @@ export class FillCellToolController { static onToolDeselected() {} } -export class BoundingBoxToolController { - static getPlaneMouseControls(planeId: OrthoView, planeView: PlaneView): any { +export class BoundingBoxToolController extends ToolController { + static getPlaneMouseControls(planeId: OrthoView, planeView: PlaneView): MouseBindingMap { let primarySelectedEdge: SelectedEdge | null | undefined = null; let secondarySelectedEdge: SelectedEdge | null | undefined = null; // Accumulator for fractional movement that gets lost to rounding @@ -752,6 +990,30 @@ export class BoundingBoxToolController { }; } + static getNoLoopedKeyboardControls(): KeyboardShortcutNoLoopedHandlerMap { + const handleReactToModifier = (event: KeyboardEvent) => { + const { viewModeData, temporaryConfiguration } = Store.getState(); + const { mousePosition } = temporaryConfiguration; + if (mousePosition == null) return; + highlightAndSetCursorOnHoveredBoundingBox( + { x: mousePosition[0], y: mousePosition[1] }, + viewModeData.plane.activeViewport, + event, + ); + }; + return { + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.CREATE_BOUNDING_BOX]: { + onPressed: () => { + Store.dispatch(addUserBoundingBoxAction()); + }, + }, + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.TOGGLE_CURSOR_STATE_FOR_MOVING]: { + onPressed: handleReactToModifier, + onReleased: handleReactToModifier, + }, + }; + } + static getActionDescriptors( _activeTool: AnnotationTool, _userConfiguration: UserConfiguration, @@ -776,8 +1038,8 @@ export class BoundingBoxToolController { } } -export class QuickSelectToolController { - static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): any { +export class QuickSelectToolController extends VolumeToolController { + static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): MouseBindingMap { let startPos: Vector3 | null = null; let currentPos: Vector3 | null = null; let isDragging = false; @@ -921,10 +1183,10 @@ function getDoubleClickGuard() { return doubleClickGuard; } -export class LineMeasurementToolController { +export class LineMeasurementToolController extends ToolController { static initialPlane: OrthoView = OrthoViews.PLANE_XY; static isMeasuring = false; - static getPlaneMouseControls(): any { + static getPlaneMouseControls(): MouseBindingMap { const doubleClickGuard = getDoubleClickGuard(); const SceneController = getSceneController(); const { lineMeasurementGeometry } = SceneController; @@ -1029,10 +1291,10 @@ export class LineMeasurementToolController { } } -export class AreaMeasurementToolController { +export class AreaMeasurementToolController extends ToolController { static initialPlane: OrthoView = OrthoViews.PLANE_XY; static isMeasuring = false; - static getPlaneMouseControls(): any { + static getPlaneMouseControls(): MouseBindingMap { const SceneController = getSceneController(); const { areaMeasurementGeometry } = SceneController; const doubleClickGuard = getDoubleClickGuard(); @@ -1107,8 +1369,8 @@ export class AreaMeasurementToolController { } } -export class ProofreadToolController { - static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): any { +export class ProofreadToolController extends ToolController { + static getPlaneMouseControls(_planeId: OrthoView, planeView: PlaneView): MouseBindingMap { return { leftClick: (pos: Point2, plane: OrthoView, event: MouseEvent, isTouch: boolean) => { this.onLeftClick(planeView, pos, plane, event, isTouch); @@ -1116,6 +1378,21 @@ export class ProofreadToolController { }; } + static getNoLoopedKeyboardControls(): KeyboardShortcutNoLoopedHandlerMap { + return { + [PlaneProofreadingToolNoLoopedKeyboardShortcuts.TOGGLE_MULTICUT_MODE]: { + onPressed: () => { + const state = Store.getState(); + const isProofreadingActive = state.uiInformation.activeTool === AnnotationTool.PROOFREAD; + if (isProofreadingActive) { + const isMultiSplitActive = state.userConfiguration.isMultiSplitActive; + Store.dispatch(updateUserSettingAction("isMultiSplitActive", !isMultiSplitActive)); + } + }, + }, + }; + } + static onLeftClick( _planeView: PlaneView, pos: Point2, @@ -1229,3 +1506,37 @@ const toolToToolController = { export function getToolControllerForAnnotationTool(activeTool: AnnotationTool) { return toolToToolController[activeTool.id]; } + +export const AllNoLoopedToolKeyboardControls = { + [AnnotationTool.MOVE.id]: MoveToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.SKELETON.id]: SkeletonToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.BOUNDING_BOX.id]: BoundingBoxToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.QUICK_SELECT.id]: QuickSelectToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.PROOFREAD.id]: ProofreadToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.BRUSH.id]: DrawToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.TRACE.id]: DrawToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.ERASE_TRACE.id]: EraseToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.ERASE_BRUSH.id]: EraseToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.FILL_CELL.id]: FillCellToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.VOXEL_PIPETTE.id]: VoxelPipetteToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.LINE_MEASUREMENT.id]: LineMeasurementToolController.getNoLoopedKeyboardControls(), + [AnnotationTool.AREA_MEASUREMENT.id]: AreaMeasurementToolController.getNoLoopedKeyboardControls(), +}; + +export const AllLoopDelayedToolKeyboardControls = { + [AnnotationTool.MOVE.id]: MoveToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.SKELETON.id]: SkeletonToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.BOUNDING_BOX.id]: BoundingBoxToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.QUICK_SELECT.id]: QuickSelectToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.PROOFREAD.id]: ProofreadToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.BRUSH.id]: DrawToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.TRACE.id]: DrawToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.ERASE_TRACE.id]: EraseToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.ERASE_BRUSH.id]: EraseToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.FILL_CELL.id]: FillCellToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.VOXEL_PIPETTE.id]: VoxelPipetteToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.LINE_MEASUREMENT.id]: + LineMeasurementToolController.getLoopDelayedKeyboardControls(), + [AnnotationTool.AREA_MEASUREMENT.id]: + AreaMeasurementToolController.getLoopDelayedKeyboardControls(), +}; diff --git a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx index f43ae796d4..9f97591bc8 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/arbitrary_controller.tsx @@ -1,3 +1,4 @@ +import app from "app"; import type { ModifierKeys } from "libs/input"; import { InputKeyboard, InputKeyboardNoLoop, InputMouse } from "libs/input"; import type { Matrix4x4 } from "libs/mjs"; @@ -49,6 +50,20 @@ import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; import { api } from "viewer/singletons"; import Store from "viewer/store"; import ArbitraryView from "viewer/view/arbitrary_view"; +import { + ArbitraryControllerNavigationConfigKeyboardShortcuts, + ArbitraryControllerNavigationKeyboardShortcuts, + ArbitraryControllerNoLoopKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/arbitrary_mode_keyboard_shortcut_constants"; +import { loadKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence"; +import type { + KeyboardShortcutLoopedHandlerMap, + KeyboardShortcutNoLoopedHandlerMap, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_types"; +import { + buildKeyBindingsFromConfigAndLoopedMapping, + buildKeyBindingsFromConfigAndMapping, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_utils"; import { downloadScreenshot } from "viewer/view/rendering_utils"; import { SkeletonToolController } from "../combinations/tool_controls"; @@ -81,6 +96,7 @@ class ArbitraryController extends React.PureComponent { // @ts-expect-error ts-migrate(2564) FIXME: Property 'storePropertyUnsubscribers' has no initi... Remove this comment to see the full error message storePropertyUnsubscribers: Array<(...args: Array) => any>; + unsubscribeKeyboardListener: any = () => {}; componentDidMount() { this.input = { @@ -92,6 +108,7 @@ class ArbitraryController extends React.PureComponent { componentWillUnmount() { this.stop(); + this.unsubscribeKeyboardListener(); } initMouse(): void { @@ -146,133 +163,212 @@ class ArbitraryController extends React.PureComponent { }); } - initKeyboard(): void { + getKeyboardNavigationShortcutsHandlerMap(): KeyboardShortcutLoopedHandlerMap { const getRotateValue = () => Store.getState().userConfiguration.rotateValue; const isArbitrary = () => this.props.viewMode === constants.MODE_ARBITRARY; + const keyboardShortcutsHandlerMapForArbitraryController: KeyboardShortcutLoopedHandlerMap = + { + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITH_RECORDING]: { + onPressedWithRepeat: (timeFactor: number) => { + this.setRecord(true); + this.move(timeFactor); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITH_RECORDING]: { + onPressedWithRepeat: (timeFactor: number) => { + this.setRecord(true); + this.move(-timeFactor); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITHOUT_RECORDING]: { + onPressedWithRepeat: (timeFactor: number) => { + this.setRecord(false); + this.move(timeFactor); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITHOUT_RECORDING]: { + onPressedWithRepeat: (timeFactor: number) => { + this.setRecord(false); + this.move(-timeFactor); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_AT_CENTER]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(yawFlycamAction(getRotateValue() * timeFactor)); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_AT_CENTER]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(yawFlycamAction(-getRotateValue() * timeFactor)); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_AT_CENTER]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(pitchFlycamAction(getRotateValue() * timeFactor)); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_AT_CENTER]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(pitchFlycamAction(-getRotateValue() * timeFactor)); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_IN_DISTANCE]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(yawFlycamAction(getRotateValue() * timeFactor, isArbitrary())); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_IN_DISTANCE]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(yawFlycamAction(-getRotateValue() * timeFactor, isArbitrary())); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_IN_DISTANCE]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(pitchFlycamAction(-getRotateValue() * timeFactor, isArbitrary())); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_IN_DISTANCE]: { + onPressedWithRepeat: (timeFactor: number) => { + Store.dispatch(pitchFlycamAction(getRotateValue() * timeFactor, isArbitrary())); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_IN_ARBITRARY]: { + onPressedWithRepeat: () => { + Store.dispatch(zoomInAction()); + }, + }, + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_OUT_ARBITRARY]: { + onPressedWithRepeat: () => { + Store.dispatch(zoomOutAction()); + }, + }, + }; + return keyboardShortcutsHandlerMapForArbitraryController; + } - this.input.keyboard = new InputKeyboard({ - // KeyboardJS is sensitive to ordering (complex combos first) - // Move - space: (timeFactor: number) => { - this.setRecord(true); - this.move(timeFactor); - }, - "ctrl + space": (timeFactor: number) => { - this.setRecord(true); - this.move(-timeFactor); - }, - f: (timeFactor: number) => { - this.setRecord(false); - this.move(timeFactor); - }, - d: (timeFactor: number) => { - this.setRecord(false); - this.move(-timeFactor); - }, - // Rotate at centre - "shift + left": (timeFactor: number) => { - Store.dispatch(yawFlycamAction(getRotateValue() * timeFactor)); - }, - "shift + right": (timeFactor: number) => { - Store.dispatch(yawFlycamAction(-getRotateValue() * timeFactor)); - }, - "shift + up": (timeFactor: number) => { - Store.dispatch(pitchFlycamAction(getRotateValue() * timeFactor)); - }, - "shift + down": (timeFactor: number) => { - Store.dispatch(pitchFlycamAction(-getRotateValue() * timeFactor)); - }, - // Rotate in distance - left: (timeFactor: number) => { - Store.dispatch(yawFlycamAction(getRotateValue() * timeFactor, isArbitrary())); - }, - right: (timeFactor: number) => { - Store.dispatch(yawFlycamAction(-getRotateValue() * timeFactor, isArbitrary())); + getKeyboardNavigationConfigShortcutsHandlerMap(): KeyboardShortcutLoopedHandlerMap { + return { + [ArbitraryControllerNavigationConfigKeyboardShortcuts.INCREASE_MOVE_VALUE_ARBITRARY]: { + onPressedWithRepeat: () => this.changeMoveValue(25), }, - up: (timeFactor: number) => { - Store.dispatch(pitchFlycamAction(-getRotateValue() * timeFactor, isArbitrary())); + [ArbitraryControllerNavigationConfigKeyboardShortcuts.DECREASE_MOVE_VALUE_ARBITRARY]: { + onPressedWithRepeat: () => this.changeMoveValue(-25), }, - down: (timeFactor: number) => { - Store.dispatch(pitchFlycamAction(getRotateValue() * timeFactor, isArbitrary())); - }, - // Zoom in/out - i: () => { - Store.dispatch(zoomInAction()); - }, - o: () => { - Store.dispatch(zoomOutAction()); + }; + } + + getKeyboardNoLoopShortcutsHandlerMap(): KeyboardShortcutNoLoopedHandlerMap { + return { + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_ALL_TREES_ARBITRARY]: { + onPressed: () => { + Store.dispatch(toggleAllTreesAction()); + }, }, - }); - // Own InputKeyboard with delay for changing the Move Value, because otherwise the values changes to drastically - this.input.keyboardLoopDelayed = new InputKeyboard( - { - h: () => this.changeMoveValue(25), - g: () => this.changeMoveValue(-25), + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_INACTIVE_TREES_ARBITRARY]: { + onPressed: () => { + Store.dispatch(toggleInactiveTreesAction()); + }, }, - { - delay: Store.getState().userConfiguration.keyboardDelay, + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_ACTIVE_NODE_ARBITRARY]: { + onPressed: () => { + Store.dispatch(deleteNodeAsUserAction(Store.getState())); + }, }, - ); - this.input.keyboardNoLoop = new InputKeyboardNoLoop({ - "1": () => { - Store.dispatch(toggleAllTreesAction()); + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_TREE_ARBITRARY]: { + onPressed: () => { + Store.dispatch(createTreeAction()); + }, }, - "2": () => { - Store.dispatch(toggleInactiveTreesAction()); + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_BRANCH_POINT_ARBITRARY]: { + onPressed: () => { + this.pushBranch(); + }, }, - // Delete active node - delete: () => { - Store.dispatch(deleteNodeAsUserAction(Store.getState())); + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_BRANCH_POINT_ARBITRARY]: { + onPressed: () => { + Store.dispatch(requestDeleteBranchPointAction()); + }, }, - backspace: () => { - Store.dispatch(deleteNodeAsUserAction(Store.getState())); + [ArbitraryControllerNoLoopKeyboardShortcuts.RECENTER_ACTIVE_NODE_ARBITRARY]: { + onPressed: () => { + const state = Store.getState(); + const skeletonTracing = state.annotation.skeleton; + + if (!skeletonTracing) { + return; + } + + const activeNode = getActiveNode(skeletonTracing); + if (activeNode) { + api.tracing.centerPositionAnimated( + getNodePosition(activeNode, state), + false, + activeNode.rotation, + true, + ); + } + }, }, - c: () => { - Store.dispatch(createTreeAction()); + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_FORWARD_ARBITRARY]: { + onPressed: () => { + this.nextNode(true); + }, }, - // Branches - b: () => this.pushBranch(), - j: () => { - Store.dispatch(requestDeleteBranchPointAction()); + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_BACKWARD_ARBITRARY]: { + onPressed: () => { + this.nextNode(false); + }, }, - // Recenter active node - s: () => { - const state = Store.getState(); - const skeletonTracing = state.annotation.skeleton; - - if (!skeletonTracing) { - return; - } - - const activeNode = getActiveNode(skeletonTracing); - if (activeNode) { - api.tracing.centerPositionAnimated( - getNodePosition(activeNode, state), - false, - activeNode.rotation, - true, - ); - } + [ArbitraryControllerNoLoopKeyboardShortcuts.ROTATE_VIEW_180]: { + onPressed: () => { + Store.dispatch(yawFlycamAction(Math.PI)); + }, }, - ".": () => this.nextNode(true), - ",": () => this.nextNode(false), - // Rotate view by 180 deg - r: () => { - Store.dispatch(yawFlycamAction(Math.PI)); + [ArbitraryControllerNoLoopKeyboardShortcuts.DOWNLOAD_SCREENSHOT_ARBITRARY]: { + onPressed: downloadScreenshot, }, - // Delete active node and recenter last node - "shift + space": () => { - const skeletonTracing = Store.getState().annotation.skeleton; + }; + } - if (!skeletonTracing) { - return; - } + reloadKeyboardShortcuts() { + if (this.input.keyboard) { + this.input.keyboard.destroy(); + } + if (this.input.keyboardLoopDelayed) { + this.input.keyboardLoopDelayed.destroy(); + } + if (this.input.keyboardNoLoop) { + this.input.keyboardNoLoop.destroy(); + } + const keybindingConfig = loadKeyboardShortcuts(); + const navigationKeyboardBindings = buildKeyBindingsFromConfigAndLoopedMapping( + keybindingConfig, + this.getKeyboardNavigationShortcutsHandlerMap(), + ); + this.input.keyboard = new InputKeyboard(navigationKeyboardBindings); - Store.dispatch(deleteNodeAsUserAction(Store.getState())); - }, - q: downloadScreenshot, + const navigationConfigKeyboardBindings = buildKeyBindingsFromConfigAndLoopedMapping( + keybindingConfig, + this.getKeyboardNavigationConfigShortcutsHandlerMap(), + ); + // Own InputKeyboard with delay for changing the Move Value, because otherwise the values changes to drastically + this.input.keyboardLoopDelayed = new InputKeyboard(navigationConfigKeyboardBindings, { + delay: Store.getState().userConfiguration.keyboardDelay, }); + + const noLoopKeyboardBindings = buildKeyBindingsFromConfigAndMapping( + keybindingConfig, + this.getKeyboardNoLoopShortcutsHandlerMap(), + ); + this.input.keyboardNoLoop = new InputKeyboardNoLoop(noLoopKeyboardBindings); + } + + initKeyboard(): void { + this.reloadKeyboardShortcuts(); + this.unsubscribeKeyboardListener = app.vent.on("refreshKeyboardShortcuts", () => + this.reloadKeyboardShortcuts(), + ); } setRecord(record: boolean): void { diff --git a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx index b9d4b5f83c..d436117be2 100644 --- a/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx +++ b/frontend/javascripts/viewer/controller/viewmodes/plane_controller.tsx @@ -1,10 +1,15 @@ -import { InputKeyboard, InputKeyboardNoLoop, InputMouse, type MouseBindingMap } from "libs/input"; -import Toast from "libs/toast"; +import app from "app"; +import { + InputKeyboard, + InputKeyboardNoLoop, + InputMouse, + type KeyBindingLoopMap, + type MouseBindingMap, + type MouseEventHandler, +} from "libs/input"; import { isNoElementFocused, waitForElementWithId } from "libs/utils"; import { document } from "libs/window"; -import intersection from "lodash-es/intersection"; import union from "lodash-es/union"; -import type React from "react"; import { PureComponent } from "react"; import { connect } from "react-redux"; import { userSettings } from "types/schemas/user_settings.schema"; @@ -12,12 +17,8 @@ import type { OrthoView, OrthoViewMap } from "viewer/constants"; import { OrthoViews, OrthoViewValuesWithoutTDView } from "viewer/constants"; import { moveU, moveV, moveW, zoom } from "viewer/controller/combinations/move_handlers"; import { - moveAlongDirection, - moveNode, - toPrecedingNode, - toSubsequentNode, -} from "viewer/controller/combinations/skeleton_handlers"; -import { + AllLoopDelayedToolKeyboardControls, + AllNoLoopedToolKeyboardControls, AreaMeasurementToolController, BoundingBoxToolController, DrawToolController, @@ -30,79 +31,45 @@ import { SkeletonToolController, VoxelPipetteToolController, } from "viewer/controller/combinations/tool_controls"; -import { changeBrushSizeIfBrushIsActiveBy } from "viewer/controller/combinations/volume_handlers"; import getSceneController, { getSceneControllerOrNull, } from "viewer/controller/scene_controller_provider"; import TDController from "viewer/controller/td_controller"; -import { - getActiveMagIndexForLayer, - getMoveOffset, - getPosition, -} from "viewer/model/accessors/flycam_accessor"; +import { getMoveOffset, getPosition } from "viewer/model/accessors/flycam_accessor"; import { AnnotationTool, type AnnotationToolId } from "viewer/model/accessors/tool_accessor"; -import { calculateGlobalPos } from "viewer/model/accessors/view_mode_accessor"; -import { - getActiveSegmentationTracing, - getMaximumBrushSize, -} from "viewer/model/accessors/volumetracing_accessor"; -import { addUserBoundingBoxAction } from "viewer/model/actions/annotation_actions"; +import { getMaximumBrushSize } from "viewer/model/accessors/volumetracing_accessor"; import { pitchFlycamAction, rollFlycamAction, yawFlycamAction, } from "viewer/model/actions/flycam_actions"; import { updateUserSettingAction } from "viewer/model/actions/settings_actions"; -import { - createBranchPointAction, - createTreeAction, - requestDeleteBranchPointAction, - toggleAllTreesAction, - toggleInactiveTreesAction, -} from "viewer/model/actions/skeletontracing_actions"; -import { deleteNodeAsUserAction } from "viewer/model/actions/skeletontracing_actions_with_effects"; -import { - cycleToolAction, - enterAction, - escapeAction, - setToolAction, -} from "viewer/model/actions/ui_actions"; +import { cycleToolAction, enterAction, escapeAction } from "viewer/model/actions/ui_actions"; import { setViewportAction } from "viewer/model/actions/view_mode_actions"; -import { - createCellAction, - interpolateSegmentationLayerAction, -} from "viewer/model/actions/volumetracing_actions"; import Dimensions from "viewer/model/dimensions"; import dimensions, { type DimensionIndices } from "viewer/model/dimensions"; import { listenToStoreProperty } from "viewer/model/helpers/listener_helpers"; -import { api, Model } from "viewer/singletons"; import type { BrushPresets, StoreAnnotation, WebknossosState } from "viewer/store"; import Store from "viewer/store"; import { getDefaultBrushSizes } from "viewer/view/action_bar/tools/brush_presets"; -import { showToastWarningForLargestSegmentIdMissing } from "viewer/view/largest_segment_id_modal"; +import { loadKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence"; +import type { + KeyboardShortcutLoopedHandlerMap, + KeyboardShortcutNoLoopedHandlerMap, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_types"; +import { + buildKeyBindingsFromConfigAndLoopedMapping, + buildKeyBindingsFromConfigAndLoopedMappingForTools, + buildKeyBindingsFromConfigAndMapping, + buildKeyBindingsFromConfigAndMappingForTools, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_utils"; +import { + PlaneControllerLoopDelayedNavigationKeyboardShortcuts, + PlaneControllerLoopedNavigationKeyboardShortcuts, + PlaneControllerNoLoopGeneralKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/plane_mode/general_keyboard_shortcuts_constants"; import PlaneView from "viewer/view/plane_view"; import { downloadScreenshot } from "viewer/view/rendering_utils"; -import { highlightAndSetCursorOnHoveredBoundingBox } from "../combinations/bounding_box_handlers"; - -function ensureNonConflictingHandlers( - skeletonControls: Record, - volumeControls: Record, - proofreadControls?: Record, -): void { - const conflictingHandlers = intersection( - Object.keys(skeletonControls), - Object.keys(volumeControls), - proofreadControls ? Object.keys(proofreadControls) : [], - ); - - if (conflictingHandlers.length > 0) { - throw new Error( - `There are unsolved conflicts between skeleton, volume and proofread controller: ${conflictingHandlers.join( - ", ", - )}`, - ); - } -} const FIXED_ROTATION_STEP = Math.PI / 2; @@ -114,136 +81,12 @@ const cycleToolsBackwards = () => { Store.dispatch(cycleToolAction(true)); }; -const setTool = (tool: AnnotationTool) => { - Store.dispatch(setToolAction(tool)); -}; - type StateProps = { annotation: StoreAnnotation; activeTool: AnnotationTool; }; type Props = StateProps; -class SkeletonKeybindings { - static getKeyboardControls() { - return { - "1": () => Store.dispatch(toggleAllTreesAction()), - "2": () => Store.dispatch(toggleInactiveTreesAction()), - // Delete active node - delete: () => Store.dispatch(deleteNodeAsUserAction(Store.getState())), - backspace: () => Store.dispatch(deleteNodeAsUserAction(Store.getState())), - c: () => Store.dispatch(createTreeAction()), - e: () => moveAlongDirection(), - r: () => moveAlongDirection(true), - // Branches - b: () => Store.dispatch(createBranchPointAction()), - j: () => Store.dispatch(requestDeleteBranchPointAction()), - s: () => { - api.tracing.centerNode(); - api.tracing.centerTDView(); - }, - // navigate nodes - "ctrl + ,": () => toPrecedingNode(), - "ctrl + .": () => toSubsequentNode(), - }; - } - - static getLoopedKeyboardControls() { - return { - "ctrl + left": () => moveNode(-1, 0), - "ctrl + right": () => moveNode(1, 0), - "ctrl + up": () => moveNode(0, -1), - "ctrl + down": () => moveNode(0, 1), - }; - } - - static getExtendedKeyboardControls() { - return { s: () => setTool(AnnotationTool.SKELETON) }; - } -} - -class VolumeKeybindings { - static getKeyboardControls() { - return { - c: () => { - const volumeTracing = getActiveSegmentationTracing(Store.getState()); - - if (volumeTracing == null || volumeTracing.tracingId == null) { - return; - } - - if (volumeTracing.largestSegmentId != null) { - Store.dispatch( - createCellAction(volumeTracing.activeCellId, volumeTracing.largestSegmentId), - ); - } else { - showToastWarningForLargestSegmentIdMissing(volumeTracing); - } - }, - v: () => { - Store.dispatch(interpolateSegmentationLayerAction()); - }, - }; - } - - static getExtendedKeyboardControls() { - return { - b: () => setTool(AnnotationTool.BRUSH), - e: () => setTool(AnnotationTool.ERASE_BRUSH), - l: () => setTool(AnnotationTool.TRACE), - r: () => setTool(AnnotationTool.ERASE_TRACE), - f: () => setTool(AnnotationTool.FILL_CELL), - p: () => setTool(AnnotationTool.VOXEL_PIPETTE), - q: () => setTool(AnnotationTool.QUICK_SELECT), - o: () => setTool(AnnotationTool.PROOFREAD), - }; - } -} - -class BoundingBoxKeybindings { - static getKeyboardControls() { - return { - c: () => Store.dispatch(addUserBoundingBoxAction()), - meta: BoundingBoxKeybindings.createKeyDownAndUpHandler(), - ctrl: BoundingBoxKeybindings.createKeyDownAndUpHandler(), - }; - } - - static handleUpdateCursor = (event: KeyboardEvent) => { - const { viewModeData, temporaryConfiguration } = Store.getState(); - const { mousePosition } = temporaryConfiguration; - if (mousePosition == null) return; - highlightAndSetCursorOnHoveredBoundingBox( - { x: mousePosition[0], y: mousePosition[1] }, - viewModeData.plane.activeViewport, - event, - ); - }; - - static getExtendedKeyboardControls() { - return { x: () => setTool(AnnotationTool.BOUNDING_BOX) }; - } - - static createKeyDownAndUpHandler() { - return (event: KeyboardEvent) => BoundingBoxKeybindings.handleUpdateCursor(event); - } -} - -class ProofreadingKeybindings { - static getKeyboardControls() { - return { - m: () => { - const state = Store.getState(); - const isProofreadingActive = state.uiInformation.activeTool === AnnotationTool.PROOFREAD; - if (isProofreadingActive) { - const isMultiSplitActive = state.userConfiguration.isMultiSplitActive; - Store.dispatch(updateUserSettingAction("isMultiSplitActive", !isMultiSplitActive)); - } - }, - }; - } -} - function createDelayAwareMoveHandler( multiplier: number, useDynamicSpaceDirection: boolean = false, @@ -314,6 +157,8 @@ class PlaneController extends PureComponent { storePropertyUnsubscribers: Array<(...args: Array) => any> = []; isStarted: boolean = false; + // TODOM: improve typing + unsubscribeKeyboardListener: any = () => {}; componentDidMount() { this.input = { @@ -356,7 +201,7 @@ class PlaneController extends PureComponent { getPlaneMouseControls(planeId: OrthoView): MouseBindingMap { const moveControls = MoveToolController.getMouseControls(planeId, this.planeView); - const skeletonControls = SkeletonToolController.getMouseControls(this.planeView); + const skeletonControls = SkeletonToolController.getMouseControls(planeId, this.planeView); const drawControls = DrawToolController.getPlaneMouseControls(planeId, this.planeView); const eraseControls = EraseToolController.getPlaneMouseControls(planeId, this.planeView); const fillCellControls = FillCellToolController.getPlaneMouseControls(planeId); @@ -376,18 +221,21 @@ class PlaneController extends PureComponent { const lineMeasurementControls = LineMeasurementToolController.getPlaneMouseControls(); const areaMeasurementControls = AreaMeasurementToolController.getPlaneMouseControls(); + const getMouseControlKeys = (controls: MouseBindingMap) => + Object.keys(controls) as (keyof MouseBindingMap)[]; + const allControlKeys = union( - Object.keys(moveControls), - Object.keys(skeletonControls), - Object.keys(drawControls), - Object.keys(eraseControls), - Object.keys(fillCellControls), - Object.keys(voxelPipetteControls), - Object.keys(boundingBoxControls), - Object.keys(quickSelectControls), - Object.keys(proofreadControls), - Object.keys(lineMeasurementControls), - Object.keys(areaMeasurementControls), + getMouseControlKeys(moveControls), + getMouseControlKeys(skeletonControls), + getMouseControlKeys(drawControls), + getMouseControlKeys(eraseControls), + getMouseControlKeys(fillCellControls), + getMouseControlKeys(voxelPipetteControls), + getMouseControlKeys(boundingBoxControls), + getMouseControlKeys(quickSelectControls), + getMouseControlKeys(proofreadControls), + getMouseControlKeys(lineMeasurementControls), + getMouseControlKeys(areaMeasurementControls), ); const controls: MouseBindingMap = {}; @@ -395,7 +243,6 @@ class PlaneController extends PureComponent { for (const controlKey of allControlKeys) { controls[controlKey] = this.createToolDependentMouseHandler({ [AnnotationTool.MOVE.id]: moveControls[controlKey], - // @ts-expect-error ts-migrate(7053) FIXME: Element implicitly has an 'any' type because expre... Remove this comment to see the full error message [AnnotationTool.SKELETON.id]: skeletonControls[controlKey], [AnnotationTool.BRUSH.id]: drawControls[controlKey], [AnnotationTool.TRACE.id]: drawControls[controlKey], @@ -414,8 +261,7 @@ class PlaneController extends PureComponent { return controls; } - initKeyboard(): void { - // avoid scrolling while pressing space + getLoopedHandlerMap(): KeyboardShortcutLoopedHandlerMap { const axisIndexToRotation = { 0: pitchFlycamAction, 1: yawFlycamAction, @@ -438,7 +284,149 @@ class PlaneController extends PureComponent { const rotationAction = axisIndexToRotation[viewportIndices[dimensionIndex]]; Store.dispatch(rotationAction(rotationAngle)); }; + return { + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_LEFT]: { + onPressedWithRepeat: (timeFactor: number) => + moveU(-getMoveOffset(Store.getState(), timeFactor)), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_RIGHT]: { + onPressedWithRepeat: (timeFactor: number) => + moveU(getMoveOffset(Store.getState(), timeFactor)), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_UP]: { + onPressedWithRepeat: (timeFactor: number) => + moveV(-getMoveOffset(Store.getState(), timeFactor)), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_DOWN]: { + onPressedWithRepeat: (timeFactor: number) => + moveV(getMoveOffset(Store.getState(), timeFactor)), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_LEFT]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 1, false), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_RIGHT]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 1, true), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_UP]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 0, false), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_DOWN]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 0, true), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_LEFT]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 2, false), + }, + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_RIGHT]: { + onPressedWithRepeat: (timeFactor: number) => rotateViewportAware(timeFactor, 2, true), + }, + } as KeyboardShortcutLoopedHandlerMap; + } + + getLoopDelayedHandlerMap(): KeyboardShortcutLoopedHandlerMap { + return { + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_FORWARD]: { + onPressedWithRepeat: createDelayAwareMoveHandler(5, true), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_BACKWARD]: { + onPressedWithRepeat: createDelayAwareMoveHandler(-5, true), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD]: { + onPressedWithRepeat: createDelayAwareMoveHandler(-1), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD]: { + onPressedWithRepeat: createDelayAwareMoveHandler(1), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD_DIRECTION_AWARE]: { + onPressedWithRepeat: createDelayAwareMoveHandler(1, true), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD_DIRECTION_AWARE]: { + onPressedWithRepeat: createDelayAwareMoveHandler(-1, true), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_IN_PLANE]: { + onPressedWithRepeat: () => zoom(1, false), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_OUT_PLANE]: { + onPressedWithRepeat: () => zoom(-1, false), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.INCREASE_MOVE_VALUE_PLANE]: { + onPressedWithRepeat: () => this.changeMoveValue(25), + }, + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.DECREASE_MOVE_VALUE_PLANE]: { + onPressedWithRepeat: () => this.changeMoveValue(-25), + }, + } as KeyboardShortcutLoopedHandlerMap; + } + + getNoLoopHandlerMap(): KeyboardShortcutNoLoopedHandlerMap { + return { + [PlaneControllerNoLoopGeneralKeyboardShortcuts.DOWNLOAD_SCREENSHOT]: { + onPressed: () => downloadScreenshot(), + }, + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS]: { + onPressed: () => cycleTools(), + }, + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS_BACKWARDS]: { + onPressed: () => cycleToolsBackwards(), + }, + } as KeyboardShortcutNoLoopedHandlerMap; + } + + reloadKeyboardShortcuts() { + // destroy existing keyboards + this.input.keyboard?.destroy(); + this.input.keyboardLoopDelayed?.destroy(); + this.input.keyboardNoLoop?.destroy(); + + const keybindingConfig = loadKeyboardShortcuts(); + + // looped keyboard + const loopedControllerBindings = buildKeyBindingsFromConfigAndLoopedMapping( + keybindingConfig, + this.getLoopedHandlerMap(), + ); + this.input.keyboard = new InputKeyboard(loopedControllerBindings); + + const toolDependentLoopedBindings = buildKeyBindingsFromConfigAndLoopedMappingForTools( + keybindingConfig, + AllLoopDelayedToolKeyboardControls, + ); + + // delayed looped keyboard + const delayedControllerBindings = buildKeyBindingsFromConfigAndLoopedMapping( + keybindingConfig, + this.getLoopDelayedHandlerMap(), + ); + const withAdditionalActions: KeyBindingLoopMap = { + ...delayedControllerBindings, + ...toolDependentLoopedBindings, + // Enter & Escape need to be separate due to being constant and not configurable. + enter: { + onPressedWithRepeat: (_, _isOriginalEvent, event) => Store.dispatch(enterAction(event)), + }, + esc: { onPressedWithRepeat: () => Store.dispatch(escapeAction()) }, + }; + this.input.keyboardLoopDelayed = new InputKeyboard(withAdditionalActions, { + delay: Store.getState().userConfiguration.keyboardDelay, + }); + + // no-loop keyboard + const noLoopControllerBindings = buildKeyBindingsFromConfigAndMapping( + keybindingConfig, + this.getNoLoopHandlerMap(), + ); + const toolDependentNoLoopedBindings = buildKeyBindingsFromConfigAndMappingForTools( + keybindingConfig, + AllNoLoopedToolKeyboardControls, + ); + this.input.keyboardNoLoop = new InputKeyboardNoLoop( + { ...noLoopControllerBindings, ...toolDependentNoLoopedBindings }, + {}, + ); + } + + initKeyboard(): void { + // avoid scrolling while pressing space document.addEventListener("keydown", (event: KeyboardEvent) => { if ( (event.which === 32 || event.which === 18 || (event.which >= 37 && event.which <= 40)) && @@ -447,72 +435,16 @@ class PlaneController extends PureComponent { event.preventDefault(); } }); - this.input.keyboard = new InputKeyboard({ - // Move - left: (timeFactor) => moveU(-getMoveOffset(Store.getState(), timeFactor)), - right: (timeFactor) => moveU(getMoveOffset(Store.getState(), timeFactor)), - up: (timeFactor) => moveV(-getMoveOffset(Store.getState(), timeFactor)), - down: (timeFactor) => moveV(getMoveOffset(Store.getState(), timeFactor)), - "shift + left": (timeFactor: number) => rotateViewportAware(timeFactor, 1, false), - "shift + right": (timeFactor: number) => rotateViewportAware(timeFactor, 1, true), - "shift + up": (timeFactor: number) => rotateViewportAware(timeFactor, 0, false), - "shift + down": (timeFactor: number) => rotateViewportAware(timeFactor, 0, true), - "alt + left": (timeFactor: number) => rotateViewportAware(timeFactor, 2, false), - "alt + right": (timeFactor: number) => rotateViewportAware(timeFactor, 2, true), - }); - const { - baseControls: notLoopedKeyboardControls, - keyUpControls, - extendedControls: extendedNotLoopedKeyboardControls, - } = this.getNotLoopedKeyboardControls(); - const loopedKeyboardControls = this.getLoopedKeyboardControls(); - ensureNonConflictingHandlers(notLoopedKeyboardControls, loopedKeyboardControls); - this.input.keyboardLoopDelayed = new InputKeyboard( - { - // KeyboardJS is sensitive to ordering (complex combos first) - "shift + i": () => changeBrushSizeIfBrushIsActiveBy(-1), - "shift + o": () => changeBrushSizeIfBrushIsActiveBy(1), - "shift + f": createDelayAwareMoveHandler(5, true), - "shift + d": createDelayAwareMoveHandler(-5, true), - "shift + space": createDelayAwareMoveHandler(-1), - "ctrl + space": createDelayAwareMoveHandler(-1), - enter: (_, _isOriginalEvent, event) => Store.dispatch(enterAction(event)), - esc: () => Store.dispatch(escapeAction()), - space: createDelayAwareMoveHandler(1), - f: createDelayAwareMoveHandler(1, true), - d: createDelayAwareMoveHandler(-1, true), - // Zoom in/out - i: () => zoom(1, false), - o: () => zoom(-1, false), - h: () => this.changeMoveValue(25), - g: () => this.changeMoveValue(-25), - ...loopedKeyboardControls, - }, - { - delay: Store.getState().userConfiguration.keyboardDelay, - }, - ); - const ignoredTimeFactor = 0; - const rotateViewportAwareFixedWithoutTiming = ( - dimensionIndex: DimensionIndices, - oppositeDirection: boolean, - ) => rotateViewportAware(ignoredTimeFactor, dimensionIndex, oppositeDirection, true); - this.input.keyboardNoLoop = new InputKeyboardNoLoop( - { - ...notLoopedKeyboardControls, - // Directly rotate by 90 degrees. - "ctrl + shift + left": () => rotateViewportAwareFixedWithoutTiming(1, false), - "ctrl + shift + right": () => rotateViewportAwareFixedWithoutTiming(1, true), - "ctrl + shift + up": () => rotateViewportAwareFixedWithoutTiming(0, false), - "ctrl + shift + down": () => rotateViewportAwareFixedWithoutTiming(0, true), - "ctrl + alt + left": () => rotateViewportAwareFixedWithoutTiming(2, false), - "ctrl + alt + right": () => rotateViewportAwareFixedWithoutTiming(0, true), - }, - {}, - extendedNotLoopedKeyboardControls, - keyUpControls, + // create keyboards from persisted config + this.reloadKeyboardShortcuts(); + + // register refresh listener + this.unsubscribeKeyboardListener = app.vent.on("refreshKeyboardShortcuts", () => + this.reloadKeyboardShortcuts(), ); + + // keep existing listener for keyboardDelay change this.storePropertyUnsubscribers.push( listenToStoreProperty( (state) => state.userConfiguration.keyboardDelay, @@ -558,117 +490,6 @@ class PlaneController extends PureComponent { return; } - getNotLoopedKeyboardControls(): Record { - const baseControls = { - "ctrl + i": (event: React.KeyboardEvent) => { - const segmentationLayer = Model.getVisibleSegmentationLayer(); - const { additionalCoordinates } = Store.getState().flycam; - - if (!segmentationLayer) { - return; - } - - const { mousePosition } = Store.getState().temporaryConfiguration; - - if (mousePosition) { - const [x, y] = mousePosition; - const globalMousePositionRounded = calculateGlobalPos(Store.getState(), { - x, - y, - }).rounded; - const { cube } = segmentationLayer; - const mapping = event.altKey ? cube.getMapping() : null; - const hoveredId = cube.getDataValue( - globalMousePositionRounded, - additionalCoordinates, - mapping, - getActiveMagIndexForLayer(Store.getState(), segmentationLayer.name), - ); - navigator.clipboard - .writeText(String(hoveredId)) - .then(() => Toast.success(`Segment id ${hoveredId} copied to clipboard.`)); - } else { - Toast.warning("No segment under cursor."); - } - }, - q: downloadScreenshot, - w: cycleTools, - "shift + w": cycleToolsBackwards, - }; - - let extendedControls = { - m: () => setTool(AnnotationTool.MOVE), - 1: () => this.handleUpdateBrushSize("small"), - 2: () => this.handleUpdateBrushSize("medium"), - 3: () => this.handleUpdateBrushSize("large"), - ...BoundingBoxKeybindings.getExtendedKeyboardControls(), - }; - - // TODO: Find a nicer way to express this, while satisfying flow - const emptyDefaultHandler = { - c: null, - }; - const { c: skeletonCHandler, ...skeletonControls } = - this.props.annotation.skeleton != null - ? SkeletonKeybindings.getKeyboardControls() - : emptyDefaultHandler; - const { c: volumeCHandler, ...volumeControls } = - this.props.annotation.volumes.length > 0 - ? VolumeKeybindings.getKeyboardControls() - : emptyDefaultHandler; - const { - c: boundingBoxCHandler, - meta: boundingBoxMetaHandler, - ctrl: boundingBoxCtrlHandler, - } = BoundingBoxKeybindings.getKeyboardControls(); - const proofreadingControls = - this.props.annotation.volumes.length > 0 - ? ProofreadingKeybindings.getKeyboardControls() - : emptyDefaultHandler; - ensureNonConflictingHandlers(skeletonControls, volumeControls, proofreadingControls); - const extendedSkeletonControls = - this.props.annotation.skeleton != null - ? SkeletonKeybindings.getExtendedKeyboardControls() - : {}; - const extendedVolumeControls = - this.props.annotation.volumes.length > 0 != null - ? VolumeKeybindings.getExtendedKeyboardControls() - : {}; - ensureNonConflictingHandlers(extendedSkeletonControls, extendedVolumeControls); - const extendedAnnotationControls = { ...extendedSkeletonControls, ...extendedVolumeControls }; - ensureNonConflictingHandlers(extendedAnnotationControls, extendedControls); - extendedControls = { ...extendedControls, ...extendedAnnotationControls }; - - return { - baseControls: { - ...baseControls, - ...skeletonControls, - ...volumeControls, - ...proofreadingControls, - c: this.createToolDependentKeyboardHandler( - skeletonCHandler, - volumeCHandler, - boundingBoxCHandler, - ), - ctrl: this.createToolDependentKeyboardHandler(null, null, boundingBoxCtrlHandler), - meta: this.createToolDependentKeyboardHandler(null, null, boundingBoxMetaHandler), - }, - keyUpControls: { - ctrl: this.createToolDependentKeyboardHandler(null, null, boundingBoxCtrlHandler), - meta: this.createToolDependentKeyboardHandler(null, null, boundingBoxMetaHandler), - }, - extendedControls, - }; - } - - getLoopedKeyboardControls() { - // Note that this code needs to be adapted in case the VolumeHandlers also starts to expose - // looped keyboard controls. For the hybrid case, these two controls would need t be combined then. - return this.props.annotation.skeleton != null - ? SkeletonKeybindings.getLoopedKeyboardControls() - : {}; - } - init(): void { const { clippingDistance } = Store.getState().userConfiguration; getSceneController().setClippingDistance(clippingDistance); @@ -688,6 +509,13 @@ class PlaneController extends PureComponent { this.destroyInput(); } + // unregister keyboard refresh listener + try { + this.unsubscribeKeyboardListener(); + } catch (_e) { + // ignore if already removed + } + // SceneController will already be null, if the user left the dataset view // because componentWillUnmount will trigger earlier for outer components and // later for inner components. The outer component TracingLayoutView is responsible @@ -776,17 +604,19 @@ class PlaneController extends PureComponent { }; } - createToolDependentMouseHandler( - toolToHandlerMap: Record) => any>, - ): (...args: Array) => any { - return (...args) => { + createToolDependentMouseHandler( + toolToHandlerMap: Record, + ): (...args: Parameters) => void { + return (...args: Parameters) => { const tool = this.props.activeTool; - const handler = toolToHandlerMap[tool.id]; - const fallbackHandler = toolToHandlerMap[AnnotationTool.MOVE.id]; + const handler = toolToHandlerMap[tool.id] as T | undefined; + const fallbackHandler = toolToHandlerMap[AnnotationTool.MOVE.id] as T | undefined; if (handler != null) { + // @ts-expect-error Typescript is too strict to allow spreading the parameters here. handler(...args); } else if (fallbackHandler != null) { + // @ts-expect-error Typescript is too strict to allow spreading the parameters here. fallbackHandler(...args); } }; diff --git a/frontend/javascripts/viewer/default_state.ts b/frontend/javascripts/viewer/default_state.ts index 88ca0b1e35..4ea9e43662 100644 --- a/frontend/javascripts/viewer/default_state.ts +++ b/frontend/javascripts/viewer/default_state.ts @@ -251,6 +251,7 @@ const defaultState: WebknossosState = { showAddScriptModal: false, showMergeAnnotationModal: false, showZarrPrivateLinksModal: false, + showKeyboardShortcutConfigModal: true, showPythonClientModal: false, aIJobDrawerState: "invisible", showRenderAnimationModal: false, diff --git a/frontend/javascripts/viewer/merger_mode.ts b/frontend/javascripts/viewer/merger_mode.ts index d4a25ac553..e5ba917ace 100644 --- a/frontend/javascripts/viewer/merger_mode.ts +++ b/frontend/javascripts/viewer/merger_mode.ts @@ -531,8 +531,10 @@ export async function enableMergerMode( ); // Register the additional key handler unregisterKeyHandlers.push( - api.utils.registerKeyHandler("9", () => { - changeOpacity(mergerModeState); + api.utils.registerKeyHandler("9", { + onPressed: () => { + changeOpacity(mergerModeState); + }, }), ); // wait for preprocessing the already existing trees before returning diff --git a/frontend/javascripts/viewer/model/actions/ui_actions.ts b/frontend/javascripts/viewer/model/actions/ui_actions.ts index 09d15e00f1..fa16d03874 100644 --- a/frontend/javascripts/viewer/model/actions/ui_actions.ts +++ b/frontend/javascripts/viewer/model/actions/ui_actions.ts @@ -42,6 +42,9 @@ type SetRenderAnimationModalVisibilityAction = ReturnType< >; type SetUserScriptsModalVisibilityAction = ReturnType; type SetZarrLinksModalVisibilityAction = ReturnType; +type SetKeyboardShortcutConfigModalVisibilityAction = ReturnType< + typeof setKeyboardShortcutConfigModalVisibilityAction +>; type SetMergeModalVisibilityAction = ReturnType; export type UiAction = @@ -63,6 +66,7 @@ export type UiAction = | SetMergeModalVisibilityAction | SetUserScriptsModalVisibilityAction | SetZarrLinksModalVisibilityAction + | SetKeyboardShortcutConfigModalVisibilityAction | SetBusyBlockingInfoAction | AllowSagaWhileBusyAction | DisallowSagaWhileBusyAction @@ -166,6 +170,11 @@ export const setZarrLinksModalVisibilityAction = (visible: boolean) => type: "SET_ZARR_LINKS_MODAL_VISIBILITY", visible, }) as const; +export const setKeyboardShortcutConfigModalVisibilityAction = (visible: boolean) => + ({ + type: "SET_KEYBOARD_SHORTCUT_CONFIG_MODAL_VISIBILITY", + visible, + }) as const; export const setBusyBlockingInfoAction = (isBusy: boolean, reason?: string) => ({ type: "SET_BUSY_BLOCKING_INFO_ACTION", diff --git a/frontend/javascripts/viewer/model/reducers/ui_reducer.ts b/frontend/javascripts/viewer/model/reducers/ui_reducer.ts index f93785a13e..9fbcd4ee60 100644 --- a/frontend/javascripts/viewer/model/reducers/ui_reducer.ts +++ b/frontend/javascripts/viewer/model/reducers/ui_reducer.ts @@ -129,6 +129,12 @@ function UiReducer(state: WebknossosState, action: Action): WebknossosState { }); } + case "SET_KEYBOARD_SHORTCUT_CONFIG_MODAL_VISIBILITY": { + return updateKey(state, "uiInformation", { + showKeyboardShortcutConfigModal: action.visible, + }); + } + case "SET_CREATE_ANIMATION_MODAL_VISIBILITY": { return updateKey(state, "uiInformation", { showRenderAnimationModal: action.visible, diff --git a/frontend/javascripts/viewer/store.ts b/frontend/javascripts/viewer/store.ts index 740b40d0f7..c442a6ede1 100644 --- a/frontend/javascripts/viewer/store.ts +++ b/frontend/javascripts/viewer/store.ts @@ -559,6 +559,7 @@ type UiInformation = { readonly showMergeAnnotationModal: boolean; readonly showZarrPrivateLinksModal: boolean; readonly showAddScriptModal: boolean; + readonly showKeyboardShortcutConfigModal: boolean; readonly aIJobDrawerState: StartAiJobDrawerState; readonly showRenderAnimationModal: boolean; readonly activeTool: AnnotationTool; diff --git a/frontend/javascripts/viewer/view/action_bar/tracing_modals.tsx b/frontend/javascripts/viewer/view/action_bar/tracing_modals.tsx index 7706838406..4905452287 100644 --- a/frontend/javascripts/viewer/view/action_bar/tracing_modals.tsx +++ b/frontend/javascripts/viewer/view/action_bar/tracing_modals.tsx @@ -6,6 +6,7 @@ import { getAntdTheme, getThemeFromUser } from "theme"; import Constants from "viewer/constants"; import { setDownloadModalVisibilityAction, + setKeyboardShortcutConfigModalVisibilityAction, setMergeModalVisibilityAction, setRenderAnimationModalVisibilityAction, setShareModalVisibilityAction, @@ -16,6 +17,7 @@ import DownloadModalView from "viewer/view/action_bar/download_modal_view"; import MergeModalView from "viewer/view/action_bar/merge_modal_view"; import ShareModalView from "viewer/view/action_bar/share_modal_view"; import UserScriptsModalView from "viewer/view/action_bar/user_scripts_modal_view"; +import KeyboardShortcutConfigModal from "../keyboard_shortcuts/keyboard_shortcut_config_modal"; import CreateAnimationModal from "./create_animation_modal"; import { PrivateLinksModal } from "./private_links_view"; @@ -38,6 +40,9 @@ function TracingModals() { const showZarrPrivateLinksModal = useWkSelector( (state) => state.uiInformation.showZarrPrivateLinksModal, ); + const showKeyboardShortcutConfigModal = useWkSelector( + (state) => state.uiInformation.showKeyboardShortcutConfigModal, + ); const viewMode = useWkSelector((state) => state.temporaryConfiguration.viewMode); const handleShareClose = useCallback(() => { @@ -64,6 +69,10 @@ function TracingModals() { dispatch(setRenderAnimationModalVisibilityAction(false)); }, [dispatch]); + const handleKeyboardShortcutConfigClose = useCallback(() => { + dispatch(setKeyboardShortcutConfigModalVisibilityAction(false)); + }, [dispatch]); + const modals = useMemo(() => { const isSkeletonMode = Constants.MODES_SKELETON.includes(viewMode); const modalList = []; @@ -102,6 +111,14 @@ function TracingModals() { />, ); + modalList.push( + , + ); + if (restrictions.allowDownload) { modalList.push( { Store.dispatch(setZarrLinksModalVisibilityAction(true)); }; +const handleShowKeyboardShortcutConfigModal = () => { + Store.dispatch(setKeyboardShortcutConfigModalVisibilityAction(true)); +}; + const handleChangeLockedStateOfAnnotation = async ( isLocked: boolean, annotationId: string, @@ -220,6 +225,13 @@ export const useTracingViewMenuItems = ( }); } + menuItems.push({ + key: "Keyboard Shortcuts", + onClick: handleShowKeyboardShortcutConfigModal, + icon: , + label: "Keyboard Shortcuts", + }); + if (layoutMenu != null) menuItems.push(layoutMenu); if (restrictions.allowSave && !task) { diff --git a/frontend/javascripts/viewer/view/input_catcher.tsx b/frontend/javascripts/viewer/view/input_catcher.tsx index 9aa7b1f342..958f35e1ef 100644 --- a/frontend/javascripts/viewer/view/input_catcher.tsx +++ b/frontend/javascripts/viewer/view/input_catcher.tsx @@ -143,6 +143,7 @@ function InputCatcher({ const activeTool = useWkSelector((state) => state.uiInformation.activeTool); + // TODOM: how to handle this? This is not part of the "keyboard shortcut structure". const isShiftPressed = useKeyPress("Shift"); const isControlOrMetaPressed = useKeyPress("ControlOrMeta"); const isAltPressed = useKeyPress("Alt"); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/arbitrary_mode_keyboard_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/arbitrary_mode_keyboard_shortcut_constants.ts new file mode 100644 index 0000000000..85ebf111fe --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/arbitrary_mode_keyboard_shortcut_constants.ts @@ -0,0 +1,203 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "./keyboard_shortcut_types"; + +export enum ArbitraryControllerNavigationKeyboardShortcuts { + MOVE_FORWARD_WITH_RECORDING = "MOVE_FORWARD_WITH_RECORDING", + MOVE_BACKWARD_WITH_RECORDING = "MOVE_BACKWARD_WITH_RECORDING", + MOVE_FORWARD_WITHOUT_RECORDING = "MOVE_FORWARD_WITHOUT_RECORDING", + MOVE_BACKWARD_WITHOUT_RECORDING = "MOVE_BACKWARD_WITHOUT_RECORDING", + YAW_FLYCAM_POSITIVE_AT_CENTER = "YAW_FLYCAM_POSITIVE_AT_CENTER", + YAW_FLYCAM_INVERTED_AT_CENTER = "YAW_FLYCAM_INVERTED_AT_CENTER", + PITCH_FLYCAM_POSITIVE_AT_CENTER = "PITCH_FLYCAM_POSITIVE_AT_CENTER", + PITCH_FLYCAM_INVERTED_AT_CENTER = "PITCH_FLYCAM_INVERTED_AT_CENTER", + YAW_FLYCAM_POSITIVE_IN_DISTANCE = "YAW_FLYCAM_POSITIVE_IN_DISTANCE", + YAW_FLYCAM_INVERTED_IN_DISTANCE = "YAW_FLYCAM_INVERTED_IN_DISTANCE", + PITCH_FLYCAM_POSITIVE_IN_DISTANCE = "PITCH_FLYCAM_POSITIVE_IN_DISTANCE", + PITCH_FLYCAM_INVERTED_IN_DISTANCE = "PITCH_FLYCAM_INVERTED_IN_DISTANCE", + ZOOM_IN_ARBITRARY = "ZOOM_IN_ARBITRARY", + ZOOM_OUT_ARBITRARY = "ZOOM_OUT_ARBITRARY", +} + +export const DEFAULT_ARBITRARY_NAVIGATION_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITH_RECORDING]: [[["Space"]]], + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITH_RECORDING]: [ + [["Control", "Space"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITHOUT_RECORDING]: [[["f"]]], + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITHOUT_RECORDING]: [[["d"]]], + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_AT_CENTER]: [ + [["Shift", "ArrowLeft"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_AT_CENTER]: [ + [["Shift", "ArrowRight"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_AT_CENTER]: [ + [["Shift", "ArrowUp"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_AT_CENTER]: [ + [["Shift", "ArrowDown"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_IN_DISTANCE]: [ + [["ArrowLeft"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_IN_DISTANCE]: [ + [["ArrowRight"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_IN_DISTANCE]: [ + [["ArrowDown"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_IN_DISTANCE]: [ + [["ArrowUp"]], + ], + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_IN_ARBITRARY]: [[["i"]]], + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_OUT_ARBITRARY]: [[["o"]]], + } as const; + +export const ArbitraryNavigationKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITH_RECORDING]: + "Move forward (while creating nodes)", + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITH_RECORDING]: + "Move backward (while creating nodes)", + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_FORWARD_WITHOUT_RECORDING]: + "Move forward (without creating nodes)", + [ArbitraryControllerNavigationKeyboardShortcuts.MOVE_BACKWARD_WITHOUT_RECORDING]: + "Move backward (without creating nodes)", + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_AT_CENTER]: + "Rotate left around center", + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_AT_CENTER]: + "Rotate right around center", + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_AT_CENTER]: + "Rotate upwards around center", + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_AT_CENTER]: + "Rotate downwards around center", + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_POSITIVE_IN_DISTANCE]: + "Rotate left in distance", + [ArbitraryControllerNavigationKeyboardShortcuts.YAW_FLYCAM_INVERTED_IN_DISTANCE]: + "Rotate right in distance", + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_POSITIVE_IN_DISTANCE]: + "Rotate up in distance", + [ArbitraryControllerNavigationKeyboardShortcuts.PITCH_FLYCAM_INVERTED_IN_DISTANCE]: + "Rotate down in distance", + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_IN_ARBITRARY]: "Zoom in", + [ArbitraryControllerNavigationKeyboardShortcuts.ZOOM_OUT_ARBITRARY]: "Zoom out", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.ARBITRARY_NAVIGATION, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.ARBITRARY_MODE, + }, + ] as [ArbitraryControllerNavigationKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum ArbitraryControllerNavigationConfigKeyboardShortcuts { + INCREASE_MOVE_VALUE_ARBITRARY = "INCREASE_MOVE_VALUE_ARBITRARY", + DECREASE_MOVE_VALUE_ARBITRARY = "DECREASE_MOVE_VALUE_ARBITRARY", +} + +export const DEFAULT_ARBITRARY_NAVIGATION_CONFIG_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [ArbitraryControllerNavigationConfigKeyboardShortcuts.INCREASE_MOVE_VALUE_ARBITRARY]: [[["h"]]], + [ArbitraryControllerNavigationConfigKeyboardShortcuts.DECREASE_MOVE_VALUE_ARBITRARY]: [[["g"]]], + } as const; +export const ArbitraryNavigationConfigKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + { + [ArbitraryControllerNavigationConfigKeyboardShortcuts.INCREASE_MOVE_VALUE_ARBITRARY]: { + description: "Increase move value", + domain: KeyboardShortcutDomain.ARBITRARY_NAVIGATION, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.ARBITRARY_MODE, + }, + [ArbitraryControllerNavigationConfigKeyboardShortcuts.DECREASE_MOVE_VALUE_ARBITRARY]: { + description: "Decrease move value", + domain: KeyboardShortcutDomain.ARBITRARY_NAVIGATION, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.ARBITRARY_MODE, + }, + }; + +export enum ArbitraryControllerNoLoopKeyboardShortcuts { + TOGGLE_ALL_TREES_ARBITRARY = "TOGGLE_ALL_TREES_ARBITRARY", + TOGGLE_INACTIVE_TREES_ARBITRARY = "TOGGLE_INACTIVE_TREES_ARBITRARY", + DELETE_ACTIVE_NODE_ARBITRARY = "DELETE_ACTIVE_NODE", + CREATE_TREE_ARBITRARY = "CREATE_TREE", + CREATE_BRANCH_POINT_ARBITRARY = "CREATE_BRANCH_POINT", + DELETE_BRANCH_POINT_ARBITRARY = "DELETE_BRANCH_POINT", + RECENTER_ACTIVE_NODE_ARBITRARY = "RECENTER_ACTIVE_NODE", + NEXT_NODE_FORWARD_ARBITRARY = "NEXT_NODE_FORWARD", + NEXT_NODE_BACKWARD_ARBITRARY = "NEXT_NODE_BACKWARD", + ROTATE_VIEW_180 = "ROTATE_VIEW_180", + DOWNLOAD_SCREENSHOT_ARBITRARY = "DOWNLOAD_SCREENSHOT", +} + +export const DEFAULT_ARBITRARY_NO_LOOP_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_ALL_TREES_ARBITRARY]: [[["1"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_INACTIVE_TREES_ARBITRARY]: [[["2"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_ACTIVE_NODE_ARBITRARY]: [ + [["Delete"]], + [["Backspace"]], + [["Shift", "Space"]], + ], + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_TREE_ARBITRARY]: [[["c"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_BRANCH_POINT_ARBITRARY]: [[["b"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_BRANCH_POINT_ARBITRARY]: [[["j"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.RECENTER_ACTIVE_NODE_ARBITRARY]: [[["s"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_FORWARD_ARBITRARY]: [[["."]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_BACKWARD_ARBITRARY]: [[[","]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.ROTATE_VIEW_180]: [[["r"]]], + [ArbitraryControllerNoLoopKeyboardShortcuts.DOWNLOAD_SCREENSHOT_ARBITRARY]: [[["q"]]], + } as const; + +export const ArbitraryNoLoopKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription = { + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_ALL_TREES_ARBITRARY]: "Toggle all trees", + [ArbitraryControllerNoLoopKeyboardShortcuts.TOGGLE_INACTIVE_TREES_ARBITRARY]: + "Toggle inactive trees", + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_ACTIVE_NODE_ARBITRARY]: + "Delete active node", + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_TREE_ARBITRARY]: "Create new tree", + [ArbitraryControllerNoLoopKeyboardShortcuts.CREATE_BRANCH_POINT_ARBITRARY]: + "Create branch point", + [ArbitraryControllerNoLoopKeyboardShortcuts.DELETE_BRANCH_POINT_ARBITRARY]: + "Delete branch point", + [ArbitraryControllerNoLoopKeyboardShortcuts.RECENTER_ACTIVE_NODE_ARBITRARY]: + "Recenter active node", + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_FORWARD_ARBITRARY]: "Jump to next node", + [ArbitraryControllerNoLoopKeyboardShortcuts.NEXT_NODE_BACKWARD_ARBITRARY]: + "Jump to previous node", + [ArbitraryControllerNoLoopKeyboardShortcuts.ROTATE_VIEW_180]: "Rotate view 180 degrees", + [ArbitraryControllerNoLoopKeyboardShortcuts.DOWNLOAD_SCREENSHOT_ARBITRARY]: + "Download screenshot", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.ARBITRARY_EDITING, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.ARBITRARY_MODE, + }, + ] as [ArbitraryControllerNoLoopKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_config_modal.tsx b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_config_modal.tsx new file mode 100644 index 0000000000..95d203aa17 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_config_modal.tsx @@ -0,0 +1,443 @@ +import { CloseOutlined, EditOutlined, PlusOutlined, RollbackOutlined } from "@ant-design/icons"; +import { + Button, + Flex, + Input, + Modal, + Space, + Switch, + Table, + Tabs, + type TabsProps, + Typography, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import app from "app"; +import Toast from "libs/toast"; +import { isEqual } from "lodash-es"; +import { useMemo, useState } from "react"; +import { + ALL_KEYBOARD_SHORTCUT_META_INFOS, + getAllDefaultKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_constants"; +import { + loadKeyboardShortcuts, + saveKeyboardShortcuts, + validateShortcutMapText, +} from "./keyboard_shortcut_persistence"; +import { type KeyboardComboChain, KeyboardShortcutDomain } from "./keyboard_shortcut_types"; +import { keyComboChainToUiElements } from "./keyboard_shortcut_utils"; +import { ShortcutRecorderModal } from "./shortcut_recorder_modal"; + +const { Text, Title } = Typography; + +export type ShortcutConfigModalProps = { + isOpen: boolean; + onClose: () => void; +}; +type TableDataEntry = { + key: string; + combos: KeyboardComboChain[]; + handlerId: string; + domain: string; + description: string; +}; +type ShortcutDomainTableProps = { + domainName: KeyboardShortcutDomain; + tableData: TableDataEntry[]; + columns: ColumnsType; +}; + +const ShortcutDomainTable: React.FC = ({ + domainName, + tableData, + columns, +}) => { + return ( +
+ {domainName} Shortcuts + + + ); +}; + +export default function KeyboardShortcutConfigModal({ isOpen, onClose }: ShortcutConfigModalProps) { + const [isJsonView, setIsJsonView] = useState(false); + const [isRecorderOpen, setIsRecorderOpen] = useState(false); + const [recorderTargetHandlerId, setRecorderTargetHandlerId] = useState(null); + const [recorderEditingKeyCombo, setRecorderEditingKeyCombo] = useState( + null, + ); + const [localConfig, setLocalConfig] = useState(loadKeyboardShortcuts()); + + const [jsonString, setJsonString] = useState(() => JSON.stringify(localConfig, null, 2)); + const [jsonError, setJsonError] = useState(null); + + const handleRemoveComboChain = (handlerId: string, comboChain: string[][]) => { + setLocalConfig((prevConfig) => { + const updatedCombos = prevConfig[handlerId].filter((c) => c !== comboChain); + + if (updatedCombos.length > 0) { + return { ...prevConfig, [handlerId]: updatedCombos }; + } + // @ts-expect-error TODOM fix + const defaultCombo = getAllDefaultKeyboardShortcuts()[handlerId] as KeyboardComboChain[]; + Toast.info("Restored the default shortcut to keep the shortcut reachable."); + return { ...prevConfig, [handlerId]: defaultCombo }; + }); + }; + + // Convert config into grouped table rows + const tableDataMap = useMemo(() => { + const domainToEntries: Record = { + [KeyboardShortcutDomain.GENERAL]: [], + [KeyboardShortcutDomain.GENERAL_EDITING]: [], + [KeyboardShortcutDomain.GENERAL_LAYOUT]: [], + [KeyboardShortcutDomain.GENERAL_COMMENT_TAB]: [], + [KeyboardShortcutDomain.ARBITRARY_NAVIGATION]: [], + [KeyboardShortcutDomain.ARBITRARY_EDITING]: [], + [KeyboardShortcutDomain.PLANE_NAVIGATION]: [], + [KeyboardShortcutDomain.PLANE_CONFIGURATIONS]: [], + [KeyboardShortcutDomain.PLANE_TOOL_SWITCHING]: [], + [KeyboardShortcutDomain.PLANE_SKELETON_TOOL]: [], + [KeyboardShortcutDomain.PLANE_VOLUME_TOOL]: [], + [KeyboardShortcutDomain.PLANE_BOUNDING_BOX_TOOL]: [], + [KeyboardShortcutDomain.PLANE_PROOFREADING_TOOL]: [], + }; + Object.entries(localConfig).forEach(([handlerId, keyCombos]) => { + const metaInfo = + ALL_KEYBOARD_SHORTCUT_META_INFOS[ + handlerId as keyof typeof ALL_KEYBOARD_SHORTCUT_META_INFOS + ]; + domainToEntries[metaInfo.domain].push({ + key: handlerId, + combos: keyCombos, + handlerId, + domain: metaInfo.domain, + description: metaInfo.description, + }); + }); + return domainToEntries; + }, [localConfig]); + + const columns = [ + { + title: "Shortcuts", + dataIndex: "combos", + key: "combos", + render: (combos: KeyboardComboChain[], record: TableDataEntry) => ( +
+
+ {combos.map((comboChain, index) => ( +
+ {keyComboChainToUiElements(comboChain)} +
+ ))} +
+ +
+
+
+ ), + }, + { + title: "Action", + dataIndex: "handlerId", + width: 400, + key: "handlerId", + render: (handlerId: string) => { + const metaInfo = + ALL_KEYBOARD_SHORTCUT_META_INFOS[ + handlerId as keyof typeof ALL_KEYBOARD_SHORTCUT_META_INFOS + ]; + return metaInfo?.description ?? handlerId; + }, + }, + ]; + + // Handle JSON editor changes + const onChangeJson = (value: string) => { + setJsonString(value); + const { valid, errors, parsed } = validateShortcutMapText(value); + if (valid && parsed) { + setLocalConfig(parsed); + } + if (valid) { + setJsonError(null); + } else { + setJsonError(errors.join("\n")); + } + }; + + const handleSave = () => { + if (isJsonView && jsonError) { + return; + } + saveKeyboardShortcuts(localConfig); + app.vent.emit("refreshKeyboardShortcuts"); + onClose(); + }; + const onReset = () => { + setLocalConfig(getAllDefaultKeyboardShortcuts()); + }; + + const shortcutsTabItems: TabsProps["items"] = [ + { + key: "general", + label: "General", + children: ( + <> + + + + + + ), + }, + { + key: "arbitrary", + label: "Arbitrary Mode", + children: ( + <> + + + + ), + }, + { + key: "plane", + label: "Plane Mode", + children: ( + <> + + + + ), + }, + { + key: "plane_tools", + label: "Plane Mode - Tool Activation", + children: ( + <> + + + ), + }, + { + key: "tools", + label: "Plane Mode Tools", + children: ( + <> + + + + + + ), + }, + ]; + + return ( + + + + Edit Mode + + + + + {/* TABLE VIEW */} + {!isJsonView && } + + {/* JSON VIEW */} + {isJsonView && ( + <> + onChangeJson(e.target.value)} + style={{ fontFamily: "monospace" }} + /> + {jsonError && JSON Error: {jsonError}} + + )} + + + + + + + {/*Keyboard Shortcut Recorder*/} + { + setIsRecorderOpen(false); + setRecorderTargetHandlerId(null); + }} + onSave={(newComboChain) => { + if (!recorderTargetHandlerId) return; + + // Build updated map: remove any existing key(s) that pointed to this handlerId + const updated: Record = {}; + const updatedKeyComboChains = recorderEditingKeyCombo + ? localConfig[recorderTargetHandlerId].map((keyComboChain) => + isEqual(keyComboChain, recorderEditingKeyCombo) ? newComboChain : keyComboChain, + ) + : [...localConfig[recorderTargetHandlerId], newComboChain]; + for (const [handlerId, keyComboChains] of Object.entries(localConfig)) { + if (handlerId !== recorderTargetHandlerId) { + updated[handlerId] = keyComboChains; + } + } + // assign the new combo + updated[recorderTargetHandlerId] = updatedKeyComboChains; + + setLocalConfig(updated); + setJsonString(JSON.stringify(updated, null, 2)); + + setIsRecorderOpen(false); + setRecorderTargetHandlerId(null); + }} + /> + + ); +} diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_constants.ts new file mode 100644 index 0000000000..4c88744afb --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_constants.ts @@ -0,0 +1,307 @@ +import { cloneDeep } from "lodash-es"; +import type { Mutable } from "types/type_utils"; +import { + ArbitraryControllerNavigationConfigKeyboardShortcuts, + ArbitraryControllerNavigationKeyboardShortcuts, + ArbitraryControllerNoLoopKeyboardShortcuts, + ArbitraryNavigationConfigKeyboardShortcutMetaInfo, + ArbitraryNavigationKeyboardShortcutMetaInfo, + ArbitraryNoLoopKeyboardShortcutMetaInfo, + DEFAULT_ARBITRARY_NAVIGATION_CONFIG_KEYBOARD_SHORTCUTS, + DEFAULT_ARBITRARY_NAVIGATION_KEYBOARD_SHORTCUTS, + DEFAULT_ARBITRARY_NO_LOOP_KEYBOARD_SHORTCUTS, +} from "./arbitrary_mode_keyboard_shortcut_constants"; +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "./keyboard_shortcut_types"; +import { + DEFAULT_PLANE_BOUNDING_BOX_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + PlaneBoundingBoxToolNoLoopedKeyboardShortcutMetaInfo, + PlaneBoundingBoxToolNoLoopedKeyboardShortcuts, +} from "./plane_mode/bounding_box_tool_shortcut_constants"; +import { + DEFAULT_PLANE_LOOP_DELAYED_NAVIGATION_KEYBOARD_SHORTCUTS, + DEFAULT_PLANE_LOOPED_NAVIGATION_KEYBOARD_SHORTCUTS, + DEFAULT_PLANE_NO_LOOPED_GENERAL_KEYBOARD_SHORTCUTS, + PlaneControllerLoopDelayedNavigationKeyboardShortcuts, + PlaneControllerLoopedNavigationKeyboardShortcuts, + PlaneControllerNoLoopGeneralKeyboardShortcuts, + PlaneGeneralKeyboardShortcutMetaInfo, + PlaneLoopDelayedNavigationKeyboardShortcutMetaInfo, + PlaneNavigationKeyboardShortcutMetaInfo, +} from "./plane_mode/general_keyboard_shortcuts_constants"; +import { + DEFAULT_PLANE_PROOFREADING_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + PlaneProofreadingToolNoLoopedKeyboardShortcutMetaInfo, + PlaneProofreadingToolNoLoopedKeyboardShortcuts, +} from "./plane_mode/proofreading_tool_shortcut_constants"; +import { + DEFAULT_PLANE_SKELETON_TOOL_LOOPED_KEYBOARD_SHORTCUTS, + DEFAULT_PLANE_SKELETON_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + PlaneSkeletonToolLoopedKeyboardShortcutMetaInfo, + PlaneSkeletonToolLoopedKeyboardShortcuts, + PlaneSkeletonToolNoLoopedKeyboardShortcutMetaInfo, + PlaneSkeletonToolNoLoopedKeyboardShortcuts, +} from "./plane_mode/skeleton_tool_shortcut_constants"; +import { + DEFAULT_PLANE_VOLUME_TOOL_LOOP_DELAYED_CONFIG_KEYBOARD_SHORTCUTS, + DEFAULT_PLANE_VOLUME_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + PlaneVolumeToolLoopDelayedConfigKeyboardShortcutMetaInfo, + PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts, + PlaneVolumeToolNoLoopedKeyboardShortcutMetaInfo, + PlaneVolumeToolNoLoopedKeyboardShortcuts, +} from "./plane_mode/volume_tools_shortcut_constants"; +import { + DEFAULT_PLANE_TOOL_SWITCHING_KEYBOARD_SHORTCUTS, + PlaneControllerToolSwitchingKeyboardShortcuts, + PlaneToolSwitchingKeyboardShortcutMetaInfo, +} from "./plane_mode/tool_switching_shortcut_constants"; + +// ----------------------------------------------------- Shortcuts used by controller.ts ----------------------------------------------------------------- +export enum GeneralKeyboardShortcuts { + SWITCH_VIEWMODE_PLANE = "SWITCH_VIEWMODE_PLANE", + SWITCH_VIEWMODE_ARBITRARY = "SWITCH_VIEWMODE_ARBITRARY", + SWITCH_VIEWMODE_ARBITRARY_PLANE = "SWITCH_VIEWMODE_ARBITRARY_PLANE", + CYCLE_VIEWMODE = "CYCLE_VIEWMODE", + TOGGLE_SEGMENTATION = "TOGGLE_SEGMENTATION", +} + +export const DEFAULT_GENERAL_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = { + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_PLANE]: [[["Shift", "1"]]], + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY]: [[["Shift", "2"]]], + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY_PLANE]: [[["Shift", "3"]]], + [GeneralKeyboardShortcuts.CYCLE_VIEWMODE]: [[["m"]]], + [GeneralKeyboardShortcuts.TOGGLE_SEGMENTATION]: [[["3"]]], +} as const; + +const GeneralKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_PLANE]: "View in plane mode", + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY]: "View in plane arbitrary mode", + [GeneralKeyboardShortcuts.SWITCH_VIEWMODE_ARBITRARY_PLANE]: + "View in plane arbitrary plane mode", + [GeneralKeyboardShortcuts.CYCLE_VIEWMODE]: "Cycle through viewing modes", + [GeneralKeyboardShortcuts.TOGGLE_SEGMENTATION]: "Toggle segmentation layer", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.GENERAL, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.GENERAL, + }, + ] as [GeneralKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum GeneralEditingKeyboardShortcuts { + SAVE = "SAVE", + UNDO = "UNDO", + REDO = "REDO", +} + +export const DEFAULT_GENERAL_EDITING_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [GeneralEditingKeyboardShortcuts.SAVE]: [[["Meta", "s"]], [["Control", "s"]]], + [GeneralEditingKeyboardShortcuts.UNDO]: [[["Meta", "z"]], [["Control", "z"]]], + [GeneralEditingKeyboardShortcuts.REDO]: [[["Meta", "y"]], [["Control", "y"]]], + } as const; + +const GeneralEditingKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [GeneralEditingKeyboardShortcuts.SAVE]: "Save annotation changes", + [GeneralEditingKeyboardShortcuts.UNDO]: "Undo latest annotation change", + [GeneralEditingKeyboardShortcuts.REDO]: "Redo latest annotation change", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.GENERAL_EDITING, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.GENERAL, + }, + ] as [GeneralEditingKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum GeneralLayoutKeyboardShortcuts { + MAXIMIZE = "MAXIMIZE", + TOGGLE_LEFT_BORDER = "TOGGLE_LEFT_BORDER", + TOGGLE_RIGHT_BORDER = "TOGGLE_RIGHT_BORDER", +} + +export const DEFAULT_GENERAL_LAYOUT_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [GeneralLayoutKeyboardShortcuts.MAXIMIZE]: [[["."]]], + [GeneralLayoutKeyboardShortcuts.TOGGLE_LEFT_BORDER]: [[["k"]]], + [GeneralLayoutKeyboardShortcuts.TOGGLE_RIGHT_BORDER]: [[["l"]]], + } as const; + +export const GeneralLayoutKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [GeneralLayoutKeyboardShortcuts.MAXIMIZE]: "Toggle Viewport Maximization", + [GeneralLayoutKeyboardShortcuts.TOGGLE_LEFT_BORDER]: "Toggle left Sidebars", + [GeneralLayoutKeyboardShortcuts.TOGGLE_RIGHT_BORDER]: "Toggle right Sidebars", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.GENERAL_LAYOUT, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.GENERAL, + }, + ] as [GeneralLayoutKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum CommentsTabKeyboardShortcuts { + NEXT_COMMENT = "NEXT_COMMENT", + PREVIOUS_COMMENT = "PREVIOUS_COMMENT", +} + +export const DEFAULT_COMMENTS_TAB_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [CommentsTabKeyboardShortcuts.NEXT_COMMENT]: [[["n"]]], + [CommentsTabKeyboardShortcuts.PREVIOUS_COMMENT]: [[["p"]]], + } as const; + +export const CommentsTabKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [CommentsTabKeyboardShortcuts.NEXT_COMMENT]: "Select next comment", + [CommentsTabKeyboardShortcuts.PREVIOUS_COMMENT]: "Select previous comment", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.GENERAL_COMMENT_TAB, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.GENERAL, + }, + ] as [CommentsTabKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +// Defining the collision domains in a hierarchical tree like order. +// KeyboardShortcutCollisionEntityName.GENERAL is the root from where traversing should be done to get all collision entity names to compare with. + +// The hierarchy is: +// General +// / \ +// / \ +// Arbitrary Plane +// / | \ +// / | \ +// MoveTool Skeleton Tool ()...all other tools) +export const KeyboardShortcutCollisionHierarchy: Record< + KeyboardShortcutCollisionEntityName, + KeyboardShortcutCollisionEntityName[] +> = { + [KeyboardShortcutCollisionEntityName.GENERAL]: [ + KeyboardShortcutCollisionEntityName.ARBITRARY_MODE, + KeyboardShortcutCollisionEntityName.PLANE_MODE, + ], + [KeyboardShortcutCollisionEntityName.ARBITRARY_MODE]: [], + [KeyboardShortcutCollisionEntityName.PLANE_MODE]: [ + KeyboardShortcutCollisionEntityName.PLANE_SKELETON_TOOL, + KeyboardShortcutCollisionEntityName.PLANE_VOLUME_TOOL, + KeyboardShortcutCollisionEntityName.PLANE_BOUNDING_BOX_TOOL, + KeyboardShortcutCollisionEntityName.PLANE_PROOFREADING_TOOL, + ], + [KeyboardShortcutCollisionEntityName.PLANE_SKELETON_TOOL]: [], + [KeyboardShortcutCollisionEntityName.PLANE_VOLUME_TOOL]: [], + [KeyboardShortcutCollisionEntityName.PLANE_BOUNDING_BOX_TOOL]: [], + [KeyboardShortcutCollisionEntityName.PLANE_PROOFREADING_TOOL]: [], +}; + +// ----- combined objects, types and so on ------------------- +export const ALL_KEYBOARD_HANDLER_IDS = [ + ...Object.values(GeneralKeyboardShortcuts), + ...Object.values(GeneralEditingKeyboardShortcuts), + ...Object.values(GeneralLayoutKeyboardShortcuts), + ...Object.values(CommentsTabKeyboardShortcuts), + ...Object.values(ArbitraryControllerNavigationKeyboardShortcuts), + ...Object.values(ArbitraryControllerNavigationConfigKeyboardShortcuts), + ...Object.values(ArbitraryControllerNoLoopKeyboardShortcuts), + ...Object.values(PlaneControllerLoopedNavigationKeyboardShortcuts), + ...Object.values(PlaneControllerLoopDelayedNavigationKeyboardShortcuts), + ...Object.values(PlaneControllerNoLoopGeneralKeyboardShortcuts), + ...Object.values(PlaneControllerToolSwitchingKeyboardShortcuts), + ...Object.values(PlaneSkeletonToolLoopedKeyboardShortcuts), + ...Object.values(PlaneSkeletonToolNoLoopedKeyboardShortcuts), + ...Object.values(PlaneVolumeToolNoLoopedKeyboardShortcuts), + ...Object.values(PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts), + ...Object.values(PlaneBoundingBoxToolNoLoopedKeyboardShortcuts), + ...Object.values(PlaneProofreadingToolNoLoopedKeyboardShortcuts), +] as const; + +export const ALL_KEYBOARD_SHORTCUT_META_INFOS: KeyboardShortcutHandlerMetaInfoMap = { + ...GeneralKeyboardShortcutMetaInfo, + ...GeneralEditingKeyboardShortcutMetaInfo, + ...GeneralLayoutKeyboardShortcutMetaInfo, + ...CommentsTabKeyboardShortcutMetaInfo, + ...ArbitraryNavigationKeyboardShortcutMetaInfo, + ...ArbitraryNavigationConfigKeyboardShortcutMetaInfo, + ...ArbitraryNoLoopKeyboardShortcutMetaInfo, + ...PlaneNavigationKeyboardShortcutMetaInfo, + ...PlaneLoopDelayedNavigationKeyboardShortcutMetaInfo, + ...PlaneGeneralKeyboardShortcutMetaInfo, + ...PlaneToolSwitchingKeyboardShortcutMetaInfo, + ...PlaneSkeletonToolNoLoopedKeyboardShortcutMetaInfo, + ...PlaneSkeletonToolLoopedKeyboardShortcutMetaInfo, + ...PlaneVolumeToolNoLoopedKeyboardShortcutMetaInfo, + ...PlaneVolumeToolLoopDelayedConfigKeyboardShortcutMetaInfo, + ...PlaneBoundingBoxToolNoLoopedKeyboardShortcutMetaInfo, + ...PlaneProofreadingToolNoLoopedKeyboardShortcutMetaInfo, +}; + +const ALL_KEYBOARD_SHORTCUT_DEFAULTS = { + ...DEFAULT_GENERAL_KEYBOARD_SHORTCUTS, + ...DEFAULT_GENERAL_EDITING_KEYBOARD_SHORTCUTS, + ...DEFAULT_GENERAL_LAYOUT_KEYBOARD_SHORTCUTS, + ...DEFAULT_COMMENTS_TAB_KEYBOARD_SHORTCUTS, + ...DEFAULT_ARBITRARY_NAVIGATION_KEYBOARD_SHORTCUTS, + ...DEFAULT_ARBITRARY_NAVIGATION_CONFIG_KEYBOARD_SHORTCUTS, + ...DEFAULT_ARBITRARY_NO_LOOP_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_LOOPED_NAVIGATION_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_LOOP_DELAYED_NAVIGATION_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_NO_LOOPED_GENERAL_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_TOOL_SWITCHING_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_SKELETON_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_SKELETON_TOOL_LOOPED_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_VOLUME_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_VOLUME_TOOL_LOOP_DELAYED_CONFIG_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_BOUNDING_BOX_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, + ...DEFAULT_PLANE_PROOFREADING_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS, +} as const; + +export function getAllDefaultKeyboardShortcuts(): Mutable { + return cloneDeep(ALL_KEYBOARD_SHORTCUT_DEFAULTS); +} diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence.ts new file mode 100644 index 0000000000..8eb6973848 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence.ts @@ -0,0 +1,92 @@ +import { Validator } from "jsonschema"; +import { + ALL_KEYBOARD_HANDLER_IDS, + getAllDefaultKeyboardShortcuts, +} from "viewer/view/keyboard_shortcuts/keyboard_shortcut_constants"; +import type { KeyboardShortcutsMap } from "./keyboard_shortcut_types"; + +export const KeyboardShortcutsSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + title: "KeyboardShortcutsMap", + type: "object", + description: "A mapping from handler IDs to arrays of key combos (string[][]).", + properties: Object.fromEntries( + ALL_KEYBOARD_HANDLER_IDS.map((id) => [ + id, + { + type: "array", + items: { + type: "array", + items: { + type: "array", + items: { type: "string" }, + }, + }, + }, + ]), + ), + required: [...ALL_KEYBOARD_HANDLER_IDS], + additionalProperties: false, +}; + +export function validateShortcutMapText(input: string): { + valid: boolean; + errors: string[]; + parsed: Record | null; +} { + const errors: string[] = []; + let parsed: any = null; + + // 1. JSON parsing + try { + parsed = JSON.parse(input); + } catch (err) { + return { valid: false, errors: ["Invalid JSON: " + err], parsed: null }; + } + + // 2. Schema validation + const validator = new Validator(); + const schemaResult = validator.validate(parsed, KeyboardShortcutsSchema); + + if (!schemaResult.valid) { + errors.push(...schemaResult.errors.map((e) => `Schema: ${e.stack}`)); + } + + return { + valid: errors.length === 0, + errors, + parsed, + }; +} + +// STORAGE KEY +const STORAGE_KEY = "webknossosCustomShortcuts"; + +/** + * Load persisted keyboard shortcuts. + * Falls back to merged defaults when not available or invalid. + */ +export function loadKeyboardShortcuts(): KeyboardShortcutsMap { + const returnDefaults = () => { + const defaults = getAllDefaultKeyboardShortcuts(); + saveKeyboardShortcuts(defaults); + return defaults; + }; + const json = localStorage.getItem(STORAGE_KEY); + if (!json) return returnDefaults(); + + const { valid, parsed, errors } = validateShortcutMapText(json); + if (valid) { + return parsed as KeyboardShortcutsMap; + } + console.error("Could not parse stored keyboard shortcuts.", errors); + console.error("Resetting with defaults."); + return returnDefaults(); +} + +/** + * Persist the entire keyboard shortcut map. + */ +export function saveKeyboardShortcuts(map: KeyboardShortcutsMap): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(map)); +} diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_types.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_types.ts new file mode 100644 index 0000000000..353c3bcb8a --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_types.ts @@ -0,0 +1,60 @@ +import type { KeyboardLoopHandler, KeyboardNoLoopHandler } from "libs/input"; + +export enum KeyboardShortcutDomain { + GENERAL = "General", + GENERAL_EDITING = "General Editing", + GENERAL_LAYOUT = "Layout", + GENERAL_COMMENT_TAB = "Comment Tab", + ARBITRARY_NAVIGATION = "Navigation in Arbitrary Mode", + ARBITRARY_EDITING = "Editing in Arbitrary Mode", + PLANE_NAVIGATION = "Navigation in Plane Mode", + PLANE_CONFIGURATIONS = "Change Configurations in Plane Mode", + PLANE_TOOL_SWITCHING = "Tool Switching", + PLANE_SKELETON_TOOL = "Skeleton Tool Shortcuts in Plane Mode", + PLANE_VOLUME_TOOL = "Volume Tools Shortcuts in Plane Mode", + PLANE_BOUNDING_BOX_TOOL = "Bounding Box Tool Shortcuts in Plane Mode", + PLANE_PROOFREADING_TOOL = "Proofreading Tool Shortcuts in Plane Mode", +} + +// Default is general -> colliding with all other shortcuts. +export enum KeyboardShortcutCollisionEntityName { + GENERAL = "general", + ARBITRARY_MODE = "arbitrary_mode", + PLANE_MODE = "plane_mode", + PLANE_SKELETON_TOOL = "skeleton_tool_plane", + PLANE_VOLUME_TOOL = "volume_tool_plane", + PLANE_BOUNDING_BOX_TOOL = "bounding_box_tool_plane", + PLANE_PROOFREADING_TOOL = "proofreading_tool_plane", +} + +export type ComparableKeyboardCombo = string[]; +export type KeyboardComboChain = ComparableKeyboardCombo[]; +export type KeyboardShortcutsMap = Record< + KeyboardShortcutHandlerId, + KeyboardComboChain[] +>; + +export type KeyboardShortcutMetaInfo = { + description: string; + // looped is per default false. + looped?: boolean; + domain: KeyboardShortcutDomain; + // No collision domain means colliding with all shortcuts. + collisionEntityName: KeyboardShortcutCollisionEntityName; +}; + +export type KeyboardShortcutHandlerMetaInfoMap = Record< + KeyboardShortcutHandlerId, + KeyboardShortcutMetaInfo +>; + +export type KeyboardShortcutNoLoopedHandlerMap = Record< + KeyboardShortcutHandlerId, + // looped is per default false. + KeyboardNoLoopHandler +>; +export type KeyboardShortcutLoopedHandlerMap = Record< + KeyboardShortcutHandlerId, + // looped is per default false. + KeyboardLoopHandler +>; diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_utils.tsx b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_utils.tsx new file mode 100644 index 0000000000..e97d7a35b9 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/keyboard_shortcut_utils.tsx @@ -0,0 +1,440 @@ +import { MacCommandOutlined, WindowsOutlined } from "@ant-design/icons"; +import { Typography } from "antd"; +import type { + KeyBindingLoopMap, + KeyBindingMap, + KeyboardHandlerFn, + KeyboardLoopFn, + KeyboardLoopHandler, + KeyboardNoLoopHandler, +} from "libs/input"; +import { flatten } from "lodash-es"; +import type { AnnotationToolId } from "viewer/model/accessors/tool_accessor"; +import { Store } from "viewer/singletons"; +import type { + KeyboardComboChain, + KeyboardShortcutLoopedHandlerMap, + KeyboardShortcutNoLoopedHandlerMap, + KeyboardShortcutsMap, +} from "./keyboard_shortcut_types"; +import { + KeyboardShortcutCollisionHierarchy, + ALL_KEYBOARD_SHORTCUT_META_INFOS, +} from "./keyboard_shortcut_constants"; + +const { Text } = Typography; +export const MODIFIER_KEYS = new Set(["Control", "Meta", "Meta", "Alt", "Shift"]); + +// TODOM Refactor to not converte between keyevent name and back too often! + +export function keyToUiElement(key: string): React.ReactNode { + switch (key) { + case " ": + return "Space"; + case "esc": + case "escape": + return "Esc"; + case "ArrowLeft": + return "◀"; + case "ArrowRight": + return "▶"; + case "ArrowUp": + return "▲"; + case "ArrowDown": + return "▼"; + case "Meta": + return ( + <> + / + + ); + case "Control": + return "Ctrl"; + + default: + return key; + } +} + +function escapeReservedKeystrokeCharacters(key: string): string { + if (["+", ">", ","].includes(key)) { + return `\\${key}`; + } + return key; +} + +// Moves modifier keys to the front of the combo. +function sortKeyCombo(combo: string[]): string[] { + // Ensure modifiers appear first in canonical order, + // then non-modifier keys in the order they were pressed (preserved in `order`) + const modifiersOrder = ["Control", "Meta", "Meta", "Alt", "Shift"]; + const presentModifiers: string[] = []; + const nonModifiers: string[] = []; + + const seen = new Set(); + for (const key of combo) { + if (MODIFIER_KEYS.has(key)) { + seen.add(key); + } else { + if (!seen.has(key)) { + // only add non-modifier if not a modifier (keeps uniqueness). + nonModifiers.push(key); + seen.add(key); + } + } + } + + for (const m of modifiersOrder) { + if (seen.has(m)) presentModifiers.push(m); + } + + // But order may have modifiers after non-modifiers in `order`. We already fixed ordering. + // Combine modifiers then nonModifiers + return [...presentModifiers, ...nonModifiers]; +} + +export function formatKeyCombo(combo: string[]): string { + // Ensure modifiers appear first in canonical order, + // then non-modifier keys in the order they were pressed (preserved in `order`) + return sortKeyCombo(combo) + .map((key) => escapeReservedKeystrokeCharacters(key)) + .join(" + "); +} + +export function keyComboChainToKeystrokesConfig(comboChain: KeyboardComboChain): string { + return comboChain.map((combo) => formatKeyCombo(combo)).join(", "); +} + +export function comparableKeyComboChainToKeyCombo(comboChain: ComparableKeyComboChain): string { + return comboChain.map((combo) => formatKeyCombo([...combo])).join(", "); +} + +export function keyComboChainToUiElements(comboChain: KeyboardComboChain): React.ReactNode[] { + const uiElements: React.ReactNode[] = []; + comboChain.forEach((combo, outerIndex) => { + sortKeyCombo(combo).forEach((key, innerIndex) => { + uiElements.push( + + {keyToUiElement(key)} + , + ); + if (innerIndex < combo.length - 1) { + uiElements.push(+); + } + }); + if (outerIndex < comboChain.length - 1) { + uiElements.push(>); + } + }); + return uiElements; +} + +export const buildKeyBindingsFromConfigAndMapping = ( + config: KeyboardShortcutsMap, + handlerIdMapping: KeyboardShortcutNoLoopedHandlerMap, +): KeyBindingMap => { + const mappedShortcuts = flatten( + Object.entries(config).map(([handlerId, keyChainCombos]) => { + const isInHandlerMapping = handlerId in handlerIdMapping; + if (isInHandlerMapping) { + return keyChainCombos.map((chainCombo) => { + const keyComboStr = keyComboChainToKeystrokesConfig(chainCombo); + return [keyComboStr, handlerIdMapping[handlerId]]; + }); + } else { + return undefined; + } + }), + ).filter((mapping) => mapping != null); + return Object.fromEntries(mappedShortcuts); +}; + +export const buildKeyBindingsFromConfigAndLoopedMapping = ( + config: KeyboardShortcutsMap, + handlerIdMapping: KeyboardShortcutLoopedHandlerMap, +): KeyBindingLoopMap => { + const mappedShortcuts = flatten( + Object.entries(config).map(([handlerId, keyChainCombos]) => { + const isInHandlerMapping = handlerId in handlerIdMapping; + if (isInHandlerMapping) { + return keyChainCombos.map((chainCombo) => { + const keyComboStr = keyComboChainToKeystrokesConfig(chainCombo); + return [keyComboStr, handlerIdMapping[handlerId]]; + }); + } else { + return undefined; + } + }), + ).filter((mapping) => mapping != null); + return Object.fromEntries(mappedShortcuts); +}; + +function keyComboChainToSetArray(comboChain: KeyboardComboChain): ComparableKeyComboChain { + return comboChain.map((keyCombo: string[]) => new Set(keyCombo)); +} + +function areComboChainsEqual( + comparableComboChain1: ComparableKeyComboChain, + comparableComboChain2: ComparableKeyComboChain, +): boolean { + if (comparableComboChain1.length !== comparableComboChain2.length) { + return false; + } + for (let index = 0; index < comparableComboChain1.length; ++index) { + if (comparableComboChain1[index].symmetricDifference(comparableComboChain2[index]).size !== 0) { + return false; + } + } + return true; +} + +export function invertKeyboardShortcutMap( + config: KeyboardShortcutsMap, +): [ComparableKeyComboChain, T[]][] { + const result: [ComparableKeyComboChain, T[]][] = []; + + for (const handlerId in config) { + for (const chain of config[handlerId]) { + const comparableComboChain = keyComboChainToSetArray(chain); + const existingEntry = result.find(([otherChain, _]) => + areComboChainsEqual(comparableComboChain, otherChain), + ); + + if (existingEntry) { + existingEntry[1].push(handlerId); + } else { + result.push([comparableComboChain, [handlerId]]); + } + } + } + return result; +} + +function buildToolDependentNoLoppedHandler( + toolToHandlerMap: Partial>, +): KeyboardNoLoopHandler { + return { + onPressed: (...args: Parameters) => { + const activeToolId = Store.getState().uiInformation.activeTool.id; + toolToHandlerMap[activeToolId]?.onPressed(...args); + }, + onReleased: (...args: Parameters) => { + const activeToolId = Store.getState().uiInformation.activeTool.id; + toolToHandlerMap[activeToolId]?.onReleased?.(...args); + }, + }; +} + +function buildToolDependentLoppedHandler( + toolToHandlerMap: Partial>, +): KeyboardLoopHandler { + return { + onPressedWithRepeat: (...args: Parameters) => { + const activeToolId = Store.getState().uiInformation.activeTool.id; + toolToHandlerMap[activeToolId]?.onPressedWithRepeat(...args); + }, + onReleased: (...args: Parameters) => { + const activeToolId = Store.getState().uiInformation.activeTool.id; + toolToHandlerMap[activeToolId]?.onReleased?.(...args); + }, + }; +} + +export const buildKeyBindingsFromConfigAndMappingForTools = ( + config: KeyboardShortcutsMap, + handlerIdMappingPerAnnotationTool: Record< + AnnotationToolId, + KeyboardShortcutNoLoopedHandlerMap + >, +): KeyBindingMap => { + const keyComboChainAndHandlerIds = invertKeyboardShortcutMap(config); + const bindings: KeyBindingMap = {}; + keyComboChainAndHandlerIds.forEach(([comparableComboChain, handlers]) => { + const stringifiedComboChain = comparableKeyComboChainToKeyCombo(comparableComboChain); + const toolToHandlerMap: Partial> = {}; + for (const handler of handlers) { + for (const annotationToolIdStr of Object.keys(handlerIdMappingPerAnnotationTool)) { + const annotationToolId = annotationToolIdStr as AnnotationToolId; + if (handler in handlerIdMappingPerAnnotationTool[annotationToolId]) { + toolToHandlerMap[annotationToolId] = + handlerIdMappingPerAnnotationTool[annotationToolId][handler]; + } + } + } + const hasAtLeastOneToolWithCurrentShortcut = Object.keys(toolToHandlerMap).length > 0; + if (hasAtLeastOneToolWithCurrentShortcut) { + bindings[stringifiedComboChain] = buildToolDependentNoLoppedHandler(toolToHandlerMap); + } + }); + return bindings; +}; + +export const buildKeyBindingsFromConfigAndLoopedMappingForTools = ( + config: KeyboardShortcutsMap, + handlerIdMappingPerAnnotationTool: Record< + AnnotationToolId, + KeyboardShortcutLoopedHandlerMap + >, +): KeyBindingLoopMap => { + const keyComboChainAndHandlerIds = invertKeyboardShortcutMap(config); + const bindings: KeyBindingLoopMap = {}; + keyComboChainAndHandlerIds.forEach(([comparableComboChain, handlers]) => { + const stringifiedComboChain = comparableKeyComboChainToKeyCombo(comparableComboChain); + const toolToHandlerMap: Partial> = {}; + for (const handler of handlers) { + for (const annotationToolIdStr of Object.keys(handlerIdMappingPerAnnotationTool)) { + const annotationToolId = annotationToolIdStr as AnnotationToolId; + if (handler in handlerIdMappingPerAnnotationTool[annotationToolId]) { + toolToHandlerMap[annotationToolId] = + handlerIdMappingPerAnnotationTool[annotationToolId][handler]; + } + } + } + const hasAtLeastOneToolWithCurrentShortcut = Object.keys(toolToHandlerMap).length > 0; + if (hasAtLeastOneToolWithCurrentShortcut) { + bindings[stringifiedComboChain] = buildToolDependentLoppedHandler(toolToHandlerMap); + } + }); + return bindings; +}; + +type ComparableKeyComboChain = Set[]; + +export type Collision = { + keyCombo: ComparableKeyComboChain; + conflictingHandlerIds: string[]; +}; + +function buildParentMap(hierarchy: Record): Record { + const parentMap: Record = {}; + for (const [parent, children] of Object.entries(hierarchy)) { + for (const child of children) { + parentMap[child] = parent; + } + if (!(parent in parentMap)) parentMap[parent] = null; + } + return parentMap; +} + +function getCollidableEntities( + entity: string, + hierarchy: Record, + parentMap: Record, +): Set { + const collidable = new Set(); + // Add itself + collidable.add(entity); + // Add all ancestors (parents, grandparents, etc.) + let currentParent = parentMap[entity]; + while (currentParent) { + collidable.add(currentParent); + currentParent = parentMap[currentParent]; + } + // Add all descendants + function addDescendants(ent: string) { + const children = hierarchy[ent] || []; + for (const child of children) { + collidable.add(child); + addDescendants(child); + } + } + addDescendants(entity); + return collidable; +} + +export function checkCollisionsInShortcutMap( + shortcutMap: KeyboardShortcutsMap, +): Collision[] { + const parentMap = buildParentMap(KeyboardShortcutCollisionHierarchy); + const allCollisions: Collision[] = []; + const uniqueCollisions = new Set(); + + // For each entity, check collisions within its collidable set + // TODO: only compare top down -> do not include comparing with parent collision entities. + for (const entity of Object.keys(KeyboardShortcutCollisionHierarchy)) { + const collidableEntities = getCollidableEntities( + entity, + KeyboardShortcutCollisionHierarchy, + parentMap, + ); + // Collect handler ids with collisionEntityName in collidableEntities + const relevantHandlerIds: string[] = []; + for (const [handlerId, meta] of Object.entries(ALL_KEYBOARD_SHORTCUT_META_INFOS)) { + if (collidableEntities.has(meta.collisionEntityName)) { + relevantHandlerIds.push(handlerId); + } + } + // Get the shortcuts for these handlers + const relevantShortcuts: KeyboardShortcutsMap = {}; + for (const id of relevantHandlerIds) { + if (id in shortcutMap) { + relevantShortcuts[id] = shortcutMap[id]; + } + } + // Invert to find duplicates + const inverted = invertKeyboardShortcutMap(relevantShortcuts); + for (const [comboChain, handlerIds] of inverted) { + if (handlerIds.length > 1) { + const collision: Collision = { + keyCombo: comboChain, + conflictingHandlerIds: handlerIds, + }; + const key = JSON.stringify([ + Array.from(comboChain.map((set) => Array.from(set).sort())), + handlerIds.sort(), + ]); + if (!uniqueCollisions.has(key)) { + uniqueCollisions.add(key); + allCollisions.push(collision); + } + } + } + } + return allCollisions; +} + +export function checkCollisionForShortcut( + handlerIdOfShortcut: string, + newKeyCombos: KeyboardComboChain[], + existingShortcutMap: KeyboardShortcutsMap, +): Collision[] { + const metaInfoOfShortcut = ALL_KEYBOARD_SHORTCUT_META_INFOS[handlerIdOfShortcut]; + if (!metaInfoOfShortcut) return []; + const collisionEntityNameOfShortcut = metaInfoOfShortcut.collisionEntityName; + const parentMap = buildParentMap(KeyboardShortcutCollisionHierarchy); + const collidableEntities = getCollidableEntities( + collisionEntityNameOfShortcut, + KeyboardShortcutCollisionHierarchy, + parentMap, + ); + // Collect other handler ids + // TODOM: include own shortcuts but before remove potentially currently edited short. + const otherHandlerIds: string[] = []; + for (const [id, metaInfo] of Object.entries(ALL_KEYBOARD_SHORTCUT_META_INFOS)) { + if (id !== handlerIdOfShortcut && collidableEntities.has(metaInfo.collisionEntityName)) { + otherHandlerIds.push(id); + } + } + // Create temp map with new combos + const tempMap: KeyboardShortcutsMap = { ...existingShortcutMap }; + tempMap[handlerIdOfShortcut] = newKeyCombos; + // Get relevant shortcuts + const relevantShortcuts: KeyboardShortcutsMap = {}; + for (const id of [...otherHandlerIds, handlerIdOfShortcut]) { + if (id in tempMap) { + relevantShortcuts[id] = tempMap[id]; + } + } + // Invert and find collisions involving handlerId + const inverted = invertKeyboardShortcutMap(relevantShortcuts); + const collisions: Collision[] = []; + for (const [comboChain, handlerIds] of inverted) { + if (handlerIds.includes(handlerIdOfShortcut) && handlerIds.length > 1) { + collisions.push({ + keyCombo: comboChain, + conflictingHandlerIds: handlerIds.filter((id) => id !== handlerIdOfShortcut), + }); + } + } + return collisions; +} diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/bounding_box_tool_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/bounding_box_tool_shortcut_constants.ts new file mode 100644 index 0000000000..e6338d245a --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/bounding_box_tool_shortcut_constants.ts @@ -0,0 +1,43 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneBoundingBoxToolNoLoopedKeyboardShortcuts { + CREATE_BOUNDING_BOX = "CREATE_BOUNDING_BOX", + TOGGLE_CURSOR_STATE_FOR_MOVING = "TOGGLE_CURSOR_STATE_FOR_MOVING", +} + +export const DEFAULT_PLANE_BOUNDING_BOX_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.CREATE_BOUNDING_BOX]: [[["c"]]], + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.TOGGLE_CURSOR_STATE_FOR_MOVING]: [ + [["Control"], ["Meta"]], + ], + }; + +export const PlaneBoundingBoxToolNoLoopedKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.CREATE_BOUNDING_BOX]: "Create new cell", + [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts.TOGGLE_CURSOR_STATE_FOR_MOVING]: + "Enable moving the hovered bounding box", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_BOUNDING_BOX_TOOL, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_BOUNDING_BOX_TOOL, + }, + ] as [PlaneBoundingBoxToolNoLoopedKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/general_keyboard_shortcuts_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/general_keyboard_shortcuts_constants.ts new file mode 100644 index 0000000000..2745bcceb3 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/general_keyboard_shortcuts_constants.ts @@ -0,0 +1,178 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneControllerLoopedNavigationKeyboardShortcuts { + MOVE_LEFT = "MOVE_LEFT", + MOVE_RIGHT = "MOVE_RIGHT", + MOVE_UP = "MOVE_UP", + MOVE_DOWN = "MOVE_DOWN", + YAW_LEFT = "YAW_LEFT", + YAW_RIGHT = "YAW_RIGHT", + PITCH_UP = "PITCH_UP", + PITCH_DOWN = "PITCH_DOWN", + ALT_ROLL_LEFT = "ALT_ROLL_LEFT", + ALT_ROLL_RIGHT = "ALT_ROLL_RIGHT", +} + +export const DEFAULT_PLANE_LOOPED_NAVIGATION_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_LEFT]: [[["ArrowLeft"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_RIGHT]: [[["ArrowRight"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_UP]: [[["ArrowUp"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_DOWN]: [[["ArrowDown"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_LEFT]: [[["Shift", "ArrowLeft"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_RIGHT]: [[["Shift", "ArrowRight"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_UP]: [[["Shift", "ArrowUp"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_DOWN]: [[["Shift", "ArrowDown"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_LEFT]: [[["Alt", "ArrowLeft"]]], + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_RIGHT]: [[["Alt", "ArrowRight"]]], + } as const; + +export const PlaneNavigationKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_LEFT]: "Move left", + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_RIGHT]: "Move right", + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_UP]: "Move up", + [PlaneControllerLoopedNavigationKeyboardShortcuts.MOVE_DOWN]: "Move down", + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_LEFT]: "Rotate left", + [PlaneControllerLoopedNavigationKeyboardShortcuts.YAW_RIGHT]: "Rotate right", + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_UP]: "Rotate up", + [PlaneControllerLoopedNavigationKeyboardShortcuts.PITCH_DOWN]: "Rotate down", + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_LEFT]: "Roll left", // TODOM check for correct naming + [PlaneControllerLoopedNavigationKeyboardShortcuts.ALT_ROLL_RIGHT]: "Roll right", // TODOM check for correct naming + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_NAVIGATION, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + ] as [PlaneControllerLoopedNavigationKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +// --------------------------- delayed-loop (keyboard with custom delay) --------------------------------- +export enum PlaneControllerLoopDelayedNavigationKeyboardShortcuts { + MOVE_MULTIPLE_FORWARD = "MOVE_MULTIPLE_FORWARD", + MOVE_MULTIPLE_BACKWARD = "MOVE_MULTIPLE_BACKWARD", + MOVE_ONE_BACKWARD = "SHIFT_SPACE", + MOVE_ONE_FORWARD = "MOVE_ONE_FORWARD", + MOVE_ONE_FORWARD_DIRECTION_AWARE = "MOVE_ONE_FORWARD_DIRECTION_AWARE", + MOVE_ONE_BACKWARD_DIRECTION_AWARE = "MOVE_ONE_BACKWARD_DIRECTION_AWARE", + ZOOM_IN_PLANE = "ZOOM_IN_PLANE", + ZOOM_OUT_PLANE = "ZOOM_OUT_PLANE", + INCREASE_MOVE_VALUE_PLANE = "INCREASE_MOVE_VALUE_PLANE", + DECREASE_MOVE_VALUE_PLANE = "DECREASE_MOVE_VALUE_PLANE", +} + +export const DEFAULT_PLANE_LOOP_DELAYED_NAVIGATION_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_FORWARD]: [ + [["Shift", "f"]], + ], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_BACKWARD]: [ + [["Shift", "d"]], + ], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD]: [ + [["Shift", "Space"]], + [["Control", "Space"]], + ], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD]: [[["Space"]]], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD_DIRECTION_AWARE]: [ + [["f"]], + ], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD_DIRECTION_AWARE]: [ + [["d"]], + ], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_IN_PLANE]: [[["i"]]], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_OUT_PLANE]: [[["o"]]], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.INCREASE_MOVE_VALUE_PLANE]: [[["h"]]], + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.DECREASE_MOVE_VALUE_PLANE]: [[["g"]]], + } as const; + +export const PlaneLoopDelayedNavigationKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_FORWARD]: + "Fast move forward", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_MULTIPLE_BACKWARD]: + "Fast move backward", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD]: "Move backward", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD]: "Move forward", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_FORWARD_DIRECTION_AWARE]: + "Move forward (direction aware)", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.MOVE_ONE_BACKWARD_DIRECTION_AWARE]: + "Move backward (direction aware)", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_IN_PLANE]: "Zoom in", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.ZOOM_OUT_PLANE]: "Zoom out", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.INCREASE_MOVE_VALUE_PLANE]: + "Increase move value", + [PlaneControllerLoopDelayedNavigationKeyboardShortcuts.DECREASE_MOVE_VALUE_PLANE]: + "Decrease move value", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_NAVIGATION, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + ] as [PlaneControllerLoopDelayedNavigationKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum PlaneControllerNoLoopGeneralKeyboardShortcuts { + DOWNLOAD_SCREENSHOT = "DOWNLOAD_SCREENSHOT", + CYCLE_TOOLS = "CYCLE_TOOLS", + CYCLE_TOOLS_BACKWARDS = "CYCLE_TOOLS_BACKWARDS", +} + +export const DEFAULT_PLANE_NO_LOOPED_GENERAL_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneControllerNoLoopGeneralKeyboardShortcuts.DOWNLOAD_SCREENSHOT]: [[["q"]]], + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS]: [[["w"]]], + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS_BACKWARDS]: [[["Shift", "w"]]], + } as const; + +export const PlaneGeneralKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneControllerNoLoopGeneralKeyboardShortcuts.DOWNLOAD_SCREENSHOT]: + "Download Screenshot(s) of Viewport(s)", + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS]: + "Cycle Through Tools (Move / Skeleton / Brush/ ...)", + [PlaneControllerNoLoopGeneralKeyboardShortcuts.CYCLE_TOOLS_BACKWARDS]: + "Cycle Backwards Through Tools (Move / Proofread / Bounding Box / ...)", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_NAVIGATION, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + ] as [PlaneControllerNoLoopGeneralKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/proofreading_tool_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/proofreading_tool_shortcut_constants.ts new file mode 100644 index 0000000000..635fb48aa4 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/proofreading_tool_shortcut_constants.ts @@ -0,0 +1,25 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneProofreadingToolNoLoopedKeyboardShortcuts { + TOGGLE_MULTICUT_MODE = "TOGGLE_MULTICUT_MODE", +} + +export const DEFAULT_PLANE_PROOFREADING_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneProofreadingToolNoLoopedKeyboardShortcuts.TOGGLE_MULTICUT_MODE]: [[["m"]]], + }; + +export const PlaneProofreadingToolNoLoopedKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + { + [PlaneProofreadingToolNoLoopedKeyboardShortcuts.TOGGLE_MULTICUT_MODE]: { + description: "Toggle multi cut mode", + domain: KeyboardShortcutDomain.PLANE_PROOFREADING_TOOL, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_PROOFREADING_TOOL, + }, + }; diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/skeleton_tool_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/skeleton_tool_shortcut_constants.ts new file mode 100644 index 0000000000..1485d065c8 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/skeleton_tool_shortcut_constants.ts @@ -0,0 +1,120 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneSkeletonToolNoLoopedKeyboardShortcuts { + TOGGLE_ALL_TREES_PLANE = "TOGGLE_ALL_TREES_PLANE", + TOGGLE_INACTIVE_TREES_PLANE = "TOGGLE_INACTIVE_TREES_PLANE", + DELETE_ACTIVE_NODE_PLANE = "DELETE_ACTIVE_NODE_PLANE", + CREATE_TREE_PLANE = "CREATE_TREE_PLANE", + MOVE_ALONG_DIRECTION = "MOVE_ALONG_DIRECTION", + MOVE_ALONG_DIRECTION_REVERSED = "MOVE_ALONG_DIRECTION_REVERSED", + CREATE_BRANCH_POINT_PLANE = "CREATE_BRANCH_POINT_PLANE", + DELETE_BRANCH_POINT_PLANE = "DELETE_BRANCH_POINT_PLANE", + RECENTER_ACTIVE_NODE_PLANE = "RECENTER_ACTIVE_NODE_PLANE", + NEXT_NODE_FORWARD_PLANE = "NEXT_NODE_FORWARD_PLANE", + NEXT_NODE_BACKWARD_PLANE = "NEXT_NODE_BACKWARD_PLANE", +} + +export const DEFAULT_PLANE_SKELETON_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_ALL_TREES_PLANE]: [[["1"]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_INACTIVE_TREES_PLANE]: [[["2"]]], + // Delete active node + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_ACTIVE_NODE_PLANE]: [ + [["Delete"]], + [["Backspace"]], + ], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_TREE_PLANE]: [[["c"]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION]: [[["e"]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION_REVERSED]: [[["r"]]], + // Branches + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_BRANCH_POINT_PLANE]: [[["b"]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_BRANCH_POINT_PLANE]: [[["j"]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.RECENTER_ACTIVE_NODE_PLANE]: [[["s"]]], + // navigate nodes + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_BACKWARD_PLANE]: [[["Control", ","]]], + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_FORWARD_PLANE]: [[["Control", "."]]], + }; + +export const PlaneSkeletonToolNoLoopedKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_ALL_TREES_PLANE]: + "Toggle visibility of all trees", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.TOGGLE_INACTIVE_TREES_PLANE]: + "Toggle visibility of hidden trees", // TODOM check if this is correct. + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_ACTIVE_NODE_PLANE]: "Delete Active Node", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_TREE_PLANE]: "Create new Tree", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION]: + "Move along annotation direction", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.MOVE_ALONG_DIRECTION_REVERSED]: + "Move backward annotation direction", + // Branches + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.CREATE_BRANCH_POINT_PLANE]: "Create branch point", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.DELETE_BRANCH_POINT_PLANE]: "Delete branch point", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.RECENTER_ACTIVE_NODE_PLANE]: + "Recenter active node", + // navigate nodes + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_BACKWARD_PLANE]: + "Activate Previous Node", + [PlaneSkeletonToolNoLoopedKeyboardShortcuts.NEXT_NODE_FORWARD_PLANE]: "Activate Next Node", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_SKELETON_TOOL, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_SKELETON_TOOL, + }, + ] as [PlaneSkeletonToolNoLoopedKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum PlaneSkeletonToolLoopedKeyboardShortcuts { + MOVE_NODE_LEFT = "MOVE_NODE_LEFT", + MOVE_NODE_RIGHT = "MOVE_NODE_RIGHT", + MOVE_NODE_UP = "MOVE_NODE_UP", + MOVE_NODE_DOWN = "MOVE_NODE_DOWN", +} + +export const DEFAULT_PLANE_SKELETON_TOOL_LOOPED_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_LEFT]: [[["Control", "ArrowLeft"]]], + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_RIGHT]: [[["Control", "ArrowRight"]]], + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_UP]: [[["Control", "ArrowUp"]]], + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_DOWN]: [[["Control", "ArrowDown"]]], + }; + +export const PlaneSkeletonToolLoopedKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_LEFT]: "Move active node left", + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_RIGHT]: "Move active node right", + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_UP]: "Move active node up", + [PlaneSkeletonToolLoopedKeyboardShortcuts.MOVE_NODE_DOWN]: "Move active node down", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_SKELETON_TOOL, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_SKELETON_TOOL, + }, + ] as [PlaneSkeletonToolNoLoopedKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/tool_switching_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/tool_switching_shortcut_constants.ts new file mode 100644 index 0000000000..2f09a2918e --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/tool_switching_shortcut_constants.ts @@ -0,0 +1,90 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneControllerToolSwitchingKeyboardShortcuts { + SWITCH_TO_MOVE_TOOL = "SWITCH_TO_MOVE_TOOL", + SWITCH_TO_SKELETON_TOOL = "SWITCH_TO_SKELETON_TOOL", + SWITCH_TO_BRUSH_TOOL = "SWITCH_TO_BRUSH_TOOL", + SWITCH_TO_BRUSH_ERASE_TOOL = "SWITCH_TO_BRUSH_ERASE_TOOL", + SWITCH_TO_LASSO_TOOL = "SWITCH_TO_LASSO_TOOL", + SWITCH_TO_LASSO_ERASE_TOOL = "SWITCH_TO_LASSO_ERASE_TOOL", + SWITCH_TO_SEGMENT_PICKER_TOOL = "SWITCH_TO_SEGMENT_PICKER_TOOL", + SWITCH_TO_QUICK_SELECT_TOOL = "SWITCH_TO_QUICK_SELECT_TOOL", + SWITCH_TO_BOUNDING_BOX_TOOL = "SWITCH_TO_BOUNDING_BOX_TOOL", + SWITCH_TO_PROOFREADING_TOOL = "SWITCH_TO_PROOFREADING_TOOL", +} + +export const DEFAULT_PLANE_TOOL_SWITCHING_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_MOVE_TOOL]: [ + [["Control", "k"], ["m"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_SKELETON_TOOL]: [ + [["Control", "k"], ["s"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BRUSH_TOOL]: [ + [["Control", "k"], ["b"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BRUSH_ERASE_TOOL]: [ + [["Control", "k"], ["e"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_LASSO_TOOL]: [ + [["Control", "k"], ["l"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_LASSO_ERASE_TOOL]: [ + [["Control", "k"], ["r"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_SEGMENT_PICKER_TOOL]: [ + [["Control", "k"], ["p"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_QUICK_SELECT_TOOL]: [ + [["Control", "k"], ["q"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BOUNDING_BOX_TOOL]: [ + [["Control", "k"], ["x"]], + ], + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_PROOFREADING_TOOL]: [ + [["Control", "k"], ["q"]], + ], + } as const; + +export const PlaneToolSwitchingKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_MOVE_TOOL]: "Move Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_SKELETON_TOOL]: "Skeleton Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BRUSH_TOOL]: "Brush Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BRUSH_ERASE_TOOL]: + "Brush Erase Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_LASSO_TOOL]: "Lasso Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_LASSO_ERASE_TOOL]: + "Lasso Erase Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_SEGMENT_PICKER_TOOL]: + "Segment Picker Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_QUICK_SELECT_TOOL]: + "Quick Select Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_BOUNDING_BOX_TOOL]: + "Bounding Box Tool", + [PlaneControllerToolSwitchingKeyboardShortcuts.SWITCH_TO_PROOFREADING_TOOL]: + "Proofreading Tool", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_TOOL_SWITCHING, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + ] as [PlaneControllerToolSwitchingKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/volume_tools_shortcut_constants.ts b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/volume_tools_shortcut_constants.ts new file mode 100644 index 0000000000..dec7e7bd73 --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/plane_mode/volume_tools_shortcut_constants.ts @@ -0,0 +1,80 @@ +import { + KeyboardShortcutCollisionEntityName, + KeyboardShortcutDomain, + type KeyboardShortcutHandlerMetaInfoMap, + type KeyboardShortcutMetaInfo, + type KeyboardShortcutsMap, +} from "../keyboard_shortcut_types"; + +export enum PlaneVolumeToolNoLoopedKeyboardShortcuts { + CREATE_NEW_CELL = "CREATE_NEW_CELL", + INTERPOLATE_SEGMENTATION = "INTERPOLATE_SEGMENTATION", + COPY_SEGMENT_ID = "COPY_SEGMENT_ID", + BRUSH_PRESET_SMALL = "BRUSH_PRESET_SMALL", + BRUSH_PRESET_MEDIUM = "BRUSH_PRESET_MEDIUM", + BRUSH_PRESET_LARGE = "BRUSH_PRESET_LARGE", +} + +export const DEFAULT_PLANE_VOLUME_TOOL_NO_LOOPED_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneVolumeToolNoLoopedKeyboardShortcuts.CREATE_NEW_CELL]: [[["c"]]], + [PlaneVolumeToolNoLoopedKeyboardShortcuts.INTERPOLATE_SEGMENTATION]: [[["v"]]], + [PlaneVolumeToolNoLoopedKeyboardShortcuts.COPY_SEGMENT_ID]: [[["Control", "i"]]], + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_SMALL]: [[["Control", "k"], ["1"]]], + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_MEDIUM]: [[["Control", "k"], ["2"]]], + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_LARGE]: [[["Control", "k"], ["3"]]], + }; + +export const PlaneVolumeToolNoLoopedKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + (() => { + const withDescription: Record = { + [PlaneVolumeToolNoLoopedKeyboardShortcuts.CREATE_NEW_CELL]: "Create new cell", + [PlaneVolumeToolNoLoopedKeyboardShortcuts.INTERPOLATE_SEGMENTATION]: + "Interpolate annotation between latest drawn sections", + [PlaneVolumeToolNoLoopedKeyboardShortcuts.COPY_SEGMENT_ID]: "Copy segment id under cursor", + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_SMALL]: "Switch to small brush", + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_MEDIUM]: + "Switch to medium sized brush", + [PlaneVolumeToolNoLoopedKeyboardShortcuts.BRUSH_PRESET_LARGE]: "Switch to large brush", + }; + return Object.fromEntries( + Object.entries(withDescription).map( + ([handlerId, description]) => + [ + handlerId, + { + description, + domain: KeyboardShortcutDomain.PLANE_VOLUME_TOOL, + looped: false, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_VOLUME_TOOL, + }, + ] as [PlaneVolumeToolNoLoopedKeyboardShortcuts, KeyboardShortcutMetaInfo], + ), + ) as KeyboardShortcutHandlerMetaInfoMap; + })(); + +export enum PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts { + DECREASE_BRUSH_SIZE = "DECREASE_BRUSH_SIZE", + INCREASE_BRUSH_SIZE = "INCREASE_BRUSH_SIZE", +} +export const DEFAULT_PLANE_VOLUME_TOOL_LOOP_DELAYED_CONFIG_KEYBOARD_SHORTCUTS: KeyboardShortcutsMap = + { + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.DECREASE_BRUSH_SIZE]: [[["Shift", "i"]]], + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.INCREASE_BRUSH_SIZE]: [[["Shift", "o"]]], + }; + +export const PlaneVolumeToolLoopDelayedConfigKeyboardShortcutMetaInfo: KeyboardShortcutHandlerMetaInfoMap = + { + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.DECREASE_BRUSH_SIZE]: { + description: "Decrease brush size", + domain: KeyboardShortcutDomain.PLANE_CONFIGURATIONS, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + [PlaneVolumeToolLoopDelayedConfigKeyboardShortcuts.INCREASE_BRUSH_SIZE]: { + description: "Increase brush size", + domain: KeyboardShortcutDomain.PLANE_CONFIGURATIONS, + looped: true, + collisionEntityName: KeyboardShortcutCollisionEntityName.PLANE_MODE, + }, + }; diff --git a/frontend/javascripts/viewer/view/keyboard_shortcuts/shortcut_recorder_modal.tsx b/frontend/javascripts/viewer/view/keyboard_shortcuts/shortcut_recorder_modal.tsx new file mode 100644 index 0000000000..b8d5e8f6fb --- /dev/null +++ b/frontend/javascripts/viewer/view/keyboard_shortcuts/shortcut_recorder_modal.tsx @@ -0,0 +1,230 @@ +import { Button, Flex, Modal, Typography } from "antd"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { KeyboardComboChain } from "./keyboard_shortcut_types"; +import { keyComboChainToUiElements } from "./keyboard_shortcut_utils"; + +const { Text } = Typography; + +const SampleKeyCombo: KeyboardComboChain = [["Control", "a"], ["o"]]; + +type ShortcutRecorderModalProps = { + isOpen: boolean; + initialKeyComboChain?: KeyboardComboChain; // optional preview of current binding + onCancel: () => void; // do not overwrite + onSave: (newKeyComboChain: KeyboardComboChain) => void; // returns final combo like [["Control", "a"], ["o"]] +}; + +export function ShortcutRecorderModal({ + isOpen, + initialKeyComboChain, + onCancel, + onSave, +}: ShortcutRecorderModalProps) { + const [keyComboChain, setKeyComboChain] = useState( + initialKeyComboChain ?? [], + ); + const [previewKeyCombo, setPreviewKeyCombo] = useState([]); + + const currentDownSetRef = useRef>(new Set()); + const currentSeenSetRef = useRef>(new Set()); + + const clearCurrentPreview = useCallback(() => { + currentDownSetRef.current.clear(); + currentSeenSetRef.current.clear(); + console.log("clearing"); + setPreviewKeyCombo([]); + }, []); + + const handleReset = useCallback(() => { + setKeyComboChain([]); + console.log("resetting"); + clearCurrentPreview(); + }, [clearCurrentPreview]); + + useEffect(() => { + if (!isOpen) { + console.log("resetting as not open"); + handleReset(); + } + }, [isOpen, handleReset]); + + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + // prevent the rest of the app reacting while recording + e.preventDefault(); + e.stopPropagation(); + + const pressedKeyId = e.key; + + // ignore repeated keydown for held keys (auto-repeat) + if (currentDownSetRef.current.has(pressedKeyId)) { + return; + } + currentDownSetRef.current.add(pressedKeyId); + + if (currentSeenSetRef.current.has(pressedKeyId)) { + return; + } + + // add to currentDown set & order + currentSeenSetRef.current.add(pressedKeyId); + + // Update the preview state + setPreviewKeyCombo((prevPreviewStroke) => [...prevPreviewStroke, pressedKeyId]); + } + + function handleKeyUp(e: KeyboardEvent) { + e.preventDefault(); + e.stopPropagation(); + + const pressedKeyId = e.key; + + // Remove from currentDown + currentDownSetRef.current.delete(pressedKeyId); + // Keep order array coherent: don't remove the entry from the order array (we only use order snapshot) + // We only finalize when *no* keys remain pressed. + + if (currentDownSetRef.current.size === 0) { + // Finalize this stroke using lastStrokeOrderRef + setPreviewKeyCombo((prevPreviewKeyCombo) => { + if (prevPreviewKeyCombo.length > 0) { + setKeyComboChain([...keyComboChain, prevPreviewKeyCombo]); + } + return prevPreviewKeyCombo; // TODO maybe undo + }); + // clear order and last snapshot + console.log("clearing as no keys pressed"); + clearCurrentPreview(); + } + } + + // Also support user pressing Escape to cancel recording immediately + function handleEscapeAndCtrlKey(e: KeyboardEvent) { + if (e.key === "Escape" && e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + // behave like cancel: clear in-progress state but do not call onCancel + // keep strokes previously completed strokes. + console.log("resetting as escape combo used"); + handleReset(); + } + } + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("keyup", handleKeyUp, true); + window.addEventListener("keydown", handleEscapeAndCtrlKey, true); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("keydown", handleEscapeAndCtrlKey, true); + // cleanup + console.log("clearing as useEffect remount"); + clearCurrentPreview(); + }; + }, [clearCurrentPreview, isOpen, handleReset, keyComboChain]); + + function handleCancel() { + // do not overwrite; simply call onCancel + console.log("resetting due to handle cancel"); + handleReset(); + onCancel(); + } + + function handleOk() { + // final string + if (keyComboChain.length > 0) { + onSave(keyComboChain); + } else { + // nothing recorded -> treat as cancel (or you can choose to save empty) + onCancel(); + } + console.log("resetting due to handleOk"); + handleReset(); + } + + // remove last stroke + function handleRemoveLastStroke() { + setKeyComboChain((prev) => prev.slice(0, -1)); + } + + console.log("rendering", previewKeyCombo); + + return ( + 0 }} + title="Record Shortcut" + destroyOnClose={true} + > +
+ + Press keys now. Release all keys to finish the current stroke. Press keys again to add a + subsequent stroke. Example: {keyComboChainToUiElements(SampleKeyCombo)}. Reset everything + with Esc + Ctrl. + + +
+
+
+
+ Recorded:{" "} + + {keyComboChainToUiElements(keyComboChain) || "— waiting —"} + +
+
+ + + + +
+
+ +
+ Current stroke preview (updates while keys are pressed): +
+ + {previewKeyCombo.length > 0 + ? keyComboChainToUiElements([previewKeyCombo]) + : "— no keys down —"} + +
+
+
+
+ ); +} diff --git a/frontend/javascripts/viewer/view/layouting/flex_layout_wrapper.tsx b/frontend/javascripts/viewer/view/layouting/flex_layout_wrapper.tsx index 2d2c66476f..057b59e877 100644 --- a/frontend/javascripts/viewer/view/layouting/flex_layout_wrapper.tsx +++ b/frontend/javascripts/viewer/view/layouting/flex_layout_wrapper.tsx @@ -38,6 +38,10 @@ import SkeletonTabView from "viewer/view/right_border_tabs/trees_tab/skeleton_ta import Statusbar from "viewer/view/statusbar"; import TDViewControls from "viewer/view/td_view_controls"; import BorderToggleButton from "../components/border_toggle_button"; +import { GeneralLayoutKeyboardShortcuts } from "../keyboard_shortcuts/keyboard_shortcut_constants"; +import { loadKeyboardShortcuts } from "../keyboard_shortcuts/keyboard_shortcut_persistence"; +import type { KeyboardShortcutNoLoopedHandlerMap } from "../keyboard_shortcuts/keyboard_shortcut_types"; +import { buildKeyBindingsFromConfigAndMapping } from "../keyboard_shortcuts/keyboard_shortcut_utils"; import { adjustModelToBorderOpenStatus, getBorderOpenStatus, @@ -256,17 +260,25 @@ class FlexLayoutWrapper extends PureComponent { this.onAction(toggleMaximiseAction); }; - attachKeyboardShortcuts() { - const keyboardNoLoop = new InputKeyboardNoLoop( - { - ".": this.toggleMaximize, - k: () => this.toggleBorder("left"), - l: () => this.toggleBorder("right"), + getLayoutKeyboardShortcuts(): KeyboardShortcutNoLoopedHandlerMap { + return { + [GeneralLayoutKeyboardShortcuts.MAXIMIZE]: { onPressed: this.toggleMaximize }, + [GeneralLayoutKeyboardShortcuts.TOGGLE_LEFT_BORDER]: { + onPressed: () => this.toggleBorder("left"), }, - { - supportInputElements: false, + [GeneralLayoutKeyboardShortcuts.TOGGLE_RIGHT_BORDER]: { + onPressed: () => this.toggleBorder("right"), }, + }; + } + + attachKeyboardShortcuts() { + const keybindingConfig = loadKeyboardShortcuts(); + const keyboardControls = buildKeyBindingsFromConfigAndMapping( + keybindingConfig, + this.getLayoutKeyboardShortcuts(), ); + const keyboardNoLoop = new InputKeyboardNoLoop(keyboardControls); return () => keyboardNoLoop.destroy(); } diff --git a/frontend/javascripts/viewer/view/right_border_tabs/comment_tab/comment_tab_view.tsx b/frontend/javascripts/viewer/view/right_border_tabs/comment_tab/comment_tab_view.tsx index d3c6361d66..3a0b83f23b 100644 --- a/frontend/javascripts/viewer/view/right_border_tabs/comment_tab/comment_tab_view.tsx +++ b/frontend/javascripts/viewer/view/right_border_tabs/comment_tab/comment_tab_view.tsx @@ -49,6 +49,10 @@ import ButtonComponent from "viewer/view/components/button_component"; import DomVisibilityObserver from "viewer/view/components/dom_visibility_observer"; import InputComponent from "viewer/view/components/input_component"; import { MarkdownModal } from "viewer/view/components/markdown_modal"; +import { CommentsTabKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_constants"; +import { loadKeyboardShortcuts } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_persistence"; +import type { KeyboardShortcutLoopedHandlerMap } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_types"; +import { buildKeyBindingsFromConfigAndLoopedMapping } from "viewer/view/keyboard_shortcuts/keyboard_shortcut_utils"; import Comment, { commentListId } from "viewer/view/right_border_tabs/comment_tab/comment"; import AdvancedSearchPopover from "../advanced_search_popover"; import { ColoredDotIcon } from "../segments_tab/segment_list_item"; @@ -149,19 +153,20 @@ function CommentTabView(props: Props) { // Instead of directly attaching callback function, we need to rely on React.refs instead // to prevent the callbacks from becoming stale and outdated as the component changes // its state or props. - const newKeyboard = new InputKeyboard( - { - n: () => { - if (nextCommentRef?.current) nextCommentRef.current(); - }, - p: () => { - if (previousCommentRef?.current) previousCommentRef.current(); - }, + const keyboardHandlers: KeyboardShortcutLoopedHandlerMap = { + [CommentsTabKeyboardShortcuts.NEXT_COMMENT]: { + onPressedWithRepeat: () => nextCommentRef?.current?.(), }, - { - delay: keyboardDelay, + [CommentsTabKeyboardShortcuts.PREVIOUS_COMMENT]: { + onPressedWithRepeat: () => previousCommentRef?.current?.(), }, + }; + const keybindingConfig = loadKeyboardShortcuts(); + const keyboardControls = buildKeyBindingsFromConfigAndLoopedMapping( + keybindingConfig, + keyboardHandlers, ); + const newKeyboard = new InputKeyboard(keyboardControls); if (keyboard === null) setKeyboard(newKeyboard); }, () => { diff --git a/frontend/javascripts/viewer/view/statusbar.tsx b/frontend/javascripts/viewer/view/statusbar.tsx index ca08d5b2d2..7f234aab27 100644 --- a/frontend/javascripts/viewer/view/statusbar.tsx +++ b/frontend/javascripts/viewer/view/statusbar.tsx @@ -74,8 +74,7 @@ function ZoomShortcut() { {AltOrOptionKey} + - - Zoom in/out + Zoom in/out ); } diff --git a/package.json b/package.json index 9a0c88f2ee..39e66436bc 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,8 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@github/webauthn-json": "^2.1.1", + "@rwh/keystrokes": "^1.5.6", + "@rwh/react-keystrokes": "^1.5.6", "@scalableminds/prop-types": "^15.8.1", "@tanstack/query-async-storage-persister": "^5.83.0", "@tanstack/react-query": "^5.83.0", diff --git a/yarn.lock b/yarn.lock index 56a68754f9..c1b5331269 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2913,6 +2913,23 @@ __metadata: languageName: node linkType: hard +"@rwh/keystrokes@npm:^1.5.6": + version: 1.5.6 + resolution: "@rwh/keystrokes@npm:1.5.6" + checksum: 10c0/2ed1a9a8210a20701414df9e962ff809cc2f363b5026744dbe4884a0530c123abbd74230486a33f4bd742805534262eb67e79a9c279ed4603839d0ab843679eb + languageName: node + linkType: hard + +"@rwh/react-keystrokes@npm:^1.5.6": + version: 1.5.6 + resolution: "@rwh/react-keystrokes@npm:1.5.6" + peerDependencies: + "@rwh/keystrokes": 1.5.6 + react: ">=17" + checksum: 10c0/4bd5c2acc3b40e7b747d7738f93a193150b79786d2b6ada0b12467192f0dba884feb8856c4966fc82f131db7b15b07e2d1639c875add393eb93aa574ab5a1f9c + languageName: node + linkType: hard + "@scalableminds/prop-types@npm:^15.8.1": version: 15.8.1 resolution: "@scalableminds/prop-types@npm:15.8.1" @@ -12693,6 +12710,8 @@ __metadata: "@dnd-kit/sortable": "npm:^8.0.0" "@github/webauthn-json": "npm:^2.1.1" "@redux-saga/testing-utils": "npm:^1.1.5" + "@rwh/keystrokes": "npm:^1.5.6" + "@rwh/react-keystrokes": "npm:^1.5.6" "@scalableminds/prop-types": "npm:^15.8.1" "@shaderfrog/glsl-parser": "npm:^0.3.0" "@svgr/plugin-svgo": "npm:^8.1.0"