Skip to content

Commit 8eb58d1

Browse files
authored
Merge pull request #266 from ToolboxAid/PR_26179_CHARLIE_030-sprites-undo-redo
PR_26179_CHARLIE_030-sprites-undo-redo
2 parents c46fc89 + 4f85c45 commit 8eb58d1

6 files changed

Lines changed: 539 additions & 120 deletions

File tree

assets/toolbox/sprites/js/index.js

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const editorState = {
1616
gridSize: DEFAULT_GRID_SIZE,
1717
paintedPixels: new Map(),
1818
};
19+
const editorHistory = {
20+
redoStack: [],
21+
undoStack: [],
22+
};
1923

2024
function gridLabel(size) {
2125
return `Sprite Creator ${size} by ${size} pixel canvas`;
@@ -29,6 +33,49 @@ function pixelKey(row, column) {
2933
return `${row}:${column}`;
3034
}
3135

36+
function stateSnapshot() {
37+
return {
38+
gridSize: editorState.gridSize,
39+
paintedPixels: Array.from(editorState.paintedPixels.entries()),
40+
};
41+
}
42+
43+
function sameSnapshot(left, right) {
44+
return JSON.stringify(left) === JSON.stringify(right);
45+
}
46+
47+
function pushUndoSnapshot() {
48+
const snapshot = stateSnapshot();
49+
const lastSnapshot = editorHistory.undoStack[editorHistory.undoStack.length - 1];
50+
if (!lastSnapshot || !sameSnapshot(snapshot, lastSnapshot)) {
51+
editorHistory.undoStack.push(snapshot);
52+
}
53+
editorHistory.redoStack = [];
54+
updateHistoryControls();
55+
}
56+
57+
function historyStatusText() {
58+
if (editorHistory.undoStack.length === 0 && editorHistory.redoStack.length === 0) {
59+
return "Undo history is empty.";
60+
}
61+
return `Undo steps: ${editorHistory.undoStack.length}. Redo steps: ${editorHistory.redoStack.length}.`;
62+
}
63+
64+
function updateHistoryControls() {
65+
const undoButton = document.querySelector("[data-sprites-undo]");
66+
const redoButton = document.querySelector("[data-sprites-redo]");
67+
const status = document.querySelector("[data-sprites-history-status]");
68+
if (undoButton) {
69+
undoButton.disabled = editorHistory.undoStack.length === 0;
70+
}
71+
if (redoButton) {
72+
redoButton.disabled = editorHistory.redoStack.length === 0;
73+
}
74+
if (status) {
75+
status.textContent = historyStatusText();
76+
}
77+
}
78+
3279
function draftStatusText() {
3380
const count = editorState.paintedPixels.size;
3481
if (count === 0) {
@@ -54,6 +101,30 @@ function setCellColor(cell, colorKey) {
54101
cell.dataset.spriteColorKey = normalizedColorKey;
55102
}
56103

104+
function applyPaintedPixelsToGrid() {
105+
const grid = document.querySelector("[data-sprites-pixel-grid]");
106+
if (!grid) {
107+
return;
108+
}
109+
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
110+
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
111+
const colorKey = editorState.paintedPixels.get(key);
112+
if (colorKey) {
113+
setCellColor(cell, colorKey);
114+
} else {
115+
clearCellColor(cell);
116+
}
117+
});
118+
}
119+
120+
function applySnapshot(snapshot) {
121+
setGridSize(snapshot.gridSize, { recordHistory: false });
122+
editorState.paintedPixels = new Map(snapshot.paintedPixels);
123+
applyPaintedPixelsToGrid();
124+
updateDraftStatus();
125+
updateHistoryControls();
126+
}
127+
57128
function updateDraftStatus() {
58129
const status = document.querySelector("[data-sprites-draft-status]");
59130
if (status) {
@@ -99,9 +170,17 @@ function setActiveTool(toolName) {
99170
function paintCell(cell) {
100171
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
101172
if (editorState.activeTool === "eraser") {
173+
if (!editorState.paintedPixels.has(key)) {
174+
return;
175+
}
176+
pushUndoSnapshot();
102177
editorState.paintedPixels.delete(key);
103178
clearCellColor(cell);
104179
} else {
180+
if (editorState.paintedPixels.get(key) === editorState.activeColor) {
181+
return;
182+
}
183+
pushUndoSnapshot();
105184
editorState.paintedPixels.set(key, editorState.activeColor);
106185
setCellColor(cell, editorState.activeColor);
107186
}
@@ -113,6 +192,7 @@ function fillGrid() {
113192
if (!grid) {
114193
return;
115194
}
195+
pushUndoSnapshot();
116196
editorState.paintedPixels.clear();
117197
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
118198
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
@@ -122,8 +202,11 @@ function fillGrid() {
122202
updateDraftStatus();
123203
}
124204

125-
function clearCanvas() {
205+
function clearCanvas(options = {}) {
126206
const grid = document.querySelector("[data-sprites-pixel-grid]");
207+
if (options.recordHistory !== false && editorState.paintedPixels.size > 0) {
208+
pushUndoSnapshot();
209+
}
127210
editorState.paintedPixels.clear();
128211
if (grid) {
129212
grid.querySelectorAll("[data-sprite-pixel-row]").forEach(clearCellColor);
@@ -132,8 +215,9 @@ function clearCanvas() {
132215
}
133216

134217
function resetGridToDefault() {
135-
setGridSize(DEFAULT_GRID_SIZE);
136-
clearCanvas();
218+
pushUndoSnapshot();
219+
setGridSize(DEFAULT_GRID_SIZE, { recordHistory: false });
220+
clearCanvas({ recordHistory: false });
137221
}
138222

139223
function editorColorValue(colorKey) {
@@ -194,13 +278,17 @@ function exportPreviewPng() {
194278
}, "image/png");
195279
}
196280

197-
function setGridSize(size) {
281+
function setGridSize(size, options = {}) {
198282
const grid = document.querySelector("[data-sprites-pixel-grid]");
199283
const status = document.querySelector("[data-sprites-grid-status]");
200284
if (!grid || !SUPPORTED_GRID_SIZES.includes(size)) {
201285
return;
202286
}
203287

288+
if (options.recordHistory && (editorState.gridSize !== size || editorState.paintedPixels.size > 0)) {
289+
pushUndoSnapshot();
290+
}
291+
204292
grid.replaceChildren();
205293
editorState.gridSize = size;
206294
editorState.paintedPixels.clear();
@@ -239,7 +327,7 @@ function wireGridControls() {
239327
if (!button) {
240328
return;
241329
}
242-
button.addEventListener("click", () => setGridSize(size));
330+
button.addEventListener("click", () => setGridSize(size, { recordHistory: true }));
243331
});
244332
}
245333

@@ -278,6 +366,34 @@ function wireCanvasActions() {
278366
}
279367
}
280368

369+
function wireHistoryActions() {
370+
const undoButton = document.querySelector("[data-sprites-undo]");
371+
if (undoButton) {
372+
undoButton.addEventListener("click", () => {
373+
const snapshot = editorHistory.undoStack.pop();
374+
if (!snapshot) {
375+
updateHistoryControls();
376+
return;
377+
}
378+
editorHistory.redoStack.push(stateSnapshot());
379+
applySnapshot(snapshot);
380+
});
381+
}
382+
383+
const redoButton = document.querySelector("[data-sprites-redo]");
384+
if (redoButton) {
385+
redoButton.addEventListener("click", () => {
386+
const snapshot = editorHistory.redoStack.pop();
387+
if (!snapshot) {
388+
updateHistoryControls();
389+
return;
390+
}
391+
editorHistory.undoStack.push(stateSnapshot());
392+
applySnapshot(snapshot);
393+
});
394+
}
395+
}
396+
281397
function wireExportButton() {
282398
const button = document.querySelector("[data-sprites-export-png]");
283399
if (button) {
@@ -289,8 +405,10 @@ wireGridControls();
289405
wireDrawingTools();
290406
wirePaletteButtons();
291407
wireCanvasActions();
408+
wireHistoryActions();
292409
wireExportButton();
293410
setGridSize(DEFAULT_GRID_SIZE);
294411
setActiveTool(editorState.activeTool);
295412
setActiveColor(editorState.activeColor);
413+
updateHistoryControls();
296414
renderPreview();

dev/tests/playwright/tools/SpritesToolShell.spec.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,14 +244,25 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
244244
await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(1024);
245245
await expect(page.locator("[data-sprites-grid-status]")).toContainText("Canvas display mode: 32x32");
246246
await expect(page.getByRole("button", { name: "32x32" })).toHaveAttribute("aria-pressed", "true");
247+
await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
248+
await expect(page.getByRole("button", { name: "Redo" })).toBeDisabled();
247249
const firstPixel = page.locator("[data-sprites-pixel-grid] [role='gridcell']").first();
248250
await firstPixel.click();
249251
await expect(firstPixel).toHaveClass(/is-painted/);
250252
await expect(page.locator("[data-sprites-draft-status]")).toContainText("1 draft pixel painted");
253+
await page.getByRole("button", { name: "Undo" }).click();
254+
await expect(firstPixel).not.toHaveClass(/is-painted/);
255+
await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled();
256+
await page.getByRole("button", { name: "Redo" }).click();
257+
await expect(firstPixel).toHaveClass(/is-painted/);
251258
await page.getByRole("button", { name: "Eraser tool" }).click();
252259
await firstPixel.click();
253260
await expect(firstPixel).not.toHaveClass(/is-painted/);
254261
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
262+
await page.getByRole("button", { name: "Undo" }).click();
263+
await expect(firstPixel).toHaveClass(/is-painted/);
264+
await page.getByRole("button", { name: "Redo" }).click();
265+
await expect(firstPixel).not.toHaveClass(/is-painted/);
255266
await page.getByRole("button", { name: "Gold editor color" }).click();
256267
await expect(page.locator("[data-sprites-palette-status]")).toContainText("Active editor color: Gold");
257268
await page.getByRole("button", { name: "Pencil tool" }).click();
@@ -262,16 +273,30 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
262273
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
263274
await expect(page.locator("[data-sprites-pixel-grid] .sprite-canvas-cell--blue")).toHaveCount(1024);
264275
await expect(page.locator("[data-sprites-draft-status]")).toContainText("1024 draft pixels painted");
276+
await page.getByRole("button", { name: "Undo" }).click();
277+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1);
278+
await page.getByRole("button", { name: "Redo" }).click();
279+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
265280
await page.getByRole("button", { name: "Clear Canvas" }).click();
266281
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
267282
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
283+
await page.getByRole("button", { name: "Undo" }).click();
284+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
285+
await page.getByRole("button", { name: "Redo" }).click();
286+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
268287
await page.getByRole("button", { name: "Fill tool" }).click();
269288
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
270289
await page.getByRole("button", { name: "Reset to 16x16" }).click();
271290
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 16 by 16 pixel canvas");
272291
await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(256);
273292
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
274293
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
294+
await page.getByRole("button", { name: "Undo" }).click();
295+
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 32 by 32 pixel canvas");
296+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
297+
await page.getByRole("button", { name: "Redo" }).click();
298+
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 16 by 16 pixel canvas");
299+
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
275300
await page.getByRole("button", { name: "Blue editor color" }).click();
276301
await page.getByRole("button", { name: "Fill tool" }).click();
277302
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(256);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# PR_26179_CHARLIE_030-sprites-undo-redo
2+
3+
Team: CHARLIE
4+
5+
Mode: Batch governance stacked feature workflow
6+
7+
Base branch: PR_26179_CHARLIE_029-sprites-clear-reset-controls
8+
9+
Branch: PR_26179_CHARLIE_030-sprites-undo-redo
10+
11+
## Summary
12+
13+
Added undo and redo controls for Sprite Creator page-session editor actions. Undo/redo covers pencil, eraser, fill, clear canvas, and grid reset actions while preserving the unsaved editor-state-only model. No persistence, database, API, schema, or start_of_day files were changed.
14+
15+
## Branch Validation
16+
17+
PASS
18+
19+
- Started from the prior stacked Sprite branch.
20+
- Scope stayed limited to Sprite Creator undo/redo.
21+
- No DB/API/schema/runtime ownership changes.
22+
- No browser-owned authoritative product data was introduced.
23+
- No start_of_day files were changed.
24+
- ZIP package created at the user-requested batch path.
25+
26+
## Requirement Checklist
27+
28+
PASS - Add undo/redo for drawing.
29+
30+
PASS - Add undo/redo for erase.
31+
32+
PASS - Add undo/redo for fill.
33+
34+
PASS - Add undo/redo for clear canvas.
35+
36+
PASS - Add undo/redo for grid reset.
37+
38+
PASS - Keep state as unsaved page-session editor state only.
39+
40+
PASS - No persistence added.
41+
42+
PASS - No DB/API/schema changes.
43+
44+
PASS - No stale PR #219-#228 code copied.
45+
46+
PASS - Targeted Playwright test updated.
47+
48+
## Validation Lane
49+
50+
PASS - `node --check assets/toolbox/sprites/js/index.js`
51+
52+
PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
53+
54+
PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`
55+
56+
PASS - Guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.
57+
58+
PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`
59+
60+
## Manual Validation Notes
61+
62+
1. Open `toolbox/sprites/index.html`.
63+
2. Confirm Undo and Redo controls are visible and disabled before edits.
64+
3. Switch to 32x32 and confirm Undo becomes available.
65+
4. Paint a pixel, undo it, then redo it.
66+
5. Erase a pixel, undo it, then redo it.
67+
6. Fill the canvas, undo to the prior draft, then redo the fill.
68+
7. Clear the canvas, undo to restore the filled draft, then redo the clear.
69+
8. Reset to 16x16, undo to restore the prior 32x32 draft, then redo the reset.
70+
71+
## ZIP
72+
73+
`dev/workspace/zip/PR_26179_CHARLIE_030-sprites-undo-redo_delta.zip`
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
toolbox/sprites/index.html
21
assets/toolbox/sprites/js/index.js
32
dev/tests/playwright/tools/SpritesToolShell.spec.mjs
4-
docs_build/dev/reports/PR_26179_CHARLIE_029-sprites-clear-reset-controls.md
3+
docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md
54
docs_build/dev/reports/codex_changed_files.txt
65
docs_build/dev/reports/codex_review.diff
6+
toolbox/sprites/index.html

0 commit comments

Comments
 (0)