Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions packages/core/src/utilities/historyMemo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,47 @@ export class HistoryMemo {
this.ring[this.position] = memo;
return memo;
}

/**
* Replaces a memo in the current undo item while preserving its position in
* history. This is useful when an operation starts with a provisional memo
* and later replaces the affected state as part of committing that operation.
*
* @param item - The replacement memo.
* @param condition - Identifies the provisional memo to replace.
* @returns True when a matching current memo was replaced.
*/
public replaceCurrentMemo(
item: Memo | Memoable,
condition: (memo: Memo) => boolean
): boolean {
if (!item || this.undoAvailable === 0) {
return false;
}

const currentItem = this.ring[this.position];
const currentMemos = asArray(currentItem);
const memoIndex = currentMemos.findIndex(condition);

if (memoIndex === -1) {
return false;
}

const memo = (item as Memo).restoreMemo
? (item as Memo)
: (item as Memoable).createMemo?.();
if (!memo) {
return false;
}

if (Array.isArray(currentItem)) {
currentItem[memoIndex] = memo;
} else {
this.ring[this.position] = memo;
}

return true;
}
}

/**
Expand Down
52 changes: 52 additions & 0 deletions packages/core/test/utilities/historyMemo.jest.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,56 @@ describe('HistoryMemo', function () {
defaultHistoryMemo.undo();
expect(defaultHistoryMemo.canRedo).toBe(true);
});

it('replaces the current memo without adding another undo step', () => {
const provisionalMemo = createMemo(0);
provisionalMemo.id = 'provisional';
historyMemo.push(provisionalMemo);
state.testState = 1;

const replacementMemo = createMemo(10);
replacementMemo.id = 'replacement';

expect(
historyMemo.replaceCurrentMemo(
replacementMemo,
(memo) => memo.id === 'provisional'
)
).toBe(true);

historyMemo.undo();
expect(state.testState).toBe(10);
expect(historyMemo.canUndo).toBe(false);

historyMemo.redo();
expect(state.testState).toBe(1);
});

it('replaces a matching memo in the current group', () => {
const firstMemo = createMemo(0);
firstMemo.id = 'first';
const provisionalMemo = createMemo(0);
provisionalMemo.id = 'provisional';

historyMemo.startGroupRecording();
historyMemo.push(firstMemo);
historyMemo.push(provisionalMemo);
historyMemo.endGroupRecording();

const replacementMemo = createMemo(10);
replacementMemo.id = 'replacement';

expect(
historyMemo.replaceCurrentMemo(
replacementMemo,
(memo) => memo.id === 'provisional'
)
).toBe(true);
expect(
historyMemo.replaceCurrentMemo(
createMemo(20),
(memo) => memo.id === 'missing'
)
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { Types } from '@cornerstonejs/core';
import { RenderingEngine, Enums, volumeLoader } from '@cornerstonejs/core';
import {
RenderingEngine,
Enums,
volumeLoader,
utilities as csUtils,
} from '@cornerstonejs/core';
import {
initDemo,
createImageIdsAndCacheMetaData,
Expand All @@ -20,6 +25,7 @@ console.warn(
let renderingEngine;

const { KeyboardBindings } = cornerstoneTools.Enums;
const { DefaultHistoryMemo } = csUtils.HistoryMemo;

const {
PlanarFreehandContourSegmentationTool,
Expand Down Expand Up @@ -49,7 +55,7 @@ let activeSegmentIndex = 0;
// ======== Set up page ======== //
setTitleAndDescription(
'Planar Freehand Contour Segmentation Tool',
'Demonstrates how to create contour segmentations using planar freehand ROI tool'
'Demonstrates contour segmentation drawing, editing, undo, and redo using the planar freehand ROI tool'
);

const size = '512px';
Expand Down Expand Up @@ -80,6 +86,9 @@ content.appendChild(viewportGrid);
createInfoSection(content)
.addInstruction('Select a segment index')
.addInstruction('Left click and drag to draw a contour')
.addInstruction(
'Use the Undo/Redo buttons, Ctrl/Cmd+Z to undo, or Ctrl/Cmd+Shift+Z (and Ctrl+Y) to redo'
)
.openNestedSection()
.addInstruction(
'Segmentation contours are closed automatically if the mouse button is released before joining the start and end points'
Expand Down Expand Up @@ -177,6 +186,39 @@ addDropdownToToolbar({
},
});

addButtonToToolbar({
id: 'Undo',
title: 'Undo',
onClick: () => {
DefaultHistoryMemo.undo();
},
});

addButtonToToolbar({
id: 'Redo',
title: 'Redo',
onClick: () => {
DefaultHistoryMemo.redo();
},
});

document.addEventListener('keydown', (evt) => {
const isModifier = evt.ctrlKey || evt.metaKey;
const key = evt.key.toLowerCase();

if (!isModifier || (key !== 'z' && key !== 'y')) {
return;
}

evt.preventDefault();

if (key === 'y' || evt.shiftKey) {
DefaultHistoryMemo.redo();
} else {
DefaultHistoryMemo.undo();
}
});

addToggleButtonToToolbar({
title: 'Show/Hide All Segments',
onClick: function (toggle) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Spatial overlap is not pre-filtered; Clipper decides the geometry.
*/

