Skip to content

Commit

Permalink
Add extended keyboard shortcut mode via ctrl + k for tool shortcuts (#…
Browse files Browse the repository at this point in the history
…7112)

* add extended keyboard shortcut mode via ctrl + k

* enable extended shortcuts dependent on active tracing type

* add proofreading tool shortcut

* add new shortcuts to docs

* remove unused keyboard prop

* add changelog entry

* fix tests and typo

* fix linting (unused arg)

* apply review feedback

* prevent browser searchbar focus on ctrl + k

* fix linting
  • Loading branch information
MichaelBuessemeyer authored Jun 16, 2023
1 parent b91b15f commit 80e2525
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/23.06.0...HEAD)

### Added
- Added new shortcuts for fast tool switching. Look at the updated [Keyboard Shortcuts documentation](https://docs.webknossos.org/webknossos/keyboard_shortcuts.html#tool-switching-shortcuts) to see the new shortcuts. [#7112](https://github.com/scalableminds/webknossos/pull/7112)
- Subfolders of the currently active folder are now also rendered in the dataset table in the dashboard. [#6996](https://github.com/scalableminds/webknossos/pull/6996)
- Added ability to view [zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html) datasets. [#7079](https://github.com/scalableminds/webknossos/pull/7079)
- Added an index structure for volume annotation segments, in preparation for per-segment statistics. [#7063](https://github.com/scalableminds/webknossos/pull/7063)
Expand Down
24 changes: 21 additions & 3 deletions docs/keyboard_shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,6 @@ Note that you can enable *Classic Controls* which will behave slightly different
| CTRL + SHIFT + Left Mouse Drag | Remove Voxels From Segment |
| Alt + Mouse Move | Move |
| C | Create New Segment |
| W | Cycle Through Tools (Move / Skeleton / Trace / Brush / ...) |
| SHIFT + W | Cycle Backwards Through Tools (Move / Proofread / Bounding Box / Pick Cell / ...) |
| SHIFT + Mousewheel or SHIFT + I, O | Change Brush Size (Brush Mode) |
| V | Interpolate current segment between last labeled and current slice |

Expand All @@ -112,12 +110,32 @@ Note that you can enable *Classic Controls* which won't open a context menu on r
| Right Mouse Drag | Remove Voxels |
| CTRL + Right Mouse Drag | Remove Voxels while inverting the overwrite-mode (see toolbar for overwrite-mode) |

## Tool Switching Shortcuts

Note that you need to first press CTRL + K, release these keys and then the letter that was assigned to a specific tool in order to switch to it.
CTRL + K is not needed for cyclic tool switching via W / SHIFT + W.

| Key Binding | Operation |
| --------------------------------- | --------------------------------------------------------------------------------- |
| W | Cycle Through Tools (Move / Skeleton / Trace / Brush / ...) |
| SHIFT + W | Cycle Backwards Through Tools (Move / Proofread / Bounding Box / Pick Cell / ...) |
| CTRL + K, **M** | Move Tool |
| CTRL + K, **S** | Skeleton Tool |
| CTRL + K, **B** | Brush Tool |
| CTRL + K, **E** | Brush Erase Tool |
| CTRL + K, **L** | Lasso Tool |
| CTRL + K, **R** | Lasso Erase Too |
| CTRL + K, **P** | Segment Picker Tool |
| CTRL + K, **Q** | Quick Select Tool |
| CTRL + K, **X** | Bounding Box Tool |
| CTRL + K, **O** | Proofreading Tool |

## Mesh Related Shortcuts

| Key Binding | Operation |
| ------------------------------------------------------ | ----------------------------------------------------------- |
| Shift + Click on a mesh in the 3D viewport | Move the camera to the clicked position |
| Ctrl + Click on a mesh in the 3D viewport | Unload the mesh from WEBKNOSSOS
| Ctrl + Click on a mesh in the 3D viewport | Unload the mesh from WEBKNOSSOS |

## Agglomerate File Mapping Skeleton

Expand Down
75 changes: 72 additions & 3 deletions frontend/javascripts/libs/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,28 +67,79 @@ 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 = "ctrl + k";
const EXTENDED_COMMAND_DURATION = 3000;
export class InputKeyboardNoLoop {
bindings: Array<KeyboardBindingPress> = [];
isStarted: boolean = true;
supportInputElements: boolean = false;
hasExtendedBindings: boolean = false;
cancelExtendedModeTimeoutId: ReturnType<typeof setTimeout> | null = null;

constructor(
initialBindings: BindingMap<KeyboardHandler>,
options?: {
supportInputElements?: boolean;
},
extendedCommands?: BindingMap<KeyboardHandler>,
) {
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;
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, true);
for (const key of Object.keys(extendedCommands)) {
const callback = extendedCommands[key];
this.attach(key, callback, true);
}
}

for (const key of Object.keys(initialBindings)) {
const callback = initialBindings[key];
this.attach(key, callback);
}
}

attach(key: KeyboardKey, callback: KeyboardHandler) {
toggleExtendedMode = (evt: KeyboardEvent) => {
evt.preventDefault();
const isInExtendedMode = KeyboardJS.getContext() === "extended";
if (isInExtendedMode) {
this.cancelExtendedModeTimeout();
KeyboardJS.setContext("default");
return;
}
KeyboardJS.setContext("extended");
this.cancelExtendedModeTimeoutId = setTimeout(() => {
KeyboardJS.setContext("default");
}, EXTENDED_COMMAND_DURATION);
};

preventBrowserSearchbarShortcut = (evt: KeyboardEvent) => {
if (evt.ctrlKey && evt.key === "k") {
evt.preventDefault();
evt.stopPropagation();
}
};

cancelExtendedModeTimeout() {
if (this.cancelExtendedModeTimeoutId != null) {
clearTimeout(this.cancelExtendedModeTimeoutId);
this.cancelExtendedModeTimeoutId = null;
}
}

attach(key: KeyboardKey, callback: KeyboardHandler, isExtendedCommand: boolean = false) {
const binding = [
key,
(event: KeyboardEvent) => {
Expand All @@ -103,6 +154,11 @@ export class InputKeyboardNoLoop {
if (shouldIgnore(event, key)) {
return;
}
const isInExtendedMode = KeyboardJS.getContext() === "extended";
if (isInExtendedMode) {
this.cancelExtendedModeTimeout();
KeyboardJS.setContext("default");
}

if (!event.repeat) {
callback(event);
Expand All @@ -113,7 +169,15 @@ export class InputKeyboardNoLoop {
},
_.noop,
];
KeyboardJS.bind(...binding);
if (isExtendedCommand) {
KeyboardJS.withContext("extended", () => {
KeyboardJS.bind(...binding);
});
} else {
KeyboardJS.withContext("default", () => {
KeyboardJS.bind(...binding);
});
}
// @ts-expect-error ts-migrate(2345) FIXME: Argument of type '(string | ((...args: any[]) => v... Remove this comment to see the full error message
return this.bindings.push(binding);
}
Expand All @@ -124,6 +188,9 @@ export class InputKeyboardNoLoop {
for (const binding of this.bindings) {
KeyboardJS.unbind(...binding);
}
if (this.hasExtendedBindings) {
document.removeEventListener("keydown", this.preventBrowserSearchbarShortcut);
}
}
}
// This module is "main" keyboard handler.
Expand Down Expand Up @@ -221,7 +288,9 @@ export class InputKeyboard {
}
},
];
KeyboardJS.bind(...binding);
KeyboardJS.withContext("default", () => {
KeyboardJS.bind(...binding);
});
this.bindings.push(binding);
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/javascripts/libs/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@
this._paused = false;
this.setContext("global");
this.watch(targetWindow, targetElement, platform, userAgent);
// Switch to default context whose shortcuts will not be active in other contexts.
// Having the global context as default would leave the shortcuts in all contexts active.
this.setContext("default");
}

Keyboard.prototype.setLocale = function (localeName, localeBuilder) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import {
createCellAction,
interpolateSegmentationLayerAction,
} from "oxalis/model/actions/volumetracing_actions";
import { cycleToolAction, enterAction, escapeAction } from "oxalis/model/actions/ui_actions";
import {
cycleToolAction,
enterAction,
escapeAction,
setToolAction,
} from "oxalis/model/actions/ui_actions";
import {
MoveTool,
SkeletonTool,
Expand Down Expand Up @@ -93,6 +98,10 @@ const cycleToolsBackwards = () => {
Store.dispatch(cycleToolAction(true));
};

const setTool = (tool: AnnotationTool) => {
Store.dispatch(setToolAction(tool));
};

type StateProps = {
tracing: Tracing;
activeTool: AnnotationTool;
Expand Down Expand Up @@ -131,6 +140,10 @@ class SkeletonKeybindings {
"ctrl + down": () => SkeletonHandlers.moveNode(0, 1),
};
}

static getExtendedKeyboardControls() {
return { s: () => setTool(AnnotationToolEnum.SKELETON) };
}
}

class VolumeKeybindings {
Expand All @@ -154,6 +167,19 @@ class VolumeKeybindings {
},
};
}

static getExtendedKeyboardControls() {
return {
b: () => setTool(AnnotationToolEnum.BRUSH),
e: () => setTool(AnnotationToolEnum.ERASE_BRUSH),
l: () => setTool(AnnotationToolEnum.TRACE),
r: () => setTool(AnnotationToolEnum.ERASE_TRACE),
f: () => setTool(AnnotationToolEnum.FILL_CELL),
p: () => setTool(AnnotationToolEnum.PICK_CELL),
q: () => setTool(AnnotationToolEnum.QUICK_SELECT),
o: () => setTool(AnnotationToolEnum.PROOFREAD),
};
}
}

class BoundingBoxKeybindings {
Expand All @@ -162,6 +188,10 @@ class BoundingBoxKeybindings {
c: () => Store.dispatch(addUserBoundingBoxAction()),
};
}

static getExtendedKeyboardControls() {
return { x: () => setTool(AnnotationToolEnum.BOUNDING_BOX) };
}
}

