refactor: isolate canvas history model

This commit is contained in:
Dariusz L
2026-08-14 11:38:27 +02:00
parent f206ff0289
commit 55dcf6617f
6 changed files with 345 additions and 201 deletions
+68
View File
@@ -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,
};
}
}
+56 -86
View File
@@ -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();
}
}
}
+96
View File
@@ -0,0 +1,96 @@
export interface HistoryInfo {
undoCount: number;
redoCount: number;
canUndo: boolean;
canRedo: boolean;
historyLimit: number;
}
export interface HistoryStackOptions<T> {
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<T> {
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<T>) {
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,
};
}
}
+62 -113
View File
@@ -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<boolean> | null;
private canvas: Canvas & { node: ComfyNode, layers: Layer[] };
private historyLimit: number;
private layerHistory: HistoryStack<Layer[]>;
private lastSavedStateSignature: string | null;
public layersRedoStack: Layer[][];
public layersUndoStack: Layer[][];
public maskRedoStack: HTMLCanvasElement[];
public maskUndoStack: HTMLCanvasElement[];
private maskHistory: HistoryStack<HTMLCanvasElement>;
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<Layer[]>({
clone: cloneLayers,
equals: (left, right) => getStateSignature(left) === getStateSignature(right),
historyLimit: this.historyLimit,
});
this.maskHistory = new HistoryStack<HTMLCanvasElement>({
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();
}
}
}
+59
View File
@@ -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,
});
});
@@ -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<HTMLCanvasElement>\(\{[\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\)/);