Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 123 additions & 5 deletions assets/toolbox/sprites/js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const editorState = {
gridSize: DEFAULT_GRID_SIZE,
paintedPixels: new Map(),
};
const editorHistory = {
redoStack: [],
undoStack: [],
};

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

function stateSnapshot() {
return {
gridSize: editorState.gridSize,
paintedPixels: Array.from(editorState.paintedPixels.entries()),
};
}

function sameSnapshot(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}

function pushUndoSnapshot() {
const snapshot = stateSnapshot();
const lastSnapshot = editorHistory.undoStack[editorHistory.undoStack.length - 1];
if (!lastSnapshot || !sameSnapshot(snapshot, lastSnapshot)) {
editorHistory.undoStack.push(snapshot);
}
editorHistory.redoStack = [];
updateHistoryControls();
}

function historyStatusText() {
if (editorHistory.undoStack.length === 0 && editorHistory.redoStack.length === 0) {
return "Undo history is empty.";
}
return `Undo steps: ${editorHistory.undoStack.length}. Redo steps: ${editorHistory.redoStack.length}.`;
}

function updateHistoryControls() {
const undoButton = document.querySelector("[data-sprites-undo]");
const redoButton = document.querySelector("[data-sprites-redo]");
const status = document.querySelector("[data-sprites-history-status]");
if (undoButton) {
undoButton.disabled = editorHistory.undoStack.length === 0;
}
if (redoButton) {
redoButton.disabled = editorHistory.redoStack.length === 0;
}
if (status) {
status.textContent = historyStatusText();
}
}

function draftStatusText() {
const count = editorState.paintedPixels.size;
if (count === 0) {
Expand All @@ -54,6 +101,30 @@ function setCellColor(cell, colorKey) {
cell.dataset.spriteColorKey = normalizedColorKey;
}

function applyPaintedPixelsToGrid() {
const grid = document.querySelector("[data-sprites-pixel-grid]");
if (!grid) {
return;
}
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
const colorKey = editorState.paintedPixels.get(key);
if (colorKey) {
setCellColor(cell, colorKey);
} else {
clearCellColor(cell);
}
});
}

function applySnapshot(snapshot) {
setGridSize(snapshot.gridSize, { recordHistory: false });
editorState.paintedPixels = new Map(snapshot.paintedPixels);
applyPaintedPixelsToGrid();
updateDraftStatus();
updateHistoryControls();
}

