Add undo/redo functionality to Canvas

Implemented undo and redo history stacks in Canvas.js, including keyboard shortcuts (Ctrl+Z, Ctrl+Y/Shift+Z), and state saving on relevant actions. Added Undo and Redo buttons to the UI in Canvas_view.js, with dynamic enable/disable based on history state.
This commit is contained in:
Dariusz L
2025-06-24 05:34:32 +02:00
parent 41bd1761c4
commit c8e7e2c561
2 changed files with 151 additions and 13 deletions

View File

@@ -74,6 +74,85 @@ export class Canvas {
...layer,
opacity: 1
}));
this.undoStack = [];
this.redoStack = [];
this.historyLimit = 100;
this.saveState();
}
cloneLayers(layers) {
return layers.map(layer => {
const newLayer = { ...layer };
// Obiekty Image nie są klonowane, aby oszczędzać pamięć.
// Zakładamy, że same dane obrazu się nie zmieniają.
return newLayer;
});
}
saveState(replaceLast = false) {
if (replaceLast && this.undoStack.length > 0) {
this.undoStack.pop();
}
const currentState = this.cloneLayers(this.layers);
if (this.undoStack.length > 0) {
const lastState = this.undoStack[this.undoStack.length - 1];
if (JSON.stringify(currentState) === JSON.stringify(lastState)) {
return;
}
}
this.undoStack.push(currentState);
if (this.undoStack.length > this.historyLimit) {
this.undoStack.shift();
}
this.redoStack = [];
this.updateHistoryButtons();
}
undo() {
if (this.undoStack.length <= 1) return;
const currentState = this.undoStack.pop();
this.redoStack.push(currentState);
const prevState = this.undoStack[this.undoStack.length - 1];
this.layers = this.cloneLayers(prevState);
this.updateSelectionAfterHistory();
this.render();
this.updateHistoryButtons();
}
redo() {
if (this.redoStack.length === 0) return;
const nextState = this.redoStack.pop();
this.undoStack.push(nextState);
this.layers = this.cloneLayers(nextState);
this.updateSelectionAfterHistory();
this.render();
this.updateHistoryButtons();
}
updateSelectionAfterHistory() {
const newSelectedLayers = [];
if (this.selectedLayers) {
this.selectedLayers.forEach(sl => {
const found = this.layers.find(l => l.id === sl.id);
if(found) newSelectedLayers.push(found);
});
}
this.updateSelection(newSelectedLayers);
}
updateHistoryButtons() {
if (this.onHistoryChange) {
this.onHistoryChange({
canUndo: this.undoStack.length > 1,
canRedo: this.redoStack.length > 0
});
}
}
initCanvas() {
@@ -130,8 +209,6 @@ export class Canvas {
}
handleMouseDown(e) {
this.canvas.focus();
const currentTime = Date.now();
@@ -195,7 +272,7 @@ export class Canvas {
pasteLayers() {
if (this.internalClipboard.length === 0) return;
this.saveState();
const newLayers = [];
const pasteOffset = 20;
@@ -297,6 +374,8 @@ export class Canvas {
handleMouseUp(e) {
const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning';
if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize();
} else if (this.interaction.mode === 'movingCanvas') {
@@ -304,6 +383,10 @@ export class Canvas {
}
this.resetInteractionState();
this.render();
if (interactionEnded) {
this.saveState();
}
}
@@ -395,24 +478,45 @@ export class Canvas {
this.interaction.isAltPressed = true;
e.preventDefault();
}
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
if (this.selectedLayers.length > 0) {
if (e.ctrlKey) {
if (e.key.toLowerCase() === 'z') {
e.preventDefault();
e.stopPropagation();
this.copySelectedLayers();
if (e.shiftKey) {
this.redo();
} else {
this.undo();
}
return;
}
if (e.key.toLowerCase() === 'y') {
e.preventDefault();
e.stopPropagation();
this.redo();
return;
}
if (e.key.toLowerCase() === 'c') {
if (this.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.copySelectedLayers();
}
return;
}
if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.handlePaste();
return;
}
}
if (e.ctrlKey && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.handlePaste();
return;
}
if (this.selectedLayer) {
if (e.key === 'Delete') {
e.preventDefault();
e.stopPropagation();
this.saveState();
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
this.updateSelection([]);
this.render();
@@ -444,6 +548,7 @@ export class Canvas {
if (needsRender) {
this.render();
this.saveState();
}
}
}
@@ -906,6 +1011,7 @@ export class Canvas {
}
updateCanvasSize(width, height) {
this.saveState();
this.width = width;
this.height = height;
@@ -1531,6 +1637,7 @@ export class Canvas {
});
this.layers.forEach((layer, i) => layer.zIndex = i);
this.render();
this.saveState();
}
moveLayerDown() {
@@ -1548,6 +1655,7 @@ export class Canvas {
});
this.layers.forEach((layer, i) => layer.zIndex = i);
this.render();
this.saveState();
}
@@ -1619,6 +1727,7 @@ export class Canvas {
newImage.onload = () => {
layer.image = newImage;
this.render();
this.saveState();
};
newImage.src = tempCanvas.toDataURL();
});
@@ -1641,6 +1750,7 @@ export class Canvas {
newImage.onload = () => {
layer.image = newImage;
this.render();
this.saveState();
};
newImage.src = tempCanvas.toDataURL();
});

View File

@@ -519,6 +519,18 @@ async function createCanvasWidget(node, widget, app) {
}
}),
$el("button.painter-button", {
id: `undo-button-${node.id}`,
textContent: "Undo",
disabled: true,
onclick: () => canvas.undo()
}),
$el("button.painter-button", {
id: `redo-button-${node.id}`,
textContent: "Redo",
disabled: true,
onclick: () => canvas.redo()
}),
$el("button.painter-button.requires-selection.matting-button", {
textContent: "Matting",
onclick: async () => {
@@ -600,7 +612,18 @@ async function createCanvasWidget(node, widget, app) {
};
canvas.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);
canvas.onHistoryChange = ({ canUndo, canRedo }) => {
if(undoButton) undoButton.disabled = !canUndo;
if(redoButton) redoButton.disabled = !canRedo;
};
updateButtonStates();
canvas.updateHistoryButtons();
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = entries[0].target.offsetHeight;
@@ -621,9 +644,14 @@ async function createCanvasWidget(node, widget, app) {
};
const addUpdateToButton = (button) => {
if (button.textContent === "Undo" || button.textContent === "Redo") {
return;
}
const origClick = button.onclick;
button.onclick = async (...args) => {
await origClick?.(...args);
if (origClick) {
await origClick(...args);
}
await updateOutput();
};
};