diff --git a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx index 0a8d7a9f..c4994b59 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.test.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.test.tsx @@ -3,7 +3,7 @@ // thing the device receives — not what the canvas shows. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import PixelPanel from "./PixelPanel"; import { useLabStore } from "@/lib/lab/store"; import type { PixelLayer } from "@/lib/lab/types"; @@ -90,6 +90,20 @@ describe("PixelPanel", () => { expect(rgba(1, 1)).toEqual(ORANGE); }); + it("undo still works after the matrix size changes", () => { + const { overlay, viewport } = mount(); + fireEvent.pointerDown(overlay, at(40, 20)); + fireEvent.pointerUp(overlay, at(40, 20)); + // 128×64 to 64×32 keeps the middle: (40, 20) becomes (8, 4) + act(() => useLabStore.getState().setMatrix({ width: 64, height: 32 })); + expect(pixelLayer().width).toBe(64); + expect(rgba(8, 4)).toEqual(ORANGE); + fireEvent.keyDown(viewport, { key: "z", ctrlKey: true }); + expect(rgba(8, 4)).toEqual(CLEAR); + fireEvent.keyDown(viewport, { key: "z", ctrlKey: true, shiftKey: true }); + expect(rgba(8, 4)).toEqual(ORANGE); + }); + it("tool keys switch tools: e erases, g fills, b is the pen again", () => { const { overlay, viewport } = mount(); fireEvent.keyDown(viewport, { key: "g" }); diff --git a/web/src/app/pattern-lab/panels/PixelPanel.tsx b/web/src/app/pattern-lab/panels/PixelPanel.tsx index 9968e055..a10a5e17 100644 --- a/web/src/app/pattern-lab/panels/PixelPanel.tsx +++ b/web/src/app/pattern-lab/panels/PixelPanel.tsx @@ -48,16 +48,16 @@ export default function PixelPanel() { // ── history ── const pushUndo = useCallback(() => { - if (pixel) snapshotPixels(pixel.id, pixel.data); + if (pixel) snapshotPixels(pixel.id, pixel); }, [pixel]); const commit = useCallback(() => { if (pixel) bumpPixelLayer(pixel.id); }, [pixel, bumpPixelLayer]); const undo = useCallback(() => { - if (pixel && undoPixels(pixel.id, pixel.data)) commit(); + if (pixel && undoPixels(pixel.id, pixel)) commit(); }, [pixel, commit]); const redo = useCallback(() => { - if (pixel && redoPixels(pixel.id, pixel.data)) commit(); + if (pixel && redoPixels(pixel.id, pixel)) commit(); }, [pixel, commit]); // ── the pieces ── diff --git a/web/src/lib/lab/pixelHistory.test.ts b/web/src/lib/lab/pixelHistory.test.ts new file mode 100644 index 00000000..e9c6f770 --- /dev/null +++ b/web/src/lib/lab/pixelHistory.test.ts @@ -0,0 +1,120 @@ +// Pixel undo frames across a matrix resize. Until 16 October 2026 a frame was a +// bare buffer, so undo after shrinking the matrix threw a RangeError from +// Uint8ClampedArray.set, and undo after growing it wrote the old rows across +// the new, wider ones. + +import { beforeEach, describe, expect, it } from "vitest"; +import { MAX_UNDO, prunePixelHistories, redoPixels, snapshotPixels, undoPixels } from "./pixelHistory"; +import { resizeSurface, type PixelSurface, type RGBA } from "./pixelTools"; + +const RED: RGBA = [255, 0, 0, 255]; +const BLUE: RGBA = [0, 0, 255, 255]; +const CLEAR: RGBA = [0, 0, 0, 0]; + +function blank(width: number, height: number): PixelSurface { + return { data: new Uint8ClampedArray(width * height * 4), width, height }; +} + +function paint(surface: PixelSurface, x: number, y: number, color: RGBA): void { + surface.data.set(color, (y * surface.width + x) * 4); +} + +function rgba(surface: PixelSurface, x: number, y: number): number[] { + const i = (y * surface.width + x) * 4; + return Array.from(surface.data.subarray(i, i + 4)); +} + +// What resizePixelLayers does to a layer: a new buffer, same id. +function resized(surface: PixelSurface, width: number, height: number): PixelSurface { + return { data: resizeSurface(surface.data, surface.width, surface.height, width, height), width, height }; +} + +beforeEach(() => prunePixelHistories([])); + +describe("pixel undo", () => { + it("undo and redo at one size", () => { + const layer = blank(3, 3); + snapshotPixels("a", layer); + paint(layer, 1, 1, RED); + expect(undoPixels("a", layer)).toBe(true); + expect(rgba(layer, 1, 1)).toEqual(CLEAR); + expect(redoPixels("a", layer)).toBe(true); + expect(rgba(layer, 1, 1)).toEqual(RED); + expect(redoPixels("a", layer)).toBe(false); + }); + + it("after the matrix shrinks, undo puts the frame back cropped to the new size", () => { + const layer = blank(4, 2); + paint(layer, 1, 0, RED); + snapshotPixels("a", layer); + paint(layer, 2, 1, BLUE); + // 4×2 to 2×2 keeps the middle two columns: x 1 becomes 0, x 2 becomes 1 + const small = resized(layer, 2, 2); + expect(rgba(small, 1, 1)).toEqual(BLUE); + + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(RED); + expect(rgba(small, 1, 1)).toEqual(CLEAR); + expect(redoPixels("a", small)).toBe(true); + expect(rgba(small, 1, 1)).toEqual(BLUE); + }); + + it("after the matrix grows, undo puts the old rows back centred", () => { + const layer = blank(2, 1); + paint(layer, 0, 0, RED); + paint(layer, 1, 0, BLUE); + snapshotPixels("a", layer); + paint(layer, 1, 0, RED); + // 2×1 to 4×3 pads one pixel on each side: the row lands at y 1, x 1 and 2 + const big = resized(layer, 4, 3); + + expect(undoPixels("a", big)).toBe(true); + const expected = blank(4, 3); + paint(expected, 1, 1, RED); + paint(expected, 2, 1, BLUE); + expect(Array.from(big.data)).toEqual(Array.from(expected.data)); + }); + + it("frames from both sides of a resize come back in order", () => { + const layer = blank(4, 2); + snapshotPixels("a", layer); + paint(layer, 1, 0, RED); + const small = resized(layer, 2, 2); + snapshotPixels("a", small); + paint(small, 1, 1, BLUE); + + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(RED); + expect(rgba(small, 1, 1)).toEqual(CLEAR); + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(CLEAR); + expect(undoPixels("a", small)).toBe(false); + }); + + it("redo after a resize between the undo and the redo", () => { + const layer = blank(4, 2); + snapshotPixels("a", layer); + paint(layer, 2, 1, BLUE); + undoPixels("a", layer); + const small = resized(layer, 2, 2); + + expect(redoPixels("a", small)).toBe(true); + expect(rgba(small, 1, 1)).toEqual(BLUE); + }); + + it(`keeps the last ${MAX_UNDO} frames`, () => { + const layer = blank(1, 1); + for (let i = 0; i <= MAX_UNDO; i++) snapshotPixels("a", layer); + for (let i = 0; i < MAX_UNDO; i++) expect(undoPixels("a", layer)).toBe(true); + expect(undoPixels("a", layer)).toBe(false); + }); + + it("prune drops the stacks of layers that are gone", () => { + const layer = blank(1, 1); + snapshotPixels("kept", layer); + snapshotPixels("gone", layer); + prunePixelHistories(["kept"]); + expect(undoPixels("gone", layer)).toBe(false); + expect(undoPixels("kept", layer)).toBe(true); + }); +}); diff --git a/web/src/lib/lab/pixelHistory.ts b/web/src/lib/lab/pixelHistory.ts index f7ab9361..6eb0c147 100644 --- a/web/src/lib/lab/pixelHistory.ts +++ b/web/src/lib/lab/pixelHistory.ts @@ -3,10 +3,17 @@ // React: buffers, not state. The Pixel panel calls snapshot() before a stroke, // undo()/redo() on the keys, and prune() with the live layer ids so a deleted // layer's stacks — up to MAX_UNDO frames of RGBA — do not live for the tab. +// +// A frame keeps the size it was taken at. Changing the matrix size gives the +// layer a new buffer under the same id (resizePixelLayers in store/shared.ts), +// so an older frame goes back through resizeSurface, centred and cropped or +// padded the way the resize treated the layer itself. + +import { resizeSurface, type PixelSurface } from "./pixelTools"; export const MAX_UNDO = 40; -type History = { undo: Uint8ClampedArray[]; redo: Uint8ClampedArray[] }; +type History = { undo: PixelSurface[]; redo: PixelSurface[] }; const histories = new Map(); @@ -19,31 +26,39 @@ function historyFor(id: string): History { return history; } -/** Remember the buffer as it is now, before it changes. Clears the redo stack. */ -export function snapshotPixels(id: string, data: Uint8ClampedArray): void { +function frameOf(surface: PixelSurface): PixelSurface { + return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height }; +} + +function restore(surface: PixelSurface, frame: PixelSurface): void { + surface.data.set(resizeSurface(frame.data, frame.width, frame.height, surface.width, surface.height)); +} + +/** Remember the surface as it is now, before it changes. Clears the redo stack. */ +export function snapshotPixels(id: string, surface: PixelSurface): void { const history = historyFor(id); - history.undo.push(new Uint8ClampedArray(data)); + history.undo.push(frameOf(surface)); if (history.undo.length > MAX_UNDO) history.undo.shift(); history.redo = []; } -/** Restore the previous snapshot INTO `data`. False when there is none. */ -export function undoPixels(id: string, data: Uint8ClampedArray): boolean { +/** Restore the previous snapshot INTO `surface.data`, at its size. False when there is none. */ +export function undoPixels(id: string, surface: PixelSurface): boolean { const history = historyFor(id); const previous = history.undo.pop(); if (!previous) return false; - history.redo.push(new Uint8ClampedArray(data)); - data.set(previous); + history.redo.push(frameOf(surface)); + restore(surface, previous); return true; } -/** Re-apply the last undone snapshot INTO `data`. False when there is none. */ -export function redoPixels(id: string, data: Uint8ClampedArray): boolean { +/** Re-apply the last undone snapshot INTO `surface.data`, at its size. False when there is none. */ +export function redoPixels(id: string, surface: PixelSurface): boolean { const history = historyFor(id); const next = history.redo.pop(); if (!next) return false; - history.undo.push(new Uint8ClampedArray(data)); - data.set(next); + history.undo.push(frameOf(surface)); + restore(surface, next); return true; }