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
81 changes: 81 additions & 0 deletions web/src/app/pattern-lab/panels/LayersPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Del in the layer stack. There is no undo for it, so a layer with work in it
// asks first; a blank one goes straight away.

import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import LayersPanel from "./LayersPanel";
import { useLabStore } from "@/lib/lab/store";

const store = () => useLabStore.getState();
const names = () => store().layers.map((layer) => layer.name);
const del = () => screen.getByRole("button", { name: "Del" });

function drawOnePixel() {
const layer = store().layers.find((entry) => entry.id === store().activeLayerId);
if (!layer || layer.type !== "pixel") throw new Error("active layer is not pixel");
const data = new Uint8ClampedArray(layer.data);
data.set([255, 77, 0, 255], 0);
store().replacePixelData(layer.id, data);
}

beforeEach(() => {
store().discardProject();
});

describe("LayersPanel Del", () => {
it("is disabled while the stack has one layer", () => {
render(<LayersPanel />);
expect(del()).toBeDisabled();
});

it("deletes a pixel layer nobody has drawn on without asking", () => {
store().addPixelLayer();
const confirm = vi.spyOn(window, "confirm");
render(<LayersPanel />);
fireEvent.click(del());
expect(confirm).not.toHaveBeenCalled();
expect(names()).toEqual(["Code 1"]);
});

it("deletes a code layer still holding the starter pattern without asking", () => {
store().addCodeLayer();
const confirm = vi.spyOn(window, "confirm");
render(<LayersPanel />);
fireEvent.click(del());
expect(confirm).not.toHaveBeenCalled();
expect(names()).toEqual(["Code 1"]);
});

it("asks before deleting a drawn pixel layer, and Cancel keeps it", () => {
store().addPixelLayer();
drawOnePixel();
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<LayersPanel />);
fireEvent.click(del());
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm.mock.calls[0][0]).toContain('"Pixel 1"');
expect(names()).toEqual(["Pixel 1", "Code 1"]);
const kept = store().layers[0];
expect(kept.type === "pixel" ? kept.data[3] : -1).toBe(255);
});

it("deletes the drawn pixel layer once confirmed", () => {
store().addPixelLayer();
drawOnePixel();
vi.spyOn(window, "confirm").mockReturnValue(true);
render(<LayersPanel />);
fireEvent.click(del());
expect(names()).toEqual(["Code 1"]);
expect(store().activeLayerId).toBe(store().layers[0].id);
});

it("asks before deleting a code layer whose code was changed", () => {
store().addPixelLayer();
store().selectLayer(store().layers[1].id);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<LayersPanel />);
fireEvent.click(del());
expect(confirm.mock.calls[0][0]).toContain('"Code 1"');
expect(names()).toEqual(["Pixel 1", "Code 1"]);
});
});
26 changes: 24 additions & 2 deletions web/src/app/pattern-lab/panels/LayersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@
// The active layer's opacity + blend live at the bottom of the panel.

import { useState } from "react";
import { BLEND_MODES } from "@/lib/lab/types";
import { BLEND_MODES, type Layer } from "@/lib/lab/types";
import { revealEditor } from "@/lib/lab/editorReveal";
import { useLabStore } from "@/lib/lab/store";
import { STARTER_CODE } from "@/lib/lab/store/shared";
import dock from "../LabPanels.module.css";

// A pixel layer nobody has drawn on, or a code layer still holding the
// starter pattern, has nothing to lose, so Del takes it without asking.
function holdsWork(layer: Layer): boolean {
if (layer.type === "code") return layer.code !== STARTER_CODE;
for (let i = 3; i < layer.data.length; i += 4) {
if (layer.data[i] !== 0) return true;
}
return false;
}

export default function LayersPanel() {
const layers = useLabStore((state) => state.layers);
const activeLayerId = useLabStore((state) => state.activeLayerId);
Expand Down Expand Up @@ -77,7 +88,18 @@ export default function LayersPanel() {
</button>
<button
type="button"
onClick={() => active && removeLayer(active.id)}
onClick={() => {
if (!active) return;
// Deleting has no undo, and autosave writes the smaller stack
// over the saved project within 3 s.
if (
holdsWork(active) &&
!window.confirm(`Delete the layer "${active.name}"? It can't be brought back.`)
) {
return;
}
removeLayer(active.id);
}}
disabled={!active || layers.length <= 1}
title={layers.length <= 1 ? "The stack needs at least one layer" : "Delete the selected layer"}
>
Expand Down
Loading