diff --git a/src/composables/actions.ts b/src/composables/actions.ts new file mode 100644 index 000000000..d19b4cca7 --- /dev/null +++ b/src/composables/actions.ts @@ -0,0 +1,47 @@ +import { useToolStore } from '../store/tools'; +import { Tools } from '../store/tools/types'; +import { useRectangleStore } from '../store/tools/rectangles'; +import { useRulerStore } from '../store/tools/rulers'; +import { usePolygonStore } from '../store/tools/polygons'; +import { Action } from '../constants'; + +const applyLabelOffset = (offset: number) => () => { + const toolToStore = { + [Tools.Rectangle]: useRectangleStore(), + [Tools.Ruler]: useRulerStore(), + [Tools.Polygon]: usePolygonStore(), + }; + const toolStore = useToolStore(); + + // @ts-ignore - toolToStore may not have keys of all tools + const activeToolStore = toolToStore[toolStore.currentTool]; + if (!activeToolStore) return; + + const labels = Object.entries(activeToolStore.labels); + const activeLabelIndex = labels.findIndex( + ([name]) => name === activeToolStore.activeLabel + ); + + const [nextLabel] = labels.at((activeLabelIndex + offset) % labels.length)!; + activeToolStore.setActiveLabel(nextLabel); +}; + +const setTool = (tool: Tools) => () => { + useToolStore().setCurrentTool(tool); +}; + +export const ACTION_TO_FUNC = { + windowLevel: setTool(Tools.WindowLevel), + pan: setTool(Tools.Pan), + zoom: setTool(Tools.Zoom), + ruler: setTool(Tools.Ruler), + paint: setTool(Tools.Paint), + rectangle: setTool(Tools.Rectangle), + crosshairs: setTool(Tools.Crosshairs), + crop: setTool(Tools.Crop), + polygon: setTool(Tools.Polygon), + select: setTool(Tools.Select), + + decrementLabel: applyLabelOffset(-1), + incrementLabel: applyLabelOffset(1), +} as const satisfies Record void>; diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts index 5d282bea0..7667684ee 100644 --- a/src/composables/useKeyboardShortcuts.ts +++ b/src/composables/useKeyboardShortcuts.ts @@ -1,34 +1,25 @@ -import { onKeyDown } from '@vueuse/core'; +import { ref, watch } from 'vue'; +import { useMagicKeys, whenever } from '@vueuse/core'; -import { DECREMENT_LABEL_KEY, INCREMENT_LABEL_KEY } from '../config'; -import { useToolStore } from '../store/tools'; -import { Tools } from '../store/tools/types'; -import { useRectangleStore } from '../store/tools/rectangles'; -import { useRulerStore } from '../store/tools/rulers'; -import { usePolygonStore } from '../store/tools/polygons'; +import { getEntries } from '../utils'; +import { ACTION_TO_KEY } from '../config'; +import { ACTION_TO_FUNC } from './actions'; -const applyLabelOffset = (offset: number) => { - const toolToStore = { - [Tools.Rectangle]: useRectangleStore(), - [Tools.Ruler]: useRulerStore(), - [Tools.Polygon]: usePolygonStore(), - }; - const toolStore = useToolStore(); +export const actionToKey = ref(ACTION_TO_KEY); - // @ts-ignore - map may not have all keys of tools - const activeToolStore = toolToStore[toolStore.currentTool]; - if (!activeToolStore) return; - - const labels = Object.entries(activeToolStore.labels); - const activeLabelIndex = labels.findIndex( - ([name]) => name === activeToolStore.activeLabel - ); +export function useKeyboardShortcuts() { + const keys = useMagicKeys(); + let unwatchFuncs = [] as Array>; - const [nextLabel] = labels.at((activeLabelIndex + offset) % labels.length)!; - activeToolStore.setActiveLabel(nextLabel); -}; + watch( + actionToKey, + (actionMap) => { + unwatchFuncs.forEach((unwatch) => unwatch()); -export function useKeyboardShortcuts() { - onKeyDown(DECREMENT_LABEL_KEY, () => applyLabelOffset(-1)); - onKeyDown(INCREMENT_LABEL_KEY, () => applyLabelOffset(1)); + unwatchFuncs = getEntries(actionMap).map(([action, key]) => { + return whenever(keys[key], ACTION_TO_FUNC[action]); + }); + }, + { immediate: true, deep: true } + ); } diff --git a/src/config.ts b/src/config.ts index e05d5ad69..0d494e637 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import USFetusThumbnail from '@/src/assets/samples/3DUS-Fetus.jpg'; import { Layout, LayoutDirection } from './types/layout'; import { ViewSpec } from './types/views'; import { SampleDataset } from './types'; +import { Action } from './constants'; /** * These are the initial view IDs. @@ -203,11 +204,25 @@ export const POLYGON_LABEL_DEFAULTS = { white: { color: '#ffffff' }, }; -export const DECREMENT_LABEL_KEY = 'q'; -export const INCREMENT_LABEL_KEY = 'w'; - export const DEFAULT_PRESET_BY_MODALITY: Record = { CT: 'CT-AAA', MR: 'CT-Coronary-Arteries-2', US: 'US-Fetal', }; + +// Keyboard shortcuts/hotkeys. Can add modifiers: 'Shift+Ctrl+A' +export const ACTION_TO_KEY = { + windowLevel: 'l', + pan: 'n', + zoom: 'z', + ruler: 'm', + paint: 'p', + rectangle: 'r', + crosshairs: 'c', + crop: 'b', + polygon: 'g', + select: 's', + + decrementLabel: 'q', + incrementLabel: 'w', +} satisfies Record; diff --git a/src/constants.ts b/src/constants.ts index aa6f5a25e..c09c1ba2d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -47,3 +47,23 @@ export const Messages = { } as const; export const ANNOTATION_TOOL_HANDLE_RADIUS = 10; // pixels + +export const ACTIONS = [ + // set the current tool + 'windowLevel', + 'pan', + 'zoom', + 'ruler', + 'paint', + 'rectangle', + 'crosshairs', + 'crop', + 'polygon', + 'select', + + // change the current label for the current tool + 'decrementLabel', + 'incrementLabel', +] as const; + +export type Action = (typeof ACTIONS)[number]; diff --git a/src/io/import/processors/handleConfig.ts b/src/io/import/processors/handleConfig.ts index 09217ee3a..c127abbaa 100644 --- a/src/io/import/processors/handleConfig.ts +++ b/src/io/import/processors/handleConfig.ts @@ -7,7 +7,9 @@ import { useDataBrowserStore } from '@/src/store/data-browser'; import { usePolygonStore } from '@/src/store/tools/polygons'; import { useViewStore } from '@/src/store/views'; import { Layouts } from '@/src/config'; -import { zodEnumFromObjKeys } from '@/src/utils'; +import { ensureError, zodEnumFromObjKeys } from '@/src/utils'; +import { ACTIONS } from '@/src/constants'; +import { actionToKey } from '@/src/composables/useKeyboardShortcuts'; const layout = z .object({ @@ -21,6 +23,8 @@ const dataBrowser = z }) .optional(); +const shortcuts = z.record(z.enum(ACTIONS), z.string()).optional(); + // -------------------------------------------------------------------------- // Labels @@ -54,6 +58,7 @@ const config = z.object({ layout, dataBrowser, labels, + shortcuts, }); type Config = z.infer; @@ -93,10 +98,20 @@ const applyLayout = (manifest: Config) => { } }; +const applyShortcuts = (manifest: Config) => { + if (!manifest.shortcuts) return; + + actionToKey.value = { + ...actionToKey.value, + ...manifest.shortcuts, + }; +}; + const applyConfig = (manifest: Config) => { applyLayout(manifest); applyLabels(manifest); applySampleData(manifest); + applyShortcuts(manifest); }; /** @@ -111,7 +126,10 @@ const handleConfig: ImportHandler = async (dataSource, { done }) => { const manifest = await readConfigFile(fileSrc.file); applyConfig(manifest); } catch (err) { - return dataSource; + console.error(err); + throw new Error('Failed to parse config file', { + cause: ensureError(err), + }); } return done(); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 2cc1d54e9..c1ac4e746 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -289,3 +289,12 @@ export const TypedArrayConstructorNames = [ 'Float32Array', 'Float64Array', ]; + +// https://stackoverflow.com/a/74823834 +type Entries = { + [K in keyof T]-?: [K, T[K]]; +}[keyof T][]; + +// Object.entries with keys preserved rather as string +export const getEntries = (obj: T) => + Object.entries(obj) as Entries;