function updateDraftStatus() {
const status = document.querySelector("[data-sprites-draft-status]");
if (status) {
Expand Down Expand Up @@ -99,9 +170,17 @@ function setActiveTool(toolName) {
function paintCell(cell) {
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
if (editorState.activeTool === "eraser") {
if (!editorState.paintedPixels.has(key)) {
return;
}
pushUndoSnapshot();
editorState.paintedPixels.delete(key);
clearCellColor(cell);
} else {
if (editorState.paintedPixels.get(key) === editorState.activeColor) {
return;
}
pushUndoSnapshot();
editorState.paintedPixels.set(key, editorState.activeColor);
setCellColor(cell, editorState.activeColor);
}
Expand All @@ -113,6 +192,7 @@ function fillGrid() {
if (!grid) {
return;
}
pushUndoSnapshot();
editorState.paintedPixels.clear();
grid.querySelectorAll("[data-sprite-pixel-row]").forEach((cell) => {
const key = pixelKey(cell.dataset.spritePixelRow, cell.dataset.spritePixelColumn);
Expand All @@ -122,8 +202,11 @@ function fillGrid() {
updateDraftStatus();
}

function clearCanvas() {
function clearCanvas(options = {}) {
const grid = document.querySelector("[data-sprites-pixel-grid]");
if (options.recordHistory !== false && editorState.paintedPixels.size > 0) {
pushUndoSnapshot();
}
editorState.paintedPixels.clear();
if (grid) {
grid.querySelectorAll("[data-sprite-pixel-row]").forEach(clearCellColor);
Expand All @@ -132,8 +215,9 @@ function clearCanvas() {
}

function resetGridToDefault() {
setGridSize(DEFAULT_GRID_SIZE);
clearCanvas();
pushUndoSnapshot();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid recording no-op resets

When the draft is already an empty 16x16 canvas, such as immediately after undoing a paint from the initial grid, clicking Reset to 16x16 makes no visible state change but this unconditional history push clears the redo stack via pushUndoSnapshot(). That disables Redo and loses the user's ability to restore the undone edit, so the reset should only record history when the grid size or painted pixels would actually change.

Useful? React with 👍 / 👎.

setGridSize(DEFAULT_GRID_SIZE, { recordHistory: false });
clearCanvas({ recordHistory: false });
}

function editorColorValue(colorKey) {
Expand Down Expand Up @@ -194,13 +278,17 @@ function exportPreviewPng() {
}, "image/png");
}

function setGridSize(size) {
function setGridSize(size, options = {}) {
const grid = document.querySelector("[data-sprites-pixel-grid]");
const status = document.querySelector("[data-sprites-grid-status]");
if (!grid || !SUPPORTED_GRID_SIZES.includes(size)) {
return;
}

if (options.recordHistory && (editorState.gridSize !== size || editorState.paintedPixels.size > 0)) {
pushUndoSnapshot();
}

grid.replaceChildren();
editorState.gridSize = size;
editorState.paintedPixels.clear();
Expand Down Expand Up @@ -239,7 +327,7 @@ function wireGridControls() {
if (!button) {
return;
}
button.addEventListener("click", () => setGridSize(size));
button.addEventListener("click", () => setGridSize(size, { recordHistory: true }));
});
}

Expand Down Expand Up @@ -278,6 +366,34 @@ function wireCanvasActions() {
}
}

function wireHistoryActions() {
const undoButton = document.querySelector("[data-sprites-undo]");
if (undoButton) {
undoButton.addEventListener("click", () => {
const snapshot = editorHistory.undoStack.pop();
if (!snapshot) {
updateHistoryControls();
return;
}
editorHistory.redoStack.push(stateSnapshot());
applySnapshot(snapshot);
});
}

const redoButton = document.querySelector("[data-sprites-redo]");
if (redoButton) {
redoButton.addEventListener("click", () => {
const snapshot = editorHistory.redoStack.pop();
if (!snapshot) {
updateHistoryControls();
return;
}
editorHistory.undoStack.push(stateSnapshot());
applySnapshot(snapshot);
});
}
}

function wireExportButton() {
const button = document.querySelector("[data-sprites-export-png]");
if (button) {
Expand All @@ -289,8 +405,10 @@ wireGridControls();
wireDrawingTools();
wirePaletteButtons();
wireCanvasActions();
wireHistoryActions();
wireExportButton();
setGridSize(DEFAULT_GRID_SIZE);
setActiveTool(editorState.activeTool);
setActiveColor(editorState.activeColor);
updateHistoryControls();
renderPreview();
25 changes: 25 additions & 0 deletions dev/tests/playwright/tools/SpritesToolShell.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -244,14 +244,25 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(1024);
await expect(page.locator("[data-sprites-grid-status]")).toContainText("Canvas display mode: 32x32");
await expect(page.getByRole("button", { name: "32x32" })).toHaveAttribute("aria-pressed", "true");
await expect(page.getByRole("button", { name: "Undo" })).toBeEnabled();
await expect(page.getByRole("button", { name: "Redo" })).toBeDisabled();
const firstPixel = page.locator("[data-sprites-pixel-grid] [role='gridcell']").first();
await firstPixel.click();
await expect(firstPixel).toHaveClass(/is-painted/);
await expect(page.locator("[data-sprites-draft-status]")).toContainText("1 draft pixel painted");
await page.getByRole("button", { name: "Undo" }).click();
await expect(firstPixel).not.toHaveClass(/is-painted/);
await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled();
await page.getByRole("button", { name: "Redo" }).click();
await expect(firstPixel).toHaveClass(/is-painted/);
await page.getByRole("button", { name: "Eraser tool" }).click();
await firstPixel.click();
await expect(firstPixel).not.toHaveClass(/is-painted/);
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
await page.getByRole("button", { name: "Undo" }).click();
await expect(firstPixel).toHaveClass(/is-painted/);
await page.getByRole("button", { name: "Redo" }).click();
await expect(firstPixel).not.toHaveClass(/is-painted/);
await page.getByRole("button", { name: "Gold editor color" }).click();
await expect(page.locator("[data-sprites-palette-status]")).toContainText("Active editor color: Gold");
await page.getByRole("button", { name: "Pencil tool" }).click();
Expand All @@ -262,16 +273,30 @@ test("Sprite Creator shell loads with visible tool, canvas, details, and status
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
await expect(page.locator("[data-sprites-pixel-grid] .sprite-canvas-cell--blue")).toHaveCount(1024);
await expect(page.locator("[data-sprites-draft-status]")).toContainText("1024 draft pixels painted");
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1);
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
await page.getByRole("button", { name: "Clear Canvas" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
await page.getByRole("button", { name: "Fill tool" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
await page.getByRole("button", { name: "Reset to 16x16" }).click();
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 16 by 16 pixel canvas");
await expect(page.locator("[data-sprites-pixel-grid] [role='gridcell']")).toHaveCount(256);
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
await expect(page.locator("[data-sprites-draft-status]")).toContainText("empty draft");
await page.getByRole("button", { name: "Undo" }).click();
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 32 by 32 pixel canvas");
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(1024);
await page.getByRole("button", { name: "Redo" }).click();
await expect(page.locator("[data-sprites-pixel-grid]")).toHaveAttribute("aria-label", "Sprite Creator 16 by 16 pixel canvas");
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(0);
await page.getByRole("button", { name: "Blue editor color" }).click();
await page.getByRole("button", { name: "Fill tool" }).click();
await expect(page.locator("[data-sprites-pixel-grid] .is-painted")).toHaveCount(256);
Expand Down
73 changes: 73 additions & 0 deletions docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# PR_26179_CHARLIE_030-sprites-undo-redo

Team: CHARLIE

Mode: Batch governance stacked feature workflow

Base branch: PR_26179_CHARLIE_029-sprites-clear-reset-controls

Branch: PR_26179_CHARLIE_030-sprites-undo-redo

## Summary

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.

## Branch Validation

PASS

- Started from the prior stacked Sprite branch.
- Scope stayed limited to Sprite Creator undo/redo.
- No DB/API/schema/runtime ownership changes.
- No browser-owned authoritative product data was introduced.
- No start_of_day files were changed.
- ZIP package created at the user-requested batch path.

## Requirement Checklist

PASS - Add undo/redo for drawing.

PASS - Add undo/redo for erase.

PASS - Add undo/redo for fill.

PASS - Add undo/redo for clear canvas.

PASS - Add undo/redo for grid reset.

PASS - Keep state as unsaved page-session editor state only.

PASS - No persistence added.

PASS - No DB/API/schema changes.

PASS - No stale PR #219-#228 code copied.

PASS - Targeted Playwright test updated.

## Validation Lane

PASS - `node --check assets/toolbox/sprites/js/index.js`

PASS - `node --check dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - `git diff --check -- toolbox/sprites/index.html assets/toolbox/sprites/js/index.js dev/tests/playwright/tools/SpritesToolShell.spec.mjs`

PASS - Guard scan found no prohibited inline style/script/event-handler or stale placeholder text in touched runtime files.

PASS - `npx playwright test dev/tests/playwright/tools/SpritesToolShell.spec.mjs --workers=1 --reporter=list`

## Manual Validation Notes

1. Open `toolbox/sprites/index.html`.
2. Confirm Undo and Redo controls are visible and disabled before edits.
3. Switch to 32x32 and confirm Undo becomes available.
4. Paint a pixel, undo it, then redo it.
5. Erase a pixel, undo it, then redo it.
6. Fill the canvas, undo to the prior draft, then redo the fill.
7. Clear the canvas, undo to restore the filled draft, then redo the clear.
8. Reset to 16x16, undo to restore the prior 32x32 draft, then redo the reset.

## ZIP

`dev/workspace/zip/PR_26179_CHARLIE_030-sprites-undo-redo_delta.zip`
4 changes: 2 additions & 2 deletions docs_build/dev/reports/codex_changed_files.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
toolbox/sprites/index.html
assets/toolbox/sprites/js/index.js
dev/tests/playwright/tools/SpritesToolShell.spec.mjs
docs_build/dev/reports/PR_26179_CHARLIE_029-sprites-clear-reset-controls.md
docs_build/dev/reports/PR_26179_CHARLIE_030-sprites-undo-redo.md
docs_build/dev/reports/codex_changed_files.txt
docs_build/dev/reports/codex_review.diff
toolbox/sprites/index.html
Loading
Loading