refactor: isolate canvas history model
This commit is contained in:
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\)/);
|
||||
|
||||
Reference in New Issue
Block a user