const getMoveValue = (timeFactor: number) => {
Expand Down Expand Up @@ -376,7 +406,10 @@ class PlaneController extends React.PureComponent<Props> {
up: (timeFactor) => MoveHandlers.moveV(-getMoveValue(timeFactor)),
down: (timeFactor) => MoveHandlers.moveV(getMoveValue(timeFactor)),
});
const notLoopedKeyboardControls = this.getNotLoopedKeyboardControls();
const {
baseControls: notLoopedKeyboardControls,
extendedControls: extendedNotLoopedKeyboardControls,
} = this.getNotLoopedKeyboardControls();
const loopedKeyboardControls = this.getLoopedKeyboardControls();
ensureNonConflictingHandlers(notLoopedKeyboardControls, loopedKeyboardControls);
this.input.keyboardLoopDelayed = new InputKeyboard(
Expand Down Expand Up @@ -404,7 +437,11 @@ class PlaneController extends React.PureComponent<Props> {
delay: Store.getState().userConfiguration.keyboardDelay,
},
);
this.input.keyboardNoLoop = new InputKeyboardNoLoop(notLoopedKeyboardControls);
this.input.keyboardNoLoop = new InputKeyboardNoLoop(
notLoopedKeyboardControls,
{},
extendedNotLoopedKeyboardControls,
);
this.storePropertyUnsubscribers.push(
listenToStoreProperty(
(state) => state.userConfiguration.keyboardDelay,
Expand Down Expand Up @@ -454,6 +491,11 @@ class PlaneController extends React.PureComponent<Props> {
w: cycleTools,
"shift + w": cycleToolsBackwards,
};
let extendedControls = {
m: () => setTool(AnnotationToolEnum.MOVE),
...BoundingBoxKeybindings.getExtendedKeyboardControls(),
};

// TODO: Find a nicer way to express this, while satisfying flow
const emptyDefaultHandler = {
c: null,
Expand All @@ -468,16 +510,29 @@ class PlaneController extends React.PureComponent<Props> {
: emptyDefaultHandler;
const { c: boundingBoxCHandler } = BoundingBoxKeybindings.getKeyboardControls();
ensureNonConflictingHandlers(skeletonControls, volumeControls);
const extendedSkeletonControls =
this.props.tracing.skeleton != null ? SkeletonKeybindings.getExtendedKeyboardControls() : {};
const extendedVolumeControls =
this.props.tracing.volumes.length > 0 != null
? VolumeKeybindings.getExtendedKeyboardControls()
: {};
ensureNonConflictingHandlers(extendedSkeletonControls, extendedVolumeControls);
const extendedAnnotationControls = { ...extendedSkeletonControls, ...extendedVolumeControls };
ensureNonConflictingHandlers(extendedAnnotationControls, extendedControls);
extendedControls = { ...extendedControls, ...extendedAnnotationControls };

return {
...baseControls,
...skeletonControls,
...volumeControls,
c: this.createToolDependentKeyboardHandler(
skeletonCHandler,
volumeCHandler,
boundingBoxCHandler,
),
baseControls: {
...baseControls,
...skeletonControls,
...volumeControls,
c: this.createToolDependentKeyboardHandler(
skeletonCHandler,
volumeCHandler,
boundingBoxCHandler,
),
},
extendedControls,
};
}

Expand Down
1 change: 1 addition & 0 deletions frontend/javascripts/test/helpers/apiHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ mockRequire("libs/date", DateMock);
export const KeyboardJS = {
bind: _.noop,
unbind: _.noop,
withContext: (_arg0: string, arg1: () => void) => arg1(),
};
mockRequire("libs/keyboard", KeyboardJS);
mockRequire("libs/toast", {
Expand Down

0 comments on commit 80e2525

Please sign in to comment.