Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
1d837b0
Add initial working version fro controller shortcuts
MichaelBuessemeyer Nov 17, 2025
d439850
WIP add arbitary controller shortcuts support
MichaelBuessemeyer Nov 17, 2025
fca07f1
WIP add arbitrary controller support
MichaelBuessemeyer Nov 18, 2025
d08e29f
refactor key config format to support multiple shortcuts per handler …
MichaelBuessemeyer Nov 18, 2025
efcb1b3
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Nov 19, 2025
b21f416
sort imports
MichaelBuessemeyer Nov 19, 2025
94862a9
WIP: add plane controller shortcuts
MichaelBuessemeyer Nov 19, 2025
f086ea7
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Feb 20, 2026
96d2a3c
fix keyboard_shortcut_constants.ts
MichaelBuessemeyer Feb 20, 2026
346917a
WIP migrate plane shortcuts & tool specific shortcuts to new "shortcu…
MichaelBuessemeyer Feb 21, 2026
8d271f5
refactor all keyboard shortcut constants into separate files, make th…
MichaelBuessemeyer Feb 23, 2026
ad41939
WIP fix imports and other compile errors
MichaelBuessemeyer Feb 23, 2026
e4ade41
improve & unify naming of tool shortcut constants
MichaelBuessemeyer Feb 24, 2026
659083a
try to migrate keyboardJS to Keystrokes
MichaelBuessemeyer Feb 24, 2026
be74d93
move tool related shortcuts to their controller definitions & improve…
MichaelBuessemeyer Feb 25, 2026
29c886e
Migrate Plane controller; Annotation Tools is still WIP; fix TS
MichaelBuessemeyer Feb 25, 2026
48ee7aa
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Feb 25, 2026
b64ebf2
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Feb 26, 2026
24f12fa
WIP. realize tool dependent keyboard shortcuts in ortho mode
MichaelBuessemeyer Feb 26, 2026
1f5b9c1
fix shortcuts using ctrl as keystroke requires it to be spelled control
MichaelBuessemeyer Feb 27, 2026
5dc1c00
remove debug statement
MichaelBuessemeyer Feb 27, 2026
616230f
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Mar 2, 2026
94cef3b
WIP reorganize keyboard shortcuts modal
MichaelBuessemeyer Mar 2, 2026
60b3ad3
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Mar 25, 2026
5893d2c
WIP improve UI
MichaelBuessemeyer Mar 25, 2026
d6c796b
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Mar 25, 2026
83ef32e
refine key combo visualization column
MichaelBuessemeyer Mar 25, 2026
8296098
fix displayed ">" and format keys in recording modal properly
MichaelBuessemeyer Mar 25, 2026
793796c
fix imports
MichaelBuessemeyer Mar 25, 2026
5066bbd
fix imports and ts
MichaelBuessemeyer Mar 25, 2026
6196c11
Merge branch 'master' of github.com:scalableminds/webknossos into cus…
MichaelBuessemeyer Mar 26, 2026
584c624
use event.key directly as stored variable to identify key as this is …
MichaelBuessemeyer Mar 26, 2026
c1a8a98
WIP: proper collision detection & var naming adjustments
MichaelBuessemeyer Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
409 changes: 228 additions & 181 deletions frontend/javascripts/libs/input.ts

Large diffs are not rendered by default.

16 changes: 6 additions & 10 deletions frontend/javascripts/libs/shortcut_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion frontend/javascripts/test/api/api_skeleton_latest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions frontend/javascripts/viewer/api/api_latest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});
Expand Down
209 changes: 130 additions & 79 deletions frontend/javascripts/viewer/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 = {
Expand All @@ -52,13 +58,23 @@ type State = {
organizationToSwitchTo: APIOrganization | null | undefined;
};

type ControllerEditAllowedKeyboardHandlerIdMap = KeyboardShortcutNoLoopedHandlerMap<
GeneralKeyboardShortcuts | GeneralEditingKeyboardShortcuts
>;
type ControllerViewOnlyKeyboardHandlerIdMap =
KeyboardShortcutNoLoopedHandlerMap<GeneralKeyboardShortcuts>;
type ControllerKeyboardHandlerIdMap =
| ControllerEditAllowedKeyboardHandlerIdMap
| ControllerViewOnlyKeyboardHandlerIdMap;

class Controller extends PureComponent<PropsWithRouter, State> {
keyboardNoLoop?: InputKeyboardNoLoop;
_isMounted: boolean = false;
state: State = {
gotUnhandledError: false,
organizationToSwitchTo: null,
};
unsubscribeKeyboardListener: any = () => {};

// Main controller, responsible for setting modes and everything
// that has to be controlled in any mode.
Expand Down Expand Up @@ -91,6 +107,7 @@ class Controller extends PureComponent<PropsWithRouter, State> {
this.props.setBlocking({
shouldBlock: false,
});
this.unsubscribeKeyboardListener();
}

tryFetchingModel() {
Expand Down Expand Up @@ -208,26 +225,85 @@ class Controller extends PureComponent<PropsWithRouter, State> {
);
}

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> =
{
[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;
Expand All @@ -240,65 +316,40 @@ class Controller extends PureComponent<PropsWithRouter, State> {
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);
}
Expand Down
Loading
Loading