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
16 changes: 15 additions & 1 deletion web/src/app/pattern-lab/panels/PixelPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
6 changes: 3 additions & 3 deletions web/src/app/pattern-lab/panels/PixelPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
120 changes: 120 additions & 0 deletions web/src/lib/lab/pixelHistory.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 27 additions & 12 deletions web/src/lib/lab/pixelHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, History>();

Expand All @@ -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;
}

Expand Down
Loading