import type { Types } from '@cornerstonejs/core';
import { utilities as csUtils, type Types } from '@cornerstonejs/core';
import type { ContourSegmentationAnnotation } from '../../types';
import { ContourWindingDirection } from '../../types/ContourAnnotation';
import {
Expand All @@ -26,7 +26,10 @@ import {
getContourHolesData,
updateViewportsForAnnotations,
} from './sharedOperations';
import { getAllAnnotations } from '../../stateManagement/annotation/annotationState';
import {
getAllAnnotations,
getAnnotation,
} from '../../stateManagement/annotation/annotationState';
import {
addAnnotation,
addChildAnnotation,
Expand All @@ -40,6 +43,7 @@ import isContourSegmentationAnnotation from './isContourSegmentationAnnotation';
import { hasToolByName } from '../../store/addTool';

const DEFAULT_CONTOUR_SEG_TOOL_NAME = 'PlanarFreehandContourSegmentationTool';
const { DefaultHistoryMemo } = csUtils.HistoryMemo;

/** Add the stroke to the segment (§3.2). Always additive, never removes area. */
export function addContourStroke(
Expand Down Expand Up @@ -116,20 +120,22 @@ function applyStroke(
: booleanResult;

// Collect every annotation we're about to discard.
const toRemove: ContourSegmentationAnnotation[] = [sourceAnnotation];
const previousAnnotations: ContourSegmentationAnnotation[] = [];
for (const t of targets) {
toRemove.push(t);
previousAnnotations.push(t);
for (const h of getContourHolesData(viewport, t)) {
toRemove.push(h.annotation);
previousAnnotations.push(h.annotation);
}
}
const toRemove = [sourceAnnotation, ...previousAnnotations];
for (const annotation of toRemove) {
removeAnnotationCompletely(annotation);
}

// Rebuild from clipper output.
const template = targets[0] ?? sourceAnnotation;
const { element } = viewport;
const resultAnnotations: ContourSegmentationAnnotation[] = [];

for (const polygon of resultPolygons) {
if (polygon.outer.length < 3) {
Expand All @@ -143,6 +149,7 @@ function applyStroke(
);
addAnnotation(parent, element);
addContourSegmentationAnnotation(parent);
resultAnnotations.push(parent);

polygon.holes?.forEach((holePolyline) => {
if (holePolyline.length < 3) {
Expand All @@ -156,6 +163,7 @@ function applyStroke(
);
addAnnotation(hole, element);
addChildAnnotation(parent, hole);
resultAnnotations.push(hole);
triggerAnnotationModified(hole, element);
});

Expand All @@ -164,9 +172,74 @@ function applyStroke(
triggerAnnotationModified(parent, element);
}

replaceContourStrokeHistory(
sourceAnnotation,
previousAnnotations,
resultAnnotations,
viewport
);
updateViewportsForAnnotations(viewport, toRemove);
}

function replaceContourStrokeHistory(
sourceAnnotation: ContourSegmentationAnnotation,
previousAnnotations: ContourSegmentationAnnotation[],
resultAnnotations: ContourSegmentationAnnotation[],
viewport: Types.IViewport
): void {
const memo: Types.Memo = {
id: sourceAnnotation.annotationUID,
operationType: 'annotation',
restoreMemo: (isUndo = true) => {
const annotationsToRemove = isUndo
? resultAnnotations
: previousAnnotations;
const annotationsToAdd = isUndo ? previousAnnotations : resultAnnotations;

removeStoredAnnotations(annotationsToRemove);
addStoredAnnotations(annotationsToAdd, viewport.element);
updateViewportsForAnnotations(viewport, [
...annotationsToRemove,
...annotationsToAdd,
]);
},
};

DefaultHistoryMemo.replaceCurrentMemo(
memo,
(currentMemo) =>
currentMemo.id === sourceAnnotation.annotationUID &&
currentMemo.operationType === 'annotation'
);
}

function removeStoredAnnotations(
annotations: ContourSegmentationAnnotation[]
): void {
for (let i = annotations.length - 1; i >= 0; i--) {
const annotation = annotations[i];

if (getAnnotation(annotation.annotationUID)) {
removeAnnotation(annotation.annotationUID);
}
removeContourSegmentationAnnotation(annotation);
}
}

function addStoredAnnotations(
annotations: ContourSegmentationAnnotation[],
element: HTMLDivElement
): void {
for (const annotation of annotations) {
if (getAnnotation(annotation.annotationUID)) {
continue;
}

addAnnotation(annotation, element);
addContourSegmentationAnnotation(annotation);
}
}

function collectSamePlaneSegmentTargets(
viewport: Types.IViewport,
sourceAnnotation: ContourSegmentationAnnotation
Expand Down
4 changes: 2 additions & 2 deletions tests/vitest-browser/harness/createPlanarViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type FakeStackOptions,
} from './fakeImageStack';

const { Events, OrientationAxis, RenderBackend, ViewportType } = Enums;
const { Events, OrientationAxis, RenderBackends, ViewportType } = Enums;

export type PlanarRenderMode =
| 'vtkImage'
Expand Down Expand Up @@ -202,7 +202,7 @@ export async function createPlanarViewport(
displaySetId,
options: {
orientation,
renderBackend: isCpu ? RenderBackend.CPU : RenderBackend.GPU,
renderBackend: isCpu ? RenderBackends.CPU : RenderBackends.GPU,
},
});
} catch (error) {
Expand Down
32 changes: 31 additions & 1 deletion tests/vitest-browser/harness/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ export async function setupTools(
const viewportCtx = await createPlanarViewport(opts.viewport);

const toolGroupId =
opts.toolGroupId ?? nextToolGroupId(`vitest-tools:${viewportCtx.viewportId}`);
opts.toolGroupId ??
nextToolGroupId(`vitest-tools:${viewportCtx.viewportId}`);
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);

if (!toolGroup) {
Expand Down Expand Up @@ -305,6 +306,35 @@ export function mouseDrag(
dispatchMouseUpOnDocument(element, to);
}

/**
* Freehand drag gesture: mousedown at the first canvas point, one mousemove
* per remaining point, then mouseup at the final point. This mirrors
* {@link mouseDrag} but preserves a caller-supplied path for tools that record
* every drag point, such as planar freehand contours.
*/
export function mouseDragPath(
element: HTMLElement,
canvasPoints: [number, number][],
opts: { button?: number } = {}
): void {
if (canvasPoints.length < 2) {
throw new Error('mouseDragPath requires at least two canvas points');
}

const buttons = opts.button ?? MouseBindings.Primary;
const points = canvasPoints.map(roundCanvasPoint);
const first = points[0];
const last = points[points.length - 1];

dispatchMouseDownOnElement(element, first, buttons);

for (let i = 1; i < points.length; i++) {
dispatchMouseMoveOnDocument(element, points[i], buttons);
}

dispatchMouseUpOnDocument(element, last);
}

/**
* Hover-only move: a single `mousemove` dispatched directly on `element`
* with no button held. This is the listener that stays bound to the element
Expand Down
Loading
Loading