From 79bf34a2ff6d834c850c1840f6f8b6798dd48f80 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 16 Aug 2023 14:12:55 -0400 Subject: [PATCH 1/4] feat(history): add core history mechanism --- src/core/__tests__/history.spec.ts | 111 ++++++++++++++++++++++++++++ src/core/history.ts | 48 ++++++++++++ src/store/__tests__/history.spec.ts | 69 +++++++++++++++++ src/store/history.ts | 55 ++++++++++++++ src/types/history.ts | 11 +++ 5 files changed, 294 insertions(+) create mode 100644 src/core/__tests__/history.spec.ts create mode 100644 src/core/history.ts create mode 100644 src/store/__tests__/history.spec.ts create mode 100644 src/store/history.ts create mode 100644 src/types/history.ts diff --git a/src/core/__tests__/history.spec.ts b/src/core/__tests__/history.spec.ts new file mode 100644 index 000000000..d75cb064b --- /dev/null +++ b/src/core/__tests__/history.spec.ts @@ -0,0 +1,111 @@ +/* eslint-disable no-param-reassign */ +import { HistoryManager } from '@/src/core/history'; +import { expect } from 'chai'; +import { describe, it } from 'vitest'; + +interface Collection { + [x: string]: string; +} + +function createOperation(target: Collection, key: string, value: string) { + const notApplied = Symbol('not applied'); + let old: any = notApplied; + + const isApplied = () => old !== notApplied; + + const apply = () => { + if (isApplied()) return; + old = target[key]; + target[key] = value; + }; + + const revert = () => { + if (!isApplied()) return; + if (old === undefined) { + delete target[key]; + } else { + target[key] = old; + } + old = notApplied; + }; + + return { isApplied, apply, revert }; +} + +describe('History', () => { + it('should apply a series of pushed operations', () => { + const collection: Collection = Object.create(null); + + const op1 = createOperation(collection, 'dog', 'border collie'); + const op2 = createOperation(collection, 'cat', 'orange'); + const op3 = createOperation(collection, 'tree', 'willow'); + + const manager = new HistoryManager(); + manager.pushOperation(op1, true); + manager.pushOperation(op2, true); + manager.pushOperation(op3, true); + + expect(collection).to.deep.equal({ + dog: 'border collie', + cat: 'orange', + tree: 'willow', + }); + + manager.undo(); + manager.undo(); + expect(collection).to.deep.equal({ + dog: 'border collie', + }); + + manager.redo(); + expect(collection).to.deep.equal({ + dog: 'border collie', + cat: 'orange', + }); + + manager.undo(); + const op4 = createOperation(collection, 'cloud', 'cumulus'); + manager.pushOperation(op4, true); + expect(collection).to.deep.equal({ + dog: 'border collie', + cloud: 'cumulus', + }); + + manager.redo(); + expect(collection).to.deep.equal({ + dog: 'border collie', + cloud: 'cumulus', + }); + }); + + it('should support finite history', () => { + const collection: Collection = Object.create(null); + + const op1 = createOperation(collection, 'dog', 'border collie'); + const op2 = createOperation(collection, 'cat', 'orange'); + const op3 = createOperation(collection, 'tree', 'willow'); + + const manager = new HistoryManager(2); + manager.pushOperation(op1, true); + manager.pushOperation(op2, true); + manager.pushOperation(op3, true); + + expect(collection).to.deep.equal({ + dog: 'border collie', + cat: 'orange', + tree: 'willow', + }); + + manager.undo(); + manager.undo(); + expect(collection).to.deep.equal({ + dog: 'border collie', + }); + + // no more history past 2 undos + manager.undo(); + expect(collection).to.deep.equal({ + dog: 'border collie', + }); + }); +}); diff --git a/src/core/history.ts b/src/core/history.ts new file mode 100644 index 000000000..26659e313 --- /dev/null +++ b/src/core/history.ts @@ -0,0 +1,48 @@ +import { IHistoryManager, IHistoryOperation } from '@/src/types/history'; + +const INFINITE_HISTORY = Infinity; + +export class HistoryManager implements IHistoryManager { + #undoStack: IHistoryOperation[]; + #redoStack: IHistoryOperation[]; + #maxHistory: number; + + constructor(maxHistory = INFINITE_HISTORY) { + this.#maxHistory = maxHistory; + this.#undoStack = []; + this.#redoStack = []; + } + + get maxHistory() { + return this.#maxHistory; + } + + undo() { + if (!this.#undoStack.length) { + return; + } + const op = this.#undoStack.pop()!; + this.#redoStack.push(op); + op.revert(); + } + + redo() { + if (!this.#redoStack.length) { + return; + } + const op = this.#redoStack.pop()!; + this.#undoStack.push(op); + op.apply(); + } + + pushOperation(operation: IHistoryOperation, autoApply = false) { + this.#redoStack.length = 0; + this.#undoStack.push(operation); + while (this.#undoStack.length > this.#maxHistory) { + this.#undoStack.shift(); + } + if (autoApply && !operation.isApplied()) { + operation.apply(); + } + } +} diff --git a/src/store/__tests__/history.spec.ts b/src/store/__tests__/history.spec.ts new file mode 100644 index 000000000..26843749d --- /dev/null +++ b/src/store/__tests__/history.spec.ts @@ -0,0 +1,69 @@ +/* eslint-disable no-param-reassign */ +import useHistoryStore from '@/src/store/history'; +import { expect } from 'chai'; +import { createPinia, setActivePinia } from 'pinia'; +import { it, beforeEach, describe } from 'vitest'; + +interface Collection { + [x: string]: string; +} + +function createOperation(target: Collection, key: string, value: string) { + const notApplied = Symbol('not applied'); + let old: any = notApplied; + + const isApplied = () => old !== notApplied; + + const apply = () => { + if (isApplied()) return; + old = target[key]; + target[key] = value; + }; + + const revert = () => { + if (!isApplied()) return; + if (old === undefined) { + delete target[key]; + } else { + target[key] = old; + } + old = notApplied; + }; + + return { isApplied, apply, revert }; +} + +describe('History store', () => { + beforeEach(() => { + const pinia = createPinia(); + setActivePinia(pinia); + }); + + it('should record history', () => { + const store = useHistoryStore(); + const collection: Collection = Object.create(null); + + const ops = [ + createOperation(collection, 'fowl', 'dove'), + createOperation(collection, 'dinosaur', 'stegosaurus'), + ]; + + ops.forEach((op) => store.pushOperation({ datasetID: '1' }, op, true)); + + expect(collection).to.deep.equal({ + fowl: 'dove', + dinosaur: 'stegosaurus', + }); + + store.undo({ datasetID: '1' }); + expect(collection).to.deep.equal({ + fowl: 'dove', + }); + + store.redo({ datasetID: '2' }); + store.undo({ datasetID: '2' }); + expect(collection).to.deep.equal({ + fowl: 'dove', + }); + }); +}); diff --git a/src/store/history.ts b/src/store/history.ts new file mode 100644 index 000000000..3afc5649e --- /dev/null +++ b/src/store/history.ts @@ -0,0 +1,55 @@ +import { HistoryManager } from '@/src/core/history'; +import { IHistoryOperation } from '@/src/types/history'; +import { defineStore } from 'pinia'; + +export interface HistoryContextKeyComponents { + datasetID: string; +} + +export function createHistoryContextKey< + C extends {} = HistoryContextKeyComponents +>(components: C) { + return Object.entries(components) + .reduce( + (flattened, [key, value]) => [...flattened, `${key}:${value}`], + [] as string[] + ) + .join(','); +} + +const useHistoryStore = defineStore('history', () => { + const managers: Record = Object.create(null); + + const pushOperation = ( + key: HistoryContextKeyComponents, + operation: IHistoryOperation, + autoApply = false + ) => { + const contextKey = createHistoryContextKey(key); + if (!(contextKey in managers)) { + managers[contextKey] = new HistoryManager(); + } + managers[contextKey].pushOperation(operation, autoApply); + }; + + const getManager = (key: HistoryContextKeyComponents) => { + const contextKey = createHistoryContextKey(key); + return managers[contextKey]; + }; + + const undo = (key: HistoryContextKeyComponents) => { + getManager(key)?.undo(); + }; + + const redo = (key: HistoryContextKeyComponents) => { + getManager(key)?.redo(); + }; + + return { + pushOperation, + undo, + redo, + }; +}); + +export default useHistoryStore; diff --git a/src/types/history.ts b/src/types/history.ts new file mode 100644 index 000000000..ce87db038 --- /dev/null +++ b/src/types/history.ts @@ -0,0 +1,11 @@ +export interface IHistoryOperation { + apply(): T; + revert(): void; + isApplied(): boolean; +} + +export interface IHistoryManager { + undo(): void; + redo(): void; + pushOperation(operation: IHistoryOperation): void; +} From 12a22afa150caee83ae2caa87a7dca215208bb30 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 16 Aug 2023 14:13:42 -0400 Subject: [PATCH 2/4] feat(useKeyboardShortcuts): add undo/redo --- src/composables/useKeyboardShortcuts.ts | 36 ++++++++++++++++++++++--- src/config.ts | 7 +++-- src/constants.ts | 2 ++ 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts index 5d282bea0..6c993ad05 100644 --- a/src/composables/useKeyboardShortcuts.ts +++ b/src/composables/useKeyboardShortcuts.ts @@ -1,12 +1,21 @@ -import { onKeyDown } from '@vueuse/core'; +import { onKeyDown, onKeyStroke } from '@vueuse/core'; -import { DECREMENT_LABEL_KEY, INCREMENT_LABEL_KEY } from '../config'; +import useHistoryStore from '@/src/store/history'; +import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { isDarwin } from '@/src/constants'; +import { DEFAULT_KEYMAP } 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'; +const Keymap = DEFAULT_KEYMAP; + +const isCtrlOrCmd = (ev: KeyboardEvent) => { + return isDarwin ? ev.metaKey : ev.ctrlKey; +}; + const applyLabelOffset = (offset: number) => { const toolToStore = { [Tools.Rectangle]: useRectangleStore(), @@ -28,7 +37,26 @@ const applyLabelOffset = (offset: number) => { activeToolStore.setActiveLabel(nextLabel); }; +const undo = () => { + const { currentImageID } = useCurrentImage(); + if (!currentImageID.value) return; + useHistoryStore().undo({ datasetID: currentImageID.value }); +}; + +const redo = () => { + const { currentImageID } = useCurrentImage(); + if (!currentImageID.value) return; + useHistoryStore().redo({ datasetID: currentImageID.value }); +}; + export function useKeyboardShortcuts() { - onKeyDown(DECREMENT_LABEL_KEY, () => applyLabelOffset(-1)); - onKeyDown(INCREMENT_LABEL_KEY, () => applyLabelOffset(1)); + onKeyDown(Keymap.DecrementLabel, () => applyLabelOffset(-1)); + onKeyDown(Keymap.IncrementLabel, () => applyLabelOffset(1)); + onKeyStroke(Keymap.UndoRedo, (ev) => { + if (isCtrlOrCmd(ev)) { + ev.preventDefault(); + if (ev.shiftKey) redo(); + else undo(); + } + }); } diff --git a/src/config.ts b/src/config.ts index 180b91a1b..218c5afae 100644 --- a/src/config.ts +++ b/src/config.ts @@ -198,8 +198,11 @@ export const POLYGON_LABEL_DEFAULTS = { white: { color: '#ffffff' }, }; -export const DECREMENT_LABEL_KEY = 'q'; -export const INCREMENT_LABEL_KEY = 'w'; +export const DEFAULT_KEYMAP = { + UndoRedo: 'z', + DecrementLabel: 'q', + IncrementLabel: 'w', +}; export const DEFAULT_PRESET_BY_MODALITY: Record = { CT: 'CT-AAA', diff --git a/src/constants.ts b/src/constants.ts index 7192ccd86..1ce94ea5a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -45,3 +45,5 @@ export const Messages = { 'Lost the WebGL context! Please reload the webpage. If the problem persists, you may need to restart your web browser.', }, } as const; + +export const isDarwin = /Mac|iPhone|iPad/.test(navigator.platform); From de35b68f972bfd9765553bdf7362d869d13b670c Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 16 Aug 2023 14:14:12 -0400 Subject: [PATCH 3/4] feat(tools): add basic undo/redo to tools Only supports undoing/redoing adding tools. --- src/components/tools/polygon/PolygonTool.vue | 32 +++++-- .../tools/rectangle/RectangleTool.vue | 32 +++++-- src/components/tools/ruler/RulerTool.vue | 32 +++++-- src/store/operations/tools.ts | 94 +++++++++++++++++++ 4 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 src/store/operations/tools.ts diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 9ada565ed..fc1ef372a 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -59,6 +59,10 @@ import { } from '@/src/utils/frameOfReference'; import { usePolygonStore } from '@/src/store/tools/polygons'; import { Polygon, PolygonID } from '@/src/types/polygon'; +import { createAddToolOperation } from '@/src/store/operations/tools'; +import useHistoryStore from '@/src/store/history'; +import { IHistoryOperation } from '@/src/types/history'; +import { Maybe } from '@/src/types'; import PolygonWidget2D from './PolygonWidget2D.vue'; type ToolID = PolygonID; @@ -100,6 +104,17 @@ export default defineComponent({ const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value)); const placingToolID = ref(null); + let addToolOperation: Maybe> = null; + + const addPlacingTool = () => { + const imageID = currentImageID.value; + if (!imageID) return; + addToolOperation = createAddToolOperation(activeToolStore, { + imageID, + placing: true, + }); + placingToolID.value = addToolOperation.apply(); + }; // --- active tool management --- // @@ -124,10 +139,7 @@ export default defineComponent({ placingToolID.value = null; } if (active && imageID) { - placingToolID.value = activeToolStore.addTool({ - imageID, - placing: true, - }); + addPlacingTool(); } }, { immediate: true } @@ -154,11 +166,13 @@ export default defineComponent({ }); const onToolPlaced = () => { - if (currentImageID.value) { - placingToolID.value = activeToolStore.addTool({ - imageID: currentImageID.value, - placing: true, - }); + const imageID = currentImageID.value; + if (imageID) { + useHistoryStore().pushOperation( + { datasetID: imageID }, + addToolOperation! + ); + addPlacingTool(); } }; diff --git a/src/components/tools/rectangle/RectangleTool.vue b/src/components/tools/rectangle/RectangleTool.vue index fe929b4bc..a4dcec8b5 100644 --- a/src/components/tools/rectangle/RectangleTool.vue +++ b/src/components/tools/rectangle/RectangleTool.vue @@ -59,6 +59,10 @@ import { } from '@/src/utils/frameOfReference'; import { useRectangleStore } from '@/src/store/tools/rectangles'; import { Rectangle, RectangleID } from '@/src/types/rectangle'; +import { createAddToolOperation } from '@/src/store/operations/tools'; +import useHistoryStore from '@/src/store/history'; +import { IHistoryOperation } from '@/src/types/history'; +import { Maybe } from '@/src/types'; import RectangleWidget2D from './RectangleWidget2D.vue'; type ToolID = RectangleID; @@ -100,6 +104,17 @@ export default defineComponent({ const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value)); const placingToolID = ref(null); + let addToolOperation: Maybe> = null; + + const addPlacingTool = () => { + const imageID = currentImageID.value; + if (!imageID) return; + addToolOperation = createAddToolOperation(activeToolStore, { + imageID, + placing: true, + }); + placingToolID.value = addToolOperation.apply(); + }; // --- active tool management --- // @@ -124,10 +139,7 @@ export default defineComponent({ placingToolID.value = null; } if (active && imageID) { - placingToolID.value = activeToolStore.addTool({ - imageID, - placing: true, - }); + addPlacingTool(); } }, { immediate: true } @@ -154,11 +166,13 @@ export default defineComponent({ }); const onToolPlaced = () => { - if (currentImageID.value) { - placingToolID.value = activeToolStore.addTool({ - imageID: currentImageID.value, - placing: true, - }); + const imageID = currentImageID.value; + if (imageID) { + useHistoryStore().pushOperation( + { datasetID: imageID }, + addToolOperation! + ); + addPlacingTool(); } }; diff --git a/src/components/tools/ruler/RulerTool.vue b/src/components/tools/ruler/RulerTool.vue index 48977c58e..8decbc4b3 100644 --- a/src/components/tools/ruler/RulerTool.vue +++ b/src/components/tools/ruler/RulerTool.vue @@ -60,6 +60,10 @@ import { } from '@/src/utils/frameOfReference'; import { Ruler } from '@/src/types/ruler'; import { vec3 } from 'gl-matrix'; +import { createAddToolOperation } from '@/src/store/operations/tools'; +import useHistoryStore from '@/src/store/history'; +import { IHistoryOperation } from '@/src/types/history'; +import { Maybe } from '@/src/types'; export default defineComponent({ name: 'RulerTool', @@ -95,6 +99,17 @@ export default defineComponent({ const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value)); const placingRulerID = ref(null); + let addToolOperation: Maybe> = null; + + const addPlacingTool = () => { + const imageID = currentImageID.value; + if (!imageID) return; + addToolOperation = createAddToolOperation(rulerStore, { + imageID, + placing: true, + }); + placingRulerID.value = addToolOperation.apply(); + }; // --- active ruler management --- // @@ -119,10 +134,7 @@ export default defineComponent({ placingRulerID.value = null; } if (active && imageID) { - placingRulerID.value = rulerStore.addRuler({ - imageID, - placing: true, - }); + addPlacingTool(); } }, { immediate: true } @@ -149,11 +161,13 @@ export default defineComponent({ }); const onRulerPlaced = () => { - if (currentImageID.value) { - placingRulerID.value = rulerStore.addRuler({ - imageID: currentImageID.value, - placing: true, - }); + const imageID = currentImageID.value; + if (imageID) { + useHistoryStore().pushOperation( + { datasetID: imageID }, + addToolOperation! + ); + addPlacingTool(); } }; diff --git a/src/store/operations/tools.ts b/src/store/operations/tools.ts new file mode 100644 index 000000000..84865cacf --- /dev/null +++ b/src/store/operations/tools.ts @@ -0,0 +1,94 @@ +import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; +import { Maybe } from '@/src/types'; +import { AnnotationTool } from '@/src/types/annotation-tool'; +import { IHistoryOperation } from '@/src/types/history'; +import { Store } from 'pinia'; + +export function createAddToolOperation< + ID extends string, + T extends AnnotationTool, + S extends AnnotationToolStore & Store +>(store: S, initToolState: Partial): IHistoryOperation { + let id: Maybe = null; + let toolState: Maybe = null; + + const isApplied = () => id != null; + + const apply = () => { + if (isApplied()) return id!; + id = store.addTool(toolState ?? initToolState); + return id; + }; + + const revert = () => { + if (!isApplied()) return; + toolState = store.toolByID[id!] as T; + store.removeTool(id!); + id = null; + }; + + return { + apply, + revert, + isApplied, + }; +} + +export function createRemoveToolOperation< + ID extends string, + S extends AnnotationToolStore & Store +>(store: S, id: ID): IHistoryOperation { + let tool: Maybe> = null; + + const isApplied = () => tool != null; + + const apply = () => { + if (isApplied()) return; + tool = store.toolByID[id]; + store.removeTool(id); + }; + + const revert = () => { + if (!isApplied()) return; + store.addTool(tool!); + tool = null; + }; + + return { + apply, + revert, + isApplied, + }; +} + +export function createUpdateToolOperation< + ID extends string, + T extends AnnotationTool, + S extends AnnotationToolStore & Store +>(store: S, id: ID, toolPatch: Partial): IHistoryOperation { + // cannot use structuredClone() b/c of vue proxies + const originalTool: AnnotationTool = JSON.parse( + JSON.stringify(store.toolByID[id]) + ); + let applied = false; + + const isApplied = () => applied; + + const apply = () => { + if (isApplied()) return; + store.updateTool(id, toolPatch); + applied = true; + }; + + const revert = () => { + if (!isApplied()) return; + store.updateTool(id, originalTool); + applied = false; + }; + + return { + apply, + revert, + isApplied, + }; +} From 3f48d1ca7bdeb9bf6a93ddf1eb639d3baad0810f Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 16 Aug 2023 14:20:55 -0400 Subject: [PATCH 4/4] feat(tools): undo/redo tool deletion --- src/components/MeasurementsToolList.vue | 10 ++++++++-- src/components/tools/polygon/PolygonTool.vue | 13 ++++++++++-- .../tools/rectangle/RectangleTool.vue | 13 ++++++++++-- src/components/tools/ruler/RulerTool.vue | 20 ++++++++++++------- src/store/operations/tools.ts | 7 ++++--- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/components/MeasurementsToolList.vue b/src/components/MeasurementsToolList.vue index 83781ad34..71a2ee93b 100644 --- a/src/components/MeasurementsToolList.vue +++ b/src/components/MeasurementsToolList.vue @@ -4,9 +4,12 @@ import { computed } from 'vue'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool'; import { frameOfReferenceToImageSliceAndAxis } from '@/src/utils/frameOfReference'; +import useHistoryStore from '@/src/store/history'; +import { createRemoveToolOperation } from '@/src/store/operations/tools'; +import { Store } from 'pinia'; const props = defineProps<{ - toolStore: AnnotationToolStore; + toolStore: AnnotationToolStore & Store; icon: string; }>(); @@ -33,7 +36,10 @@ const tools = computed(() => { }); const remove = (id: ToolID) => { - props.toolStore.removeTool(id); + const imageID = currentImageID.value; + if (!imageID) return; + const op = createRemoveToolOperation(props.toolStore, id); + useHistoryStore().pushOperation({ datasetID: imageID }, op, true); }; const jumpTo = (id: ToolID) => { diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index fc1ef372a..c1a388119 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -59,7 +59,10 @@ import { } from '@/src/utils/frameOfReference'; import { usePolygonStore } from '@/src/store/tools/polygons'; import { Polygon, PolygonID } from '@/src/types/polygon'; -import { createAddToolOperation } from '@/src/store/operations/tools'; +import { + createAddToolOperation, + createRemoveToolOperation, +} from '@/src/store/operations/tools'; import useHistoryStore from '@/src/store/history'; import { IHistoryOperation } from '@/src/types/history'; import { Maybe } from '@/src/types'; @@ -221,7 +224,13 @@ export default defineComponent({ }; const deleteToolFromContextMenu = () => { - activeToolStore.removeTool(contextMenu.forToolID); + const imageID = currentImageID.value; + if (!imageID) return; + const op = createRemoveToolOperation( + activeToolStore, + contextMenu.forToolID + ); + useHistoryStore().pushOperation({ datasetID: imageID }, op, true); }; // --- tool data --- // diff --git a/src/components/tools/rectangle/RectangleTool.vue b/src/components/tools/rectangle/RectangleTool.vue index a4dcec8b5..b0a86cd23 100644 --- a/src/components/tools/rectangle/RectangleTool.vue +++ b/src/components/tools/rectangle/RectangleTool.vue @@ -59,7 +59,10 @@ import { } from '@/src/utils/frameOfReference'; import { useRectangleStore } from '@/src/store/tools/rectangles'; import { Rectangle, RectangleID } from '@/src/types/rectangle'; -import { createAddToolOperation } from '@/src/store/operations/tools'; +import { + createAddToolOperation, + createRemoveToolOperation, +} from '@/src/store/operations/tools'; import useHistoryStore from '@/src/store/history'; import { IHistoryOperation } from '@/src/types/history'; import { Maybe } from '@/src/types'; @@ -222,7 +225,13 @@ export default defineComponent({ }; const deleteToolFromContextMenu = () => { - activeToolStore.removeTool(contextMenu.forToolID); + const imageID = currentImageID.value; + if (!imageID) return; + const op = createRemoveToolOperation( + activeToolStore, + contextMenu.forToolID + ); + useHistoryStore().pushOperation({ datasetID: imageID }, op, true); }; // --- tool data --- // diff --git a/src/components/tools/ruler/RulerTool.vue b/src/components/tools/ruler/RulerTool.vue index 8decbc4b3..24fc18fa7 100644 --- a/src/components/tools/ruler/RulerTool.vue +++ b/src/components/tools/ruler/RulerTool.vue @@ -25,7 +25,7 @@ close-on-content-click > - + Delete @@ -60,7 +60,10 @@ import { } from '@/src/utils/frameOfReference'; import { Ruler } from '@/src/types/ruler'; import { vec3 } from 'gl-matrix'; -import { createAddToolOperation } from '@/src/store/operations/tools'; +import { + createAddToolOperation, + createRemoveToolOperation, +} from '@/src/store/operations/tools'; import useHistoryStore from '@/src/store/history'; import { IHistoryOperation } from '@/src/types/history'; import { Maybe } from '@/src/types'; @@ -210,17 +213,20 @@ export default defineComponent({ show: false, x: 0, y: 0, - forRulerID: '', + forToolID: '', }); const openContextMenu = (rulerID: string, displayXY: Vector2) => { [contextMenu.x, contextMenu.y] = displayXY; contextMenu.show = true; - contextMenu.forRulerID = rulerID; + contextMenu.forToolID = rulerID; }; - const deleteRulerFromContextMenu = () => { - rulerStore.removeRuler(contextMenu.forRulerID); + const deleteToolFromContextMenu = () => { + const imageID = currentImageID.value; + if (!imageID) return; + const op = createRemoveToolOperation(rulerStore, contextMenu.forToolID); + useHistoryStore().pushOperation({ datasetID: imageID }, op, true); }; // --- ruler data --- // @@ -266,7 +272,7 @@ export default defineComponent({ placingRulerID, contextMenu, openContextMenu, - deleteRulerFromContextMenu, + deleteToolFromContextMenu, onRulerPlaced, }; }, diff --git a/src/store/operations/tools.ts b/src/store/operations/tools.ts index 84865cacf..00e98079b 100644 --- a/src/store/operations/tools.ts +++ b/src/store/operations/tools.ts @@ -38,19 +38,20 @@ export function createRemoveToolOperation< ID extends string, S extends AnnotationToolStore & Store >(store: S, id: ID): IHistoryOperation { + let lastID: ID = id; let tool: Maybe> = null; const isApplied = () => tool != null; const apply = () => { if (isApplied()) return; - tool = store.toolByID[id]; - store.removeTool(id); + tool = store.toolByID[lastID]; + store.removeTool(lastID); }; const revert = () => { if (!isApplied()) return; - store.addTool(tool!); + lastID = store.addTool(tool!); tool = null; };