From 55dcf6617f08f18f81414bd7e5b35f2a561d2c97 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 14 Aug 2026 11:38:27 +0200 Subject: [PATCH] refactor: isolate canvas history model --- js/canvas/CanvasHistory.js | 68 +++++++ js/canvas/CanvasState.js | 142 ++++++-------- src/canvas/CanvasHistory.ts | 96 ++++++++++ src/canvas/CanvasState.ts | 175 +++++++----------- tests/test_canvas_history.mjs | 59 ++++++ ...est_canvas_operations_characterization.mjs | 6 +- 6 files changed, 345 insertions(+), 201 deletions(-) create mode 100644 js/canvas/CanvasHistory.js create mode 100644 src/canvas/CanvasHistory.ts create mode 100644 tests/test_canvas_history.mjs diff --git a/js/canvas/CanvasHistory.js b/js/canvas/CanvasHistory.js new file mode 100644 index 0000000..d922077 --- /dev/null +++ b/js/canvas/CanvasHistory.js @@ -0,0 +1,68 @@ +/** + * UI-agnostic undo/redo storage for immutable snapshots. + * + * The stack owns its snapshots through the supplied clone function. This + * keeps history independent from the mutable objects used by canvas and mask + * adapters while leaving rendering and persistence to their callers. + */ +export class HistoryStack { + constructor(options) { + this.undoEntries = []; + this.redoEntries = []; + this.cloneSnapshot = options.clone; + this.snapshotsEqual = options.equals; + this.historyLimit = Math.max(1, Math.floor(options.historyLimit ?? 100)); + } + get undoStack() { + return this.undoEntries; + } + get redoStack() { + return this.redoEntries; + } + push(value, replaceLast = false) { + if (replaceLast && this.undoEntries.length > 0) { + this.undoEntries.pop(); + } + const snapshot = this.cloneSnapshot(value); + const previous = this.undoEntries[this.undoEntries.length - 1]; + if (previous !== undefined && this.snapshotsEqual?.(previous, snapshot)) { + return false; + } + this.undoEntries.push(snapshot); + if (this.undoEntries.length > this.historyLimit) { + this.undoEntries.shift(); + } + this.redoEntries.length = 0; + return true; + } + undo() { + if (this.undoEntries.length <= 1) + return null; + const current = this.undoEntries.pop(); + if (current !== undefined) { + this.redoEntries.push(current); + } + const previous = this.undoEntries[this.undoEntries.length - 1]; + return previous === undefined ? null : this.cloneSnapshot(previous); + } + redo() { + const next = this.redoEntries.pop(); + if (next === undefined) + return null; + this.undoEntries.push(next); + return this.cloneSnapshot(next); + } + clear() { + this.undoEntries.length = 0; + this.redoEntries.length = 0; + } + getHistoryInfo() { + return { + undoCount: this.undoEntries.length, + redoCount: this.redoEntries.length, + canUndo: this.undoEntries.length > 1, + canRedo: this.redoEntries.length > 0, + historyLimit: this.historyLimit, + }; + } +} diff --git a/js/canvas/CanvasState.js b/js/canvas/CanvasState.js index 6de9be2..5207341 100644 --- a/js/canvas/CanvasState.js +++ b/js/canvas/CanvasState.js @@ -4,15 +4,33 @@ import { showAlertNotification } from "../utils/NotificationUtils.js"; import { generateUUID, cloneLayers, getStateSignature, debounce, createCanvas, cloneCanvas } from "../utils/CommonUtils.js"; import { loadImage } from "../media/ImageUtils.js"; import { getCanvasStateKey } from "../utils/CanvasStateKey.js"; +import { HistoryStack } from "./CanvasHistory.js"; const log = createModuleLogger('CanvasState'); export class CanvasState { + get layersUndoStack() { + return this.layerHistory.undoStack; + } + get layersRedoStack() { + return this.layerHistory.redoStack; + } + get maskUndoStack() { + return this.maskHistory.undoStack; + } + get maskRedoStack() { + return this.maskHistory.redoStack; + } constructor(canvas) { this.canvas = canvas; - this.layersUndoStack = []; - this.layersRedoStack = []; - this.maskUndoStack = []; - this.maskRedoStack = []; this.historyLimit = 100; + this.layerHistory = new HistoryStack({ + clone: cloneLayers, + equals: (left, right) => getStateSignature(left) === getStateSignature(right), + historyLimit: this.historyLimit, + }); + this.maskHistory = new HistoryStack({ + clone: cloneCanvas, + historyLimit: this.historyLimit, + }); this.saveTimeout = null; this.lastSavedStateSignature = null; this._loadInProgress = null; @@ -311,22 +329,8 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor } } saveLayersState(replaceLast = false) { - if (replaceLast && this.layersUndoStack.length > 0) { - this.layersUndoStack.pop(); - } - const currentState = cloneLayers(this.canvas.layers); - const currentStateSignature = getStateSignature(currentState); - if (this.layersUndoStack.length > 0) { - const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; - if (getStateSignature(lastState) === currentStateSignature) { - return; - } - } - this.layersUndoStack.push(currentState); - if (this.layersUndoStack.length > this.historyLimit) { - this.layersUndoStack.shift(); - } - this.layersRedoStack = []; + if (!this.layerHistory.push(this.canvas.layers, replaceLast)) + return; this.canvas.updateHistoryButtons(); if (!this._debouncedSave) { this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000); @@ -336,16 +340,8 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor saveMaskState(replaceLast = false) { if (!this.canvas.maskTool) return; - if (replaceLast && this.maskUndoStack.length > 0) { - this.maskUndoStack.pop(); - } const maskCanvas = this.canvas.maskTool.getMask(); - const clonedCanvas = cloneCanvas(maskCanvas); - this.maskUndoStack.push(clonedCanvas); - if (this.maskUndoStack.length > this.historyLimit) { - this.maskUndoStack.shift(); - } - this.maskRedoStack = []; + this.maskHistory.push(maskCanvas, replaceLast); this.canvas.updateHistoryButtons(); } undo() { @@ -365,59 +361,47 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor } } undoLayersState() { - if (this.layersUndoStack.length <= 1) + const prevState = this.layerHistory.undo(); + if (!prevState) return; - const currentState = this.layersUndoStack.pop(); - if (currentState) { - this.layersRedoStack.push(currentState); - } - const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; - this.canvas.layers = cloneLayers(prevState); + this.canvas.layers = prevState; this.canvas.updateSelectionAfterHistory(); this.canvas.render(); this.canvas.updateHistoryButtons(); } redoLayersState() { - if (this.layersRedoStack.length === 0) + const nextState = this.layerHistory.redo(); + if (!nextState) return; - const nextState = this.layersRedoStack.pop(); - if (nextState) { - this.layersUndoStack.push(nextState); - this.canvas.layers = cloneLayers(nextState); - this.canvas.updateSelectionAfterHistory(); - this.canvas.render(); - this.canvas.updateHistoryButtons(); - } + this.canvas.layers = nextState; + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + this.canvas.updateHistoryButtons(); } undoMaskState() { - if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) + if (!this.canvas.maskTool) return; - const currentState = this.maskUndoStack.pop(); - if (currentState) { - this.maskRedoStack.push(currentState); - } - if (this.maskUndoStack.length > 0) { - const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; - // Use the new restoreMaskFromSavedState method that properly clears chunks first - this.canvas.maskTool.restoreMaskFromSavedState(prevState); - // Clear stroke overlay to prevent old drawing previews from persisting - this.canvas.canvasRenderer.clearMaskStrokeOverlay(); - this.canvas.render(); - } + const prevState = this.maskHistory.undo(); + if (!prevState) + return; + // Use the new restoreMaskFromSavedState method that properly clears chunks first + this.canvas.maskTool.restoreMaskFromSavedState(prevState); + // Clear stroke overlay to prevent old drawing previews from persisting + this.canvas.canvasRenderer.clearMaskStrokeOverlay(); + this.canvas.render(); this.canvas.updateHistoryButtons(); } redoMaskState() { - if (!this.canvas.maskTool || this.maskRedoStack.length === 0) + if (!this.canvas.maskTool) return; - const nextState = this.maskRedoStack.pop(); - if (nextState) { - this.maskUndoStack.push(nextState); - // Use the new restoreMaskFromSavedState method that properly clears chunks first - this.canvas.maskTool.restoreMaskFromSavedState(nextState); - // Clear stroke overlay to prevent old drawing previews from persisting - this.canvas.canvasRenderer.clearMaskStrokeOverlay(); - this.canvas.render(); - } + const nextState = this.maskHistory.redo(); + if (!nextState) + return; + // Use the new restoreMaskFromSavedState method that properly clears chunks first + this.canvas.maskTool.restoreMaskFromSavedState(nextState); + // Clear stroke overlay to prevent old drawing previews from persisting + this.canvas.canvasRenderer.clearMaskStrokeOverlay(); + this.canvas.render(); this.canvas.updateHistoryButtons(); } /** @@ -425,12 +409,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor */ clearHistory() { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { - this.maskUndoStack = []; - this.maskRedoStack = []; + this.maskHistory.clear(); } else { - this.layersUndoStack = []; - this.layersRedoStack = []; + this.layerHistory.clear(); } this.canvas.updateHistoryButtons(); log.info("History cleared"); @@ -441,22 +423,10 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor */ getHistoryInfo() { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { - return { - undoCount: this.maskUndoStack.length, - redoCount: this.maskRedoStack.length, - canUndo: this.maskUndoStack.length > 1, - canRedo: this.maskRedoStack.length > 0, - historyLimit: this.historyLimit - }; + return this.maskHistory.getHistoryInfo(); } else { - return { - undoCount: this.layersUndoStack.length, - redoCount: this.layersRedoStack.length, - canUndo: this.layersUndoStack.length > 1, - canRedo: this.layersRedoStack.length > 0, - historyLimit: this.historyLimit - }; + return this.layerHistory.getHistoryInfo(); } } } diff --git a/src/canvas/CanvasHistory.ts b/src/canvas/CanvasHistory.ts new file mode 100644 index 0000000..3ba81a4 --- /dev/null +++ b/src/canvas/CanvasHistory.ts @@ -0,0 +1,96 @@ +export interface HistoryInfo { + undoCount: number; + redoCount: number; + canUndo: boolean; + canRedo: boolean; + historyLimit: number; +} + +export interface HistoryStackOptions { + clone: (value: T) => T; + equals?: (left: T, right: T) => boolean; + historyLimit?: number; +} + +/** + * UI-agnostic undo/redo storage for immutable snapshots. + * + * The stack owns its snapshots through the supplied clone function. This + * keeps history independent from the mutable objects used by canvas and mask + * adapters while leaving rendering and persistence to their callers. + */ +export class HistoryStack { + private readonly cloneSnapshot: (value: T) => T; + private readonly snapshotsEqual: ((left: T, right: T) => boolean) | undefined; + private readonly historyLimit: number; + private readonly undoEntries: T[] = []; + private readonly redoEntries: T[] = []; + + constructor(options: HistoryStackOptions) { + this.cloneSnapshot = options.clone; + this.snapshotsEqual = options.equals; + this.historyLimit = Math.max(1, Math.floor(options.historyLimit ?? 100)); + } + + get undoStack(): T[] { + return this.undoEntries; + } + + get redoStack(): T[] { + return this.redoEntries; + } + + push(value: T, replaceLast = false): boolean { + if (replaceLast && this.undoEntries.length > 0) { + this.undoEntries.pop(); + } + + const snapshot = this.cloneSnapshot(value); + const previous = this.undoEntries[this.undoEntries.length - 1]; + if (previous !== undefined && this.snapshotsEqual?.(previous, snapshot)) { + return false; + } + + this.undoEntries.push(snapshot); + if (this.undoEntries.length > this.historyLimit) { + this.undoEntries.shift(); + } + this.redoEntries.length = 0; + return true; + } + + undo(): T | null { + if (this.undoEntries.length <= 1) return null; + + const current = this.undoEntries.pop(); + if (current !== undefined) { + this.redoEntries.push(current); + } + + const previous = this.undoEntries[this.undoEntries.length - 1]; + return previous === undefined ? null : this.cloneSnapshot(previous); + } + + redo(): T | null { + const next = this.redoEntries.pop(); + if (next === undefined) return null; + + this.undoEntries.push(next); + return this.cloneSnapshot(next); + } + + clear(): void { + this.undoEntries.length = 0; + this.redoEntries.length = 0; + } + + getHistoryInfo(): HistoryInfo { + return { + undoCount: this.undoEntries.length, + redoCount: this.redoEntries.length, + canUndo: this.undoEntries.length > 1, + canRedo: this.redoEntries.length > 0, + historyLimit: this.historyLimit, + }; + } +} diff --git a/src/canvas/CanvasState.ts b/src/canvas/CanvasState.ts index 7a5527b..e01deb2 100644 --- a/src/canvas/CanvasState.ts +++ b/src/canvas/CanvasState.ts @@ -5,39 +5,51 @@ import {generateUUID, cloneLayers, getStateSignature, debounce, createCanvas, cl import {loadImage} from "../media/ImageUtils.js"; import {getCanvasStateKey} from "../utils/CanvasStateKey.js"; import {withErrorHandling} from "../shared/ErrorHandler.js"; +import {HistoryStack, type HistoryInfo} from "./CanvasHistory.js"; import type { Canvas } from './Canvas'; import type { Layer, ComfyNode } from '../shared/types'; const log = createModuleLogger('CanvasState'); -interface HistoryInfo { - undoCount: number; - redoCount: number; - canUndo: boolean; - canRedo: boolean; - historyLimit: number; -} - export class CanvasState { private _debouncedSave: (() => void) | null; private _loadInProgress: Promise | null; private canvas: Canvas & { node: ComfyNode, layers: Layer[] }; private historyLimit: number; + private layerHistory: HistoryStack; private lastSavedStateSignature: string | null; - public layersRedoStack: Layer[][]; - public layersUndoStack: Layer[][]; - public maskRedoStack: HTMLCanvasElement[]; - public maskUndoStack: HTMLCanvasElement[]; + private maskHistory: HistoryStack; private saveTimeout: number | null; private stateSaverWorker: Worker | null; + public get layersUndoStack(): Layer[][] { + return this.layerHistory.undoStack; + } + + public get layersRedoStack(): Layer[][] { + return this.layerHistory.redoStack; + } + + public get maskUndoStack(): HTMLCanvasElement[] { + return this.maskHistory.undoStack; + } + + public get maskRedoStack(): HTMLCanvasElement[] { + return this.maskHistory.redoStack; + } + constructor(canvas: Canvas & { node: ComfyNode, layers: Layer[] }) { this.canvas = canvas; - this.layersUndoStack = []; - this.layersRedoStack = []; - this.maskUndoStack = []; - this.maskRedoStack = []; this.historyLimit = 100; + this.layerHistory = new HistoryStack({ + clone: cloneLayers, + equals: (left, right) => getStateSignature(left) === getStateSignature(right), + historyLimit: this.historyLimit, + }); + this.maskHistory = new HistoryStack({ + clone: cloneCanvas, + historyLimit: this.historyLimit, + }); this.saveTimeout = null; this.lastSavedStateSignature = null; this._loadInProgress = null; @@ -351,26 +363,7 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor } saveLayersState(replaceLast = false): void { - if (replaceLast && this.layersUndoStack.length > 0) { - this.layersUndoStack.pop(); - } - - const currentState = cloneLayers(this.canvas.layers); - const currentStateSignature = getStateSignature(currentState); - - if (this.layersUndoStack.length > 0) { - const lastState = this.layersUndoStack[this.layersUndoStack.length - 1]; - if (getStateSignature(lastState) === currentStateSignature) { - return; - } - } - - this.layersUndoStack.push(currentState); - - if (this.layersUndoStack.length > this.historyLimit) { - this.layersUndoStack.shift(); - } - this.layersRedoStack = []; + if (!this.layerHistory.push(this.canvas.layers, replaceLast)) return; this.canvas.updateHistoryButtons(); if (!this._debouncedSave) { @@ -381,19 +374,8 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor saveMaskState(replaceLast = false): void { if (!this.canvas.maskTool) return; - - if (replaceLast && this.maskUndoStack.length > 0) { - this.maskUndoStack.pop(); - } const maskCanvas = this.canvas.maskTool.getMask(); - const clonedCanvas = cloneCanvas(maskCanvas); - - this.maskUndoStack.push(clonedCanvas); - - if (this.maskUndoStack.length > this.historyLimit) { - this.maskUndoStack.shift(); - } - this.maskRedoStack = []; + this.maskHistory.push(maskCanvas, replaceLast); this.canvas.updateHistoryButtons(); } @@ -414,70 +396,51 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor } undoLayersState(): void { - if (this.layersUndoStack.length <= 1) return; - - const currentState = this.layersUndoStack.pop(); - if (currentState) { - this.layersRedoStack.push(currentState); - } - const prevState = this.layersUndoStack[this.layersUndoStack.length - 1]; - this.canvas.layers = cloneLayers(prevState); + const prevState = this.layerHistory.undo(); + if (!prevState) return; + this.canvas.layers = prevState; this.canvas.updateSelectionAfterHistory(); this.canvas.render(); this.canvas.updateHistoryButtons(); } redoLayersState(): void { - if (this.layersRedoStack.length === 0) return; - - const nextState = this.layersRedoStack.pop(); - if (nextState) { - this.layersUndoStack.push(nextState); - this.canvas.layers = cloneLayers(nextState); - this.canvas.updateSelectionAfterHistory(); - this.canvas.render(); - this.canvas.updateHistoryButtons(); - } + const nextState = this.layerHistory.redo(); + if (!nextState) return; + this.canvas.layers = nextState; + this.canvas.updateSelectionAfterHistory(); + this.canvas.render(); + this.canvas.updateHistoryButtons(); } undoMaskState(): void { - if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return; + if (!this.canvas.maskTool) return; + const prevState = this.maskHistory.undo(); + if (!prevState) return; - const currentState = this.maskUndoStack.pop(); - if (currentState) { - this.maskRedoStack.push(currentState); - } + // Use the new restoreMaskFromSavedState method that properly clears chunks first + this.canvas.maskTool.restoreMaskFromSavedState(prevState); - if (this.maskUndoStack.length > 0) { - const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; - - // Use the new restoreMaskFromSavedState method that properly clears chunks first - this.canvas.maskTool.restoreMaskFromSavedState(prevState); - - // Clear stroke overlay to prevent old drawing previews from persisting - this.canvas.canvasRenderer.clearMaskStrokeOverlay(); - - this.canvas.render(); - } + // Clear stroke overlay to prevent old drawing previews from persisting + this.canvas.canvasRenderer.clearMaskStrokeOverlay(); + + this.canvas.render(); this.canvas.updateHistoryButtons(); } redoMaskState(): void { - if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return; + if (!this.canvas.maskTool) return; + const nextState = this.maskHistory.redo(); + if (!nextState) return; - const nextState = this.maskRedoStack.pop(); - if (nextState) { - this.maskUndoStack.push(nextState); - - // Use the new restoreMaskFromSavedState method that properly clears chunks first - this.canvas.maskTool.restoreMaskFromSavedState(nextState); - - // Clear stroke overlay to prevent old drawing previews from persisting - this.canvas.canvasRenderer.clearMaskStrokeOverlay(); - - this.canvas.render(); - } + // Use the new restoreMaskFromSavedState method that properly clears chunks first + this.canvas.maskTool.restoreMaskFromSavedState(nextState); + + // Clear stroke overlay to prevent old drawing previews from persisting + this.canvas.canvasRenderer.clearMaskStrokeOverlay(); + + this.canvas.render(); this.canvas.updateHistoryButtons(); } @@ -486,11 +449,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor */ clearHistory(): void { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { - this.maskUndoStack = []; - this.maskRedoStack = []; + this.maskHistory.clear(); } else { - this.layersUndoStack = []; - this.layersRedoStack = []; + this.layerHistory.clear(); } this.canvas.updateHistoryButtons(); log.info("History cleared"); @@ -502,21 +463,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor */ getHistoryInfo(): HistoryInfo { if (this.canvas.maskTool && this.canvas.maskTool.isActive) { - return { - undoCount: this.maskUndoStack.length, - redoCount: this.maskRedoStack.length, - canUndo: this.maskUndoStack.length > 1, - canRedo: this.maskRedoStack.length > 0, - historyLimit: this.historyLimit - }; + return this.maskHistory.getHistoryInfo(); } else { - return { - undoCount: this.layersUndoStack.length, - redoCount: this.layersRedoStack.length, - canUndo: this.layersUndoStack.length > 1, - canRedo: this.layersRedoStack.length > 0, - historyLimit: this.historyLimit - }; + return this.layerHistory.getHistoryInfo(); } } } diff --git a/tests/test_canvas_history.mjs b/tests/test_canvas_history.mjs new file mode 100644 index 0000000..ee71edb --- /dev/null +++ b/tests/test_canvas_history.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { HistoryStack } from '../js/canvas/CanvasHistory.js'; + +test('history stack keeps independent snapshots and supports undo/redo', () => { + const history = new HistoryStack({ + clone: (value) => ({ ...value }), + equals: (left, right) => left.value === right.value, + historyLimit: 3, + }); + + const first = { value: 1 }; + assert.equal(history.push(first), true); + first.value = 99; + assert.equal(history.undoStack[0].value, 1); + + assert.equal(history.push({ value: 1 }), false); + assert.equal(history.push({ value: 2 }), true); + assert.equal(history.push({ value: 3 }), true); + assert.deepEqual(history.getHistoryInfo(), { + undoCount: 3, + redoCount: 0, + canUndo: true, + canRedo: false, + historyLimit: 3, + }); + + assert.deepEqual(history.undo(), { value: 2 }); + assert.deepEqual(history.redo(), { value: 3 }); + assert.equal(history.redo(), null); +}); + +test('replacing the last snapshot clears redo history and respects the limit', () => { + const history = new HistoryStack({ + clone: (value) => value, + historyLimit: 2, + }); + + history.push('a'); + history.push('b'); + history.push('c'); + assert.deepEqual(history.undoStack, ['b', 'c']); + + assert.equal(history.undo(), 'b'); + assert.deepEqual(history.redoStack, ['c']); + assert.equal(history.push('replacement', true), true); + assert.deepEqual(history.undoStack, ['replacement']); + assert.deepEqual(history.redoStack, []); + + history.clear(); + assert.deepEqual(history.getHistoryInfo(), { + undoCount: 0, + redoCount: 0, + canUndo: false, + canRedo: false, + historyLimit: 2, + }); +}); diff --git a/tests/test_canvas_operations_characterization.mjs b/tests/test_canvas_operations_characterization.mjs index d6857de..5c2f6de 100644 --- a/tests/test_canvas_operations_characterization.mjs +++ b/tests/test_canvas_operations_characterization.mjs @@ -4,11 +4,12 @@ import test from 'node:test'; import { cloneCanvas } from '../js/utils/CommonUtils.js'; -const [layersSource, stateSource, maskEditorSource, commonUtilsSource] = await Promise.all([ +const [layersSource, stateSource, maskEditorSource, commonUtilsSource, historySource] = await Promise.all([ readFile(new URL('../src/canvas/CanvasLayers.ts', import.meta.url), 'utf8'), readFile(new URL('../src/canvas/CanvasState.ts', import.meta.url), 'utf8'), readFile(new URL('../src/mask/MaskEditorIntegration.ts', import.meta.url), 'utf8'), readFile(new URL('../src/utils/CommonUtils.ts', import.meta.url), 'utf8'), + readFile(new URL('../src/canvas/CanvasHistory.ts', import.meta.url), 'utf8'), ]); test('mirror commands share the selected-layer flip lifecycle', () => { @@ -72,7 +73,8 @@ test('shared canvas cloning preserves dimensions, options, pixels, and null-cont }); test('mask snapshots use the shared canvas clone helper', () => { - assert.match(stateSource, /const clonedCanvas = cloneCanvas\(maskCanvas\);/); + assert.match(stateSource, /maskHistory = new HistoryStack\(\{[\s\S]*?clone: cloneCanvas,/); + assert.match(historySource, /export class HistoryStack/); assert.doesNotMatch(stateSource, /clonedCtx\.drawImage\(maskCanvas, 0, 0\)/); assert.match(maskEditorSource, /const savedCanvas = cloneCanvas\(maskCanvas\);/); assert.doesNotMatch(maskEditorSource, /savedCtx\.drawImage\(maskCanvas, 0, 0\)/);