Skip to content

Commit 45ca1a4

Browse files
committed
feat(shortcuts): delete selected annotations with the delete key
The delete key had to reach the app while a checkbox or slider held focus, which the old dispatcher could not do: it watched vueuse magic keys, rebuilt every watcher whenever a binding changed, and decided whether to suppress a shortcut by asking what kind of element had focus. Shortcuts now dispatch from one keydown listener against a table of parsed bindings. Anything a control handles in script reports itself through preventDefault, so arbitration only has to cover what the browser does natively and no handler announces: typing into a field, Space or Enter activating a focused control, and the arrow keys stepping through a group of values. An action can name more than one key, since the main delete key reports Backspace on macOS. Hold actions read their state through useActionHeld instead of each view resolving a binding itself, and a binding no keystroke could produce is dropped from a loaded config with a warning rather than rejecting the file. Removing a selection says how many annotations went, because the selection can hold annotations with no visible cue in the current view and there is no undo.
1 parent 4130541 commit 45ca1a4

24 files changed

Lines changed: 1162 additions & 213 deletions

‎src/components/ControlsModal.vue‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434

3535
<script setup lang="ts">
3636
import { computed } from 'vue';
37-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
37+
import {
38+
actionToKey,
39+
readableBinding,
40+
} from '@/src/composables/useKeyboardShortcuts';
3841
import { ACTIONS } from '@/src/constants';
3942
import { useKeyboardShortcutsStore } from '@/src/store/keyboard-shortcuts';
4043
import CloseableDialog from './CloseableDialog.vue';
@@ -43,9 +46,9 @@ import { getEntries } from '../utils';
4346
const keyboardStore = useKeyboardShortcutsStore();
4447
4548
const bindings = computed(() =>
46-
getEntries(actionToKey.value).map(([action, key]) => [
49+
getEntries(actionToKey.value).map(([action, binding]) => [
4750
ACTIONS[action].readable,
48-
key,
51+
readableBinding(binding),
4952
])
5053
);
5154
</script>

‎src/components/ControlsStripTools.vue‎

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@
135135

136136
<script lang="ts">
137137
import { computed, defineComponent, ref, watch } from 'vue';
138-
import { onKeyDown, useMagicKeys } from '@vueuse/core';
138+
import { onKeyDown } from '@vueuse/core';
139139
import { Tools } from '@/src/store/tools/types';
140140
import ControlButton from '@/src/components/ControlButton.vue';
141141
import ItemGroup from '@/src/components/ItemGroup.vue';
@@ -150,7 +150,11 @@ import RulerControls from '@/src/components/RulerControls.vue';
150150
import RectangleControls from '@/src/components/RectangleControls.vue';
151151
import PolygonControls from '@/src/components/PolygonControls.vue';
152152
import WindowLevelControls from '@/src/components/tools/windowing/WindowLevelControls.vue';
153-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
153+
import {
154+
actionToKey,
155+
readableBinding,
156+
useActionHeld,
157+
} from '@/src/composables/useKeyboardShortcuts';
154158
import { useCurrentImage } from '@/src/composables/useCurrentImage';
155159
import { useViewStore } from '@/src/store/views';
156160
@@ -199,10 +203,7 @@ export default defineComponent({
199203
windowingMenu.value = false;
200204
});
201205
202-
const keys = useMagicKeys();
203-
const enableTempCrosshairs = computed(
204-
() => keys[actionToKey.value.temporaryCrosshairs].value
205-
);
206+
const enableTempCrosshairs = useActionHeld('temporaryCrosshairs');
206207
watch(enableTempCrosshairs, (enable) => {
207208
if (enable) toolStore.activateTemporaryCrosshairs();
208209
else toolStore.deactivateTemporaryCrosshairs();
@@ -212,16 +213,16 @@ export default defineComponent({
212213
const nameToShortcut = computed(() => {
213214
const keyMap = actionToKey.value;
214215
return {
215-
'Window & Level': keyMap.windowLevel,
216-
Pan: keyMap.pan,
217-
Zoom: keyMap.zoom,
218-
Crosshairs: keyMap.crosshairs,
219-
Select: keyMap.select,
220-
Paint: keyMap.paint,
221-
Rectangle: keyMap.rectangle,
222-
Polygon: keyMap.polygon,
223-
Ruler: keyMap.ruler,
224-
Crop: keyMap.crop,
216+
'Window & Level': readableBinding(keyMap.windowLevel),
217+
Pan: readableBinding(keyMap.pan),
218+
Zoom: readableBinding(keyMap.zoom),
219+
Crosshairs: readableBinding(keyMap.crosshairs),
220+
Select: readableBinding(keyMap.select),
221+
Paint: readableBinding(keyMap.paint),
222+
Rectangle: readableBinding(keyMap.rectangle),
223+
Polygon: readableBinding(keyMap.polygon),
224+
Ruler: readableBinding(keyMap.ruler),
225+
Crop: readableBinding(keyMap.crop),
225226
};
226227
});
227228

‎src/components/MeasurementsToolList.vue‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useCurrentImage } from '@/src/composables/useCurrentImage';
44
import { frameOfReferenceToImageSliceAndAxis } from '@/src/utils/frameOfReference';
55
import { nonNullable } from '@/src/utils/index';
66
import { AnnotationToolType } from '@/src/store/tools/types';
7-
import { useAnnotationToolStore } from '@/src/store/tools';
7+
import { removeSelectedTools, useAnnotationToolStore } from '@/src/store/tools';
88
import {
99
useMultipleToolSelection,
1010
MultipleSelectionState,
@@ -89,13 +89,6 @@ const toggleSelectAll = (shouldSelectAll: Maybe<boolean>) => {
8989
}
9090
};
9191
92-
function removeAll() {
93-
selectionStore.selection.forEach((sel) => {
94-
const store = useAnnotationToolStore(sel.type);
95-
store.removeTool(sel.id);
96-
});
97-
}
98-
9992
// If all selected tools are already hidden, it should be "show".
10093
// If at least one selected tool is visible, it should be "hide".
10194
const allHidden = computed(() => {
@@ -156,7 +149,7 @@ function toggleGlobalHidden() {
156149
icon
157150
variant="text"
158151
:disabled="selectionState === MultipleSelectionState.None"
159-
@click.stop="removeAll"
152+
@click.stop="removeSelectedTools"
160153
>
161154
<v-icon>mdi-delete</v-icon>
162155
<v-tooltip

‎src/components/tools/paint/PaintWidget2D.vue‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
watchEffect,
1010
inject,
1111
} from 'vue';
12-
import { useMagicKeys } from '@vueuse/core';
1312
import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator';
1413
import { vec3 } from 'gl-matrix';
1514
import { getLPSAxisFromDir } from '@/src/utils/lps';
@@ -24,7 +23,7 @@ import { onVTKEvent } from '@/src/composables/onVTKEvent';
2423
import { useSliceInfo } from '@/src/composables/useSliceInfo';
2524
import { VtkViewContext } from '@/src/components/vtk/context';
2625
import { Maybe } from '@/src/types';
27-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
26+
import { useActionHeld } from '@/src/composables/useKeyboardShortcuts';
2827
2928
export default defineComponent({
3029
name: 'PaintWidget2D',
@@ -172,10 +171,7 @@ export default defineComponent({
172171
});
173172
174173
// Brush size scroll wheel control with customizable modifier key
175-
const keys = useMagicKeys();
176-
const enableBrushSizeAdjustment = computed(
177-
() => keys[actionToKey.value.brushSizeModifier].value
178-
);
174+
const enableBrushSizeAdjustment = useActionHeld('brushSizeModifier');
179175
180176
const handleWheelEvent = (event: WheelEvent) => {
181177
if (!enableBrushSizeAdjustment.value) return;

‎src/components/tools/polygon/PolygonTool.vue‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,11 @@ import {
101101
} from '@/src/composables/annotationTool';
102102
import AnnotationContextMenu from '@/src/components/tools/AnnotationContextMenu.vue';
103103
import AnnotationInfo from '@/src/components/tools/AnnotationInfo.vue';
104-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
104+
import { useActionHeld } from '@/src/composables/useKeyboardShortcuts';
105105
import { Maybe } from '@/src/types';
106106
import { useViewLocator } from '@/src/composables/useViewLocator';
107107
import { locatorPatch } from '@/src/core/annotations/locator';
108-
import { useMagicKeys, watchImmediate } from '@vueuse/core';
108+
import { watchImmediate } from '@vueuse/core';
109109
import { fillPoly } from '@thi.ng/rasterize';
110110
import type { IGrid2D } from '@thi.ng/api';
111111
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
@@ -211,10 +211,7 @@ export default defineComponent({
211211
placingTool.remove();
212212
});
213213
214-
const keys = useMagicKeys();
215-
const mergeKey = computed(
216-
() => keys[actionToKey.value.mergeNewPolygon].value
217-
);
214+
const mergeKey = useActionHeld('mergeNewPolygon');
218215
219216
const onToolPlaced = () => {
220217
if (imageId.value) {

‎src/components/vtk/VtkCineScrubKeyManipulator.vue‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ import { Maybe } from '@/src/types';
77
import vtkGatedMouseRangeManipulator from '@/src/vtk/GatedMouseRangeManipulator';
88
import { IMouseRangeManipulatorInitialValues } from '@kitware/vtk.js/Interaction/Manipulators/MouseRangeManipulator';
99
import vtkInteractorStyleManipulator from '@kitware/vtk.js/Interaction/Style/InteractorStyleManipulator';
10-
import { syncRef, useMagicKeys } from '@vueuse/core';
10+
import { syncRef } from '@vueuse/core';
1111
import { inject, toRefs, unref, watch, computed } from 'vue';
1212
import { useViewStore } from '@/src/store/views';
13-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
13+
import { useActionHeld } from '@/src/composables/useKeyboardShortcuts';
1414
1515
type Props = {
1616
viewId: string;
@@ -45,8 +45,7 @@ const { instance: rangeManipulator } = useVtkInteractionManipulator(
4545
4646
rangeManipulator.value.setupMouseMove(view.interactor);
4747
48-
const keys = useMagicKeys();
49-
const enableGrabSlice = computed(() => keys[actionToKey.value.grabSlice].value);
48+
const enableGrabSlice = useActionHeld('grabSlice');
5049
watch(
5150
enableGrabSlice,
5251
(value) => {

‎src/components/vtk/VtkSliceViewSlicingKeyManipulator.vue‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import { LPSAxisDir } from '@/src/types/lps';
88
import vtkGatedMouseRangeManipulator from '@/src/vtk/GatedMouseRangeManipulator';
99
import { IMouseRangeManipulatorInitialValues } from '@kitware/vtk.js/Interaction/Manipulators/MouseRangeManipulator';
1010
import vtkInteractorStyleManipulator from '@kitware/vtk.js/Interaction/Style/InteractorStyleManipulator';
11-
import { syncRef, useMagicKeys } from '@vueuse/core';
11+
import { syncRef } from '@vueuse/core';
1212
import { inject, toRefs, unref, watch, computed } from 'vue';
1313
import { useViewStore } from '@/src/store/views';
14-
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
14+
import { useActionHeld } from '@/src/composables/useKeyboardShortcuts';
1515
1616
type Props = {
1717
viewId: string;
@@ -49,8 +49,7 @@ const { instance: rangeManipulator } = useVtkInteractionManipulator(
4949
5050
rangeManipulator.value.setupMouseMove(view.interactor);
5151
52-
const keys = useMagicKeys();
53-
const enableGrabSlice = computed(() => keys[actionToKey.value.grabSlice].value);
52+
const enableGrabSlice = useActionHeld('grabSlice');
5453
watch(
5554
enableGrabSlice,
5655
(value) => {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
import { setActivePinia, createPinia } from 'pinia';
3+
import { effectScope, nextTick } from 'vue';
4+
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
5+
6+
import { useImageCacheStore } from '@/src/store/image-cache';
7+
import { AnnotationToolType } from '@/src/store/tools/types';
8+
import { useToolSelectionStore } from '@/src/store/tools/toolSelection';
9+
import { useRulerStore } from '@/src/store/tools/rulers';
10+
import { useKeyboardShortcuts } from '@/src/composables/useKeyboardShortcuts';
11+
12+
const IMAGE_ID = 'img-1';
13+
14+
/** Presses delete under a live listener, then tears it down. */
15+
const pressDelete = async () => {
16+
const scope = effectScope();
17+
scope.run(() => useKeyboardShortcuts());
18+
19+
// a real keystroke starts at the focused element and bubbles to window
20+
document.activeElement?.dispatchEvent(
21+
new KeyboardEvent('keydown', {
22+
key: 'Delete',
23+
bubbles: true,
24+
cancelable: true,
25+
})
26+
);
27+
await nextTick();
28+
29+
scope.stop();
30+
};
31+
32+
const addSelectedRuler = () => {
33+
const id = useRulerStore().addTool({
34+
imageID: IMAGE_ID,
35+
placing: false,
36+
firstPoint: [1, 1, 1],
37+
secondPoint: [2, 2, 2],
38+
});
39+
useToolSelectionStore().addSelection(id, AnnotationToolType.Ruler);
40+
return id;
41+
};
42+
43+
describe('delete key removes selected annotations', () => {
44+
beforeEach(async () => {
45+
setActivePinia(createPinia());
46+
useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', {
47+
id: IMAGE_ID,
48+
});
49+
await nextTick();
50+
});
51+
52+
afterEach(() => {
53+
document.body.innerHTML = '';
54+
});
55+
56+
it('removes the selected annotation when delete is pressed', async () => {
57+
const ruler = addSelectedRuler();
58+
59+
await pressDelete();
60+
61+
expect(useRulerStore().toolByID).not.toHaveProperty(ruler);
62+
expect(useToolSelectionStore().selection).toEqual([]);
63+
});
64+
65+
it('keeps unselected annotations when delete is pressed', async () => {
66+
const rulerStore = useRulerStore();
67+
const kept = rulerStore.addTool({
68+
imageID: IMAGE_ID,
69+
placing: false,
70+
firstPoint: [3, 3, 3],
71+
secondPoint: [4, 4, 4],
72+
});
73+
const selected = addSelectedRuler();
74+
75+
await pressDelete();
76+
77+
expect(rulerStore.toolByID).not.toHaveProperty(selected);
78+
expect(rulerStore.toolByID).toHaveProperty(kept);
79+
});
80+
81+
it('ignores the delete key while typing in a text field', async () => {
82+
const ruler = addSelectedRuler();
83+
84+
const input = document.createElement('input');
85+
document.body.appendChild(input);
86+
input.focus();
87+
88+
await pressDelete();
89+
90+
expect(useRulerStore().toolByID).toHaveProperty(ruler);
91+
});
92+
93+
// Checking a row in the annotations panel leaves focus on its checkbox
94+
it('removes the annotation while a checkbox holds focus', async () => {
95+
const ruler = addSelectedRuler();
96+
97+
const checkbox = document.createElement('input');
98+
checkbox.type = 'checkbox';
99+
document.body.appendChild(checkbox);
100+
checkbox.focus();
101+
102+
await pressDelete();
103+
104+
expect(useRulerStore().toolByID).not.toHaveProperty(ruler);
105+
});
106+
});

0 commit comments

Comments
 (0)