diff --git a/web/src/app/pattern-lab/panels/CodePanel.module.css b/web/src/app/pattern-lab/panels/CodePanel.module.css index ec8deb95d..bf68f3190 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.module.css +++ b/web/src/app/pattern-lab/panels/CodePanel.module.css @@ -27,3 +27,16 @@ button.promptButton { align-items: center; gap: 6px; } + +/* Under the bar while a typed @matrix line waits for the editor to lose + focus, or when the Lab cannot read it. */ +.matrixNote { + flex: 0 0 auto; + padding: 6px 8px; + border-bottom: 1px solid rgba(23, 21, 18, 0.12); + color: #756f64; + font-family: var(--font-inter), system-ui, sans-serif; + font-size: 12px; + line-height: 1.5; + text-wrap: pretty; +} diff --git a/web/src/app/pattern-lab/panels/CodePanel.test.tsx b/web/src/app/pattern-lab/panels/CodePanel.test.tsx index 30931f673..f31a0d203 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.test.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.test.tsx @@ -8,7 +8,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import CodePanel from "./CodePanel"; import { useLabStore } from "@/lib/lab/store"; -vi.mock("@monaco-editor/react", () => ({ default: () => })); +// A textarea standing in for Monaco: typing is onChange, and leaving it is the +// blur listener the panel registers in onMount. +vi.mock("@monaco-editor/react", async () => { + const { useEffect, useRef } = await import("react"); + type Props = { + value?: string; + onChange?: (value: string | undefined) => void; + onMount?: (editor: { onDidBlurEditorWidget: (listener: () => void) => void }) => void; + }; + return { + default: function Editor({ value, onChange, onMount }: Props) { + const blur = useRef<(() => void) | null>(null); + const mount = useRef(onMount); + useEffect(() => { + mount.current?.({ + onDidBlurEditorWidget: (listener) => { + blur.current = listener; + }, + }); + }, []); + return ( + onChange?.(event.target.value)} + onBlur={() => blur.current?.()} + /> + ); + }, + }; +}); function setClipboard(value: unknown) { Object.defineProperty(navigator, "clipboard", { configurable: true, value }); @@ -98,3 +128,45 @@ describe("CodePanel colour mode", () => { expect(writeText.mock.calls[1][0]).not.toContain("Do NOT call display.setPixel"); }); }); + +// The @matrix line, typed. Until 24 October 2026 each keystroke changed the +// frame, so typing 96 over 128 cropped every pixel layer to 12, then 9 +// columns (lib/lab/store/layers.test.ts has the numbers). +describe("CodePanel @matrix line", () => { + const BODY = "export function draw(display) {}\n"; + + it("changes the frame when focus leaves the editor, not while typing", () => { + render(); + const editor = screen.getByLabelText("Code"); + + fireEvent.change(editor, { target: { value: `// @matrix 9x64\n${BODY}` } }); + fireEvent.change(editor, { target: { value: `// @matrix 96x64\n${BODY}` } }); + expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 }); + expect(screen.getByRole("status").textContent).toBe("The frame becomes 96×64 when you leave the editor."); + + fireEvent.blur(editor); + expect(useLabStore.getState().matrix).toEqual({ width: 96, height: 64 }); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("says the frame stays when the line is out of range", () => { + render(); + const editor = screen.getByLabelText("Code"); + + fireEvent.change(editor, { target: { value: `// @matrix 1x64\n${BODY}` } }); + fireEvent.blur(editor); + expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 }); + expect(screen.getByRole("status").textContent).toBe( + "The @matrix line needs each side from 8 to 512. The frame stays 128×64.", + ); + }); + + it("takes up a held line when the panel closes", () => { + const { unmount } = render(); + fireEvent.change(screen.getByLabelText("Code"), { target: { value: `// @matrix 64x64\n${BODY}` } }); + expect(useLabStore.getState().matrix).toEqual({ width: 128, height: 64 }); + + unmount(); + expect(useLabStore.getState().matrix).toEqual({ width: 64, height: 64 }); + }); +}); diff --git a/web/src/app/pattern-lab/panels/CodePanel.tsx b/web/src/app/pattern-lab/panels/CodePanel.tsx index 695a4e314..dea2441f9 100644 --- a/web/src/app/pattern-lab/panels/CodePanel.tsx +++ b/web/src/app/pattern-lab/panels/CodePanel.tsx @@ -4,9 +4,17 @@ // single-pattern workflow: preset stepper, paste/clear, the AI variation // prompt with the colour model it asks for, and the code guide. -import Editor from "@monaco-editor/react"; -import { useState } from "react"; +import Editor, { type OnMount } from "@monaco-editor/react"; +import { useEffect, useRef, useState } from "react"; import { livePresets } from "@/lib/presets"; +import { + MATRIX_MAX, + MATRIX_MIN, + formatMatrix, + matchMatrixAnnotation, + matrixesEqual, + parseMatrixAnnotation, +} from "@/lib/pattern/matrix"; import { COLOR_MODES, type ColorMode } from "@/lib/ai/settings"; import { buildVariantCopyPrompt } from "@/lib/ai/variantPrompt"; import { useResettingState } from "@/lib/hooks/useResettingState"; @@ -28,6 +36,7 @@ export default function CodePanel() { const gen = useLabStore((state) => state.gen); const setGen = useLabStore((state) => state.setGen); const updateLayerCode = useLabStore((state) => state.updateLayerCode); + const applyMatrixLine = useLabStore((state) => state.applyMatrixLine); const applyCodeToActive = useLabStore((state) => state.applyCodeToActive); const addCodeLayer = useLabStore((state) => state.addCodeLayer); const error = useLabStore((state) => @@ -39,6 +48,21 @@ export default function CodePanel() { const [cleared, setCleared] = useResettingState(false, 1200); const [guideOpen, setGuideOpen] = useState(false); + // Typing holds the @matrix line (holdMatrix below). The frame follows it + // when focus leaves the editor, or the panel leaves the layer. + const codeLayerId = isCodeLayer(layer) ? layer.id : null; + const editingId = useRef(codeLayerId); + useEffect(() => { + editingId.current = codeLayerId; + if (!codeLayerId) return; + return () => applyMatrixLine(codeLayerId); + }, [codeLayerId, applyMatrixLine]); + const applyMatrixOnBlur: OnMount = (editor) => { + editor.onDidBlurEditorWidget(() => { + if (editingId.current) applyMatrixLine(editingId.current); + }); + }; + if (!isCodeLayer(layer)) { return ( @@ -56,6 +80,16 @@ export default function CodePanel() { const activeLabIndex = labPresets.findIndex((preset) => preset.code === layer.code); + const matrixLine = matchMatrixAnnotation(layer.code); + const lineFrame = parseMatrixAnnotation(layer.code); + const matrixNote = !matrixLine + ? null + : !lineFrame + ? `The @matrix line needs each side from ${MATRIX_MIN} to ${MATRIX_MAX}. The frame stays ${formatMatrix(matrix)}.` + : matrixLine !== layer.matrixAnnotationRaw && !matrixesEqual(lineFrame, matrix) + ? `The frame becomes ${formatMatrix(lineFrame)} when you leave the editor.` + : null; + const stepLabPreset = (dir: number) => { if (labPresets.length === 0) return; const base = activeLabIndex >= 0 ? activeLabIndex : dir > 0 ? -1 : 0; @@ -169,6 +203,11 @@ export default function CodePanel() { {error && {error}} + {matrixNote && ( + + {matrixNote} + + )} updateLayerCode(layer.id, value ?? "")} + onChange={(value) => updateLayerCode(layer.id, value ?? "", { holdMatrix: true })} + onMount={applyMatrixOnBlur} options={{ minimap: { enabled: false }, fontSize: 14, diff --git a/web/src/lib/lab/pixelHistory.test.ts b/web/src/lib/lab/pixelHistory.test.ts index e9c6f7701..7e143b250 100644 --- a/web/src/lib/lab/pixelHistory.test.ts +++ b/web/src/lib/lab/pixelHistory.test.ts @@ -118,3 +118,45 @@ describe("pixel undo", () => { expect(undoPixels("kept", layer)).toBe(true); }); }); + +// Since 24 October 2026 a frame change snapshots the layer before it crops or +// pads it (resizePixelLayers). Right after the change that snapshot is the +// picture as it is, so undo goes past it, and only past that kind. +describe("pixel undo across a frame change's snapshot", () => { + it("goes past one that changes nothing, to the stroke before", () => { + const layer = blank(4, 2); + snapshotPixels("a", layer); + paint(layer, 1, 0, RED); + snapshotPixels("a", layer, true); + const small = resized(layer, 2, 2); + + expect(undoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(CLEAR); + expect(redoPixels("a", small)).toBe(true); + expect(rgba(small, 0, 0)).toEqual(RED); + }); + + it("restores one that brings back what a smaller frame cut off", () => { + const layer = blank(4, 2); + paint(layer, 0, 0, RED); + snapshotPixels("a", layer, true); + const small = resized(layer, 2, 2); + snapshotPixels("a", small, true); + const grown = resized(small, 4, 2); + expect(rgba(grown, 0, 0)).toEqual(CLEAR); + + expect(undoPixels("a", grown)).toBe(true); + expect(rgba(grown, 0, 0)).toEqual(RED); + }); + + it("still takes a stroke's snapshot that changes nothing as a step", () => { + // Esc on a selection lifted from empty pixels undoes the lift, and only it. + const layer = blank(2, 2); + snapshotPixels("a", layer); + paint(layer, 1, 1, BLUE); + snapshotPixels("a", layer); + + expect(undoPixels("a", layer)).toBe(true); + expect(rgba(layer, 1, 1)).toEqual(BLUE); + }); +}); diff --git a/web/src/lib/lab/pixelHistory.ts b/web/src/lib/lab/pixelHistory.ts index 6eb0c1470..4ab9a8462 100644 --- a/web/src/lib/lab/pixelHistory.ts +++ b/web/src/lib/lab/pixelHistory.ts @@ -3,6 +3,10 @@ // 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 change calls snapshot() too, before it crops or pads the layer. +// Right after the change that snapshot, put back at the new size, is the +// picture as it is, so undo goes past it to the step before. It counts once +// the frame grows back and it brings in what the smaller frame cut off. // // 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), @@ -13,7 +17,8 @@ import { resizeSurface, type PixelSurface } from "./pixelTools"; export const MAX_UNDO = 40; -type History = { undo: PixelSurface[]; redo: PixelSurface[] }; +type Frame = PixelSurface & { frameChange: boolean }; +type History = { undo: Frame[]; redo: Frame[] }; const histories = new Map(); @@ -26,26 +31,39 @@ function historyFor(id: string): History { return history; } -function frameOf(surface: PixelSurface): PixelSurface { - return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height }; +function frameOf(surface: PixelSurface, frameChange = false): Frame { + return { data: new Uint8ClampedArray(surface.data), width: surface.width, height: surface.height, frameChange }; } 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 { +function showsTheSame(frame: PixelSurface, surface: PixelSurface): boolean { + const data = resizeSurface(frame.data, frame.width, frame.height, surface.width, surface.height); + for (let i = 0; i < data.length; i++) if (data[i] !== surface.data[i]) return false; + return true; +} + +/** + * Remember the surface as it is now, before it changes. Clears the redo stack. + * `frameChange` marks the snapshot a matrix resize takes. + */ +export function snapshotPixels(id: string, surface: PixelSurface, frameChange = false): void { const history = historyFor(id); - history.undo.push(frameOf(surface)); + history.undo.push(frameOf(surface, frameChange)); if (history.undo.length > MAX_UNDO) history.undo.shift(); history.redo = []; } -/** Restore the previous snapshot INTO `surface.data`, at its size. False when there is none. */ +/** + * Restore the previous snapshot INTO `surface.data`, at its size, passing + * over a frame change's that would change nothing. False when there is none. + */ export function undoPixels(id: string, surface: PixelSurface): boolean { const history = historyFor(id); - const previous = history.undo.pop(); + let previous = history.undo.pop(); + while (previous?.frameChange && showsTheSame(previous, surface)) previous = history.undo.pop(); if (!previous) return false; history.redo.push(frameOf(surface)); restore(surface, previous); diff --git a/web/src/lib/lab/store.ts b/web/src/lib/lab/store.ts index e298c83ee..90cda4bc2 100644 --- a/web/src/lib/lab/store.ts +++ b/web/src/lib/lab/store.ts @@ -69,7 +69,14 @@ export type LabStore = LabProject & { setLayerRole: (id: string, role: LayerRole) => void; setLayerMaskInvert: (id: string, maskInvert: boolean) => void; - updateLayerCode: (id: string, code: string) => void; + /** + * Replace a code layer's code. A changed @matrix line it can read changes + * the frame; with holdMatrix (the Code panel's editor, keystroke by + * keystroke) the line waits for applyMatrixLine. + */ + updateLayerCode: (id: string, code: string, options?: { holdMatrix?: boolean }) => void; + /** Take up the @matrix line the editor held: focus has left it. */ + applyMatrixLine: (id: string) => void; applyCodeToActive: (code: string) => void; /** Immutable edit of the Director show (timeline authoring). */ updateDirector: (update: (show: DirectorShow) => DirectorShow) => void; diff --git a/web/src/lib/lab/store/layers.test.ts b/web/src/lib/lab/store/layers.test.ts new file mode 100644 index 000000000..cebb4e2e9 --- /dev/null +++ b/web/src/lib/lab/store/layers.test.ts @@ -0,0 +1,141 @@ +// The @matrix line in a code layer's code. Until 24 October 2026 the Lab +// changed the frame on every keystroke in the editor. Typing 96 over the 128 +// of "// @matrix 128x64" passed through 12x64, 1x64 (unreadable, so back to +// 128x64) and 9x64, and each step cropped the pixel and text layers to that +// width: a pixel layer painted edge to edge kept 576 of its 8,192 cells, and +// the text moved to x 47 instead of 48. Now the editor holds the line until +// focus leaves it, a line the Lab cannot read leaves the frame alone, and a +// frame change puts each pixel layer on its undo stack first. + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Each character lights a 2×2 block centred on spec.x, as in text.test.ts. +vi.mock("../textRaster", () => ({ + textCoverage: vi.fn((spec: { text: string; x: number; y: number }, _font: string, width: number, height: number) => { + const coverage = new Uint8ClampedArray(width * height); + const lineWidth = spec.text.length * 2; + const left = spec.x - lineWidth / 2; + for (let y = spec.y - 1; y < spec.y + 1; y++) { + for (let x = left; x < left + lineWidth; x++) { + if (x >= 0 && x < width && y >= 0 && y < height) coverage[y * width + x] = 255; + } + } + return coverage; + }), +})); + +import { prunePixelHistories, undoPixels } from "../pixelHistory"; +import { useLabStore } from "../store"; +import type { PixelLayer, TextLayer } from "../types"; + +const store = () => useLabStore.getState(); +const BODY = "export function draw(display) {}\n"; +const withLine = (line: string) => `${line}\n${BODY}`; + +// Selecting the 128 and typing 96, one keystroke at a time. +const TYPING = ["// @matrix 12x64", "// @matrix 1x64", "// @matrix x64", "// @matrix 9x64", "// @matrix 96x64"]; + +function lit(layer: { data: Uint8ClampedArray }): number { + let count = 0; + for (let i = 3; i < layer.data.length; i += 4) if (layer.data[i] > 0) count++; + return count; +} + +function codeId(): string { + const layer = store().layers.find((entry) => entry.type === "code"); + if (!layer) throw new Error("no code layer"); + return layer.id; +} + +function pixel(): PixelLayer { + const layer = store().layers.find((entry) => entry.type === "pixel"); + if (layer?.type !== "pixel") throw new Error("no pixel layer"); + return layer; +} + +function text(): TextLayer { + const layer = store().layers.find((entry) => entry.type === "text"); + if (layer?.type !== "text") throw new Error("no text layer"); + return layer; +} + +beforeEach(() => { + prunePixelHistories([]); + store().discardProject(); + store().updateLayerCode(codeId(), withLine("// @matrix 128x64")); + store().addPixelLayer(); + store().replacePixelData(pixel().id, new Uint8ClampedArray(128 * 64 * 4).fill(255)); + store().addTextLayer(); + // 11 characters, 22 pixels wide: columns 53 to 74 on rows 31 and 32. + store().setTextSpec(text().id, { text: "Patternflow", x: 64, y: 32 }); +}); + +describe("the @matrix line while typing", () => { + it("holds the frame through every keystroke and applies 96x64 once the editor is left", () => { + expect(lit(pixel())).toBe(8192); + expect(lit(text())).toBe(44); + + for (const line of TYPING) { + store().updateLayerCode(codeId(), withLine(line), { holdMatrix: true }); + expect(store().matrix).toEqual({ width: 128, height: 64 }); + expect(lit(pixel())).toBe(8192); + } + + store().applyMatrixLine(codeId()); + expect(store().matrix).toEqual({ width: 96, height: 64 }); + expect(lit(pixel())).toBe(96 * 64); + expect(text().spec.x).toBe(48); + expect(lit(text())).toBe(44); + + // Leaving the editor again with the same line changes nothing. + store().applyMatrixLine(codeId()); + expect(store().matrix).toEqual({ width: 96, height: 64 }); + }); + + it("keeps a frame picked in Preview when the editor is left with the line as it was", () => { + store().setMatrix({ width: 64, height: 64 }); + store().updateLayerCode(codeId(), `${withLine("// @matrix 128x64")}// tuned\n`, { holdMatrix: true }); + store().applyMatrixLine(codeId()); + expect(store().matrix).toEqual({ width: 64, height: 64 }); + }); +}); + +describe("a line the Lab cannot read", () => { + it("leaves the frame as it is, pasted or left in the editor", () => { + store().updateLayerCode(codeId(), withLine("// @matrix 96x64")); + expect(store().matrix).toEqual({ width: 96, height: 64 }); + + for (const line of ["// @matrix 1x64", "// @matrix 600x64", "// matrix gone"]) { + store().updateLayerCode(codeId(), withLine(line)); + expect(store().matrix).toEqual({ width: 96, height: 64 }); + expect(lit(pixel())).toBe(96 * 64); + } + + store().updateLayerCode(codeId(), withLine("// @matrix 4x64"), { holdMatrix: true }); + store().applyMatrixLine(codeId()); + expect(store().matrix).toEqual({ width: 96, height: 64 }); + expect(text().spec.x).toBe(48); + }); +}); + +describe("a frame change and pixel undo", () => { + it("brings back the columns a narrower @matrix line cut off", () => { + store().updateLayerCode(codeId(), withLine("// @matrix 12x64")); + store().updateLayerCode(codeId(), withLine("// @matrix 128x64")); + expect(lit(pixel())).toBe(12 * 64); + + // The frame from before 12x64 grew back to 128x64 changes nothing, so + // one undo goes past it. + expect(undoPixels(pixel().id, pixel())).toBe(true); + expect(lit(pixel())).toBe(8192); + }); + + it("brings back the columns a narrower frame from Preview cut off", () => { + store().setMatrix({ width: 12, height: 64 }); + store().setMatrix({ width: 128, height: 64 }); + expect(lit(pixel())).toBe(12 * 64); + + expect(undoPixels(pixel().id, pixel())).toBe(true); + expect(lit(pixel())).toBe(8192); + }); +}); diff --git a/web/src/lib/lab/store/layers.ts b/web/src/lib/lab/store/layers.ts index 547df1f92..194147c15 100644 --- a/web/src/lib/lab/store/layers.ts +++ b/web/src/lib/lab/store/layers.ts @@ -2,7 +2,7 @@ // One of the slices store.ts composes. Bodies are unchanged from the single // 800-line store they came out of; only the file boundary is new. -import { DEFAULT_MATRIX, matrixesEqual, matchMatrixAnnotation, parseMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; +import { matrixesEqual, matchMatrixAnnotation, parseMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; import { parseRampAnnotation, stripRampAnnotation } from "@/lib/pattern/ramp"; import { applyKnobEntries, matchKnobsAnnotation, parseKnobsAnnotation } from "../annotations"; import { cloneRampState, createPixelLayer, isCodeLayer, layerId, nextLayerName, type LabProject, type Layer } from "../types"; @@ -10,7 +10,7 @@ import type { LabStore } from "../store"; import type { SliceSet, SliceGet } from "./kinds"; import { codeLayerFromSource, resizePixelLayers, updateLayer, updateCodeLayer, STARTER_CODE } from "./shared"; -export type LayersSlice = Pick; +export type LayersSlice = Pick; export function createLayersSlice(set: SliceSet, get: SliceGet): LayersSlice { return { @@ -146,7 +146,7 @@ export function createLayersSlice(set: SliceSet, get: SliceGet): LayersSlice { layers: updateLayer(state.layers, id, (layer) => ({ ...layer, maskInvert })), })), - updateLayerCode: (id, code) => { + updateLayerCode: (id, code, options) => { const state = get(); const layer = state.layers.find((entry) => entry.id === id); if (!isCodeLayer(layer)) return; @@ -171,21 +171,33 @@ export function createLayersSlice(set: SliceSet, get: SliceGet): LayersSlice { } } - let matrixPatch: { matrix: MatrixSize } | null = null; + // The editor holds the @matrix line while it has focus (applyMatrixLine + // takes it from there), so the frame changes once and not at each + // keystroke on the way from 128x64 to 96x64. + const hold = options?.holdMatrix === true; const matrixRaw = matchMatrixAnnotation(code); - if (matrixRaw !== layer.matrixAnnotationRaw) { - const parsed = parseMatrixAnnotation(code) ?? DEFAULT_MATRIX; - if (!matrixesEqual(parsed, state.matrix)) matrixPatch = { matrix: parsed }; - } + const matrix = hold ? null : frameFromLine(layer.matrixAnnotationRaw, code, state.matrix); let layers = updateCodeLayer(state.layers, id, (entry) => ({ ...entry, code, knobsAnnotationRaw: knobsRaw, - matrixAnnotationRaw: matrixRaw, + matrixAnnotationRaw: hold ? entry.matrixAnnotationRaw : matrixRaw, })); - if (matrixPatch) layers = resizePixelLayers(layers, matrixPatch.matrix); - set({ layers, ...knobPatch, ...(matrixPatch ?? {}) }); + if (matrix) layers = resizePixelLayers(layers, matrix); + set({ layers, ...knobPatch, ...(matrix ? { matrix } : {}) }); + }, + + applyMatrixLine: (id) => { + const state = get(); + const layer = state.layers.find((entry) => entry.id === id); + if (!isCodeLayer(layer)) return; + const matrixRaw = matchMatrixAnnotation(layer.code); + if (matrixRaw === layer.matrixAnnotationRaw) return; + const matrix = frameFromLine(layer.matrixAnnotationRaw, layer.code, state.matrix); + let layers = updateCodeLayer(state.layers, id, (entry) => ({ ...entry, matrixAnnotationRaw: matrixRaw })); + if (matrix) layers = resizePixelLayers(layers, matrix); + set({ layers, ...(matrix ? { matrix } : {}) }); }, applyCodeToActive: (code) => { @@ -242,3 +254,14 @@ export function createLayersSlice(set: SliceSet, get: SliceGet): LayersSlice { }, }; } + +/** + * The frame a changed @matrix line asks for, or null to keep the frame there + * is. A line the Lab cannot read (1x64, 600x64) or no line at all is not a + * frame, and the pixel and text layers stay as they are. + */ +function frameFromLine(appliedRaw: string | null, code: string, current: MatrixSize): MatrixSize | null { + if (matchMatrixAnnotation(code) === appliedRaw) return null; + const parsed = parseMatrixAnnotation(code); + return parsed && !matrixesEqual(parsed, current) ? parsed : null; +} diff --git a/web/src/lib/lab/store/shared.ts b/web/src/lib/lab/store/shared.ts index c1aa34d0f..d5dc44eb9 100644 --- a/web/src/lib/lab/store/shared.ts +++ b/web/src/lib/lab/store/shared.ts @@ -1,13 +1,15 @@ // ── Lab store: helpers the slices share ────────────────────────────────────── // Pure functions over project state — building a layer from source, the // default project, resizing pixel layers to a new matrix — that more than -// one slice needs. No store access here; the slices do that. +// one slice needs. No store access here; the slices do that. The one thing +// outside the state a resize touches is the Pixel panel's undo stack. import { DEFAULT_MATRIX, matchMatrixAnnotation, parseMatrixAnnotation, type MatrixSize } from "@/lib/pattern/matrix"; import { parseRampAnnotation, stripRampAnnotation } from "@/lib/pattern/ramp"; import { livePresets } from "@/lib/presets"; import { applyKnobEntries, defaultKnobState, matchKnobsAnnotation, parseKnobsAnnotation, type KnobAnnotationEntry } from "../annotations"; import { emptyShow } from "../director/types"; +import { snapshotPixels } from "../pixelHistory"; import { resizeSurface } from "../pixelTools"; import { drawTextLayer } from "../text"; import { DEFAULT_RAMP_STATE, cloneRampState, isRampLayer, layerId, type CodeLayer, type LabProject, type Layer, type RampLayer, type RampState, type RasterLayer } from "../types"; @@ -116,12 +118,14 @@ export function defaultProject(): LabProject { /** * Bitmap layers re-centred on a new frame. A text layer's bitmap moves with * the rest and its place moves by the same offset, so the next time it is - * drawn it lands where it already is. + * drawn it lands where it already is. A pixel layer goes on its undo stack + * first, so Undo in the Pixel panel brings back what a smaller frame cut off. */ export function resizePixelLayers(layers: Layer[], matrix: MatrixSize): Layer[] { return layers.map((layer) => { if (layer.type === "code") return layer; if (layer.width === matrix.width && layer.height === matrix.height) return layer; + if (layer.type === "pixel") snapshotPixels(layer.id, layer, true); const data = resizeSurface(layer.data, layer.width, layer.height, matrix.width, matrix.height); const resized: RasterLayer = { ...layer,