Files
SnapOtter/tests/e2e-editor/editor-tool-coordinates.spec.ts
T
SnapOtter 81e16d7ce6 fix(editor): capture document pixels without the zoom/pan transform (#259)
Every editor tool that reads or writes raster pixels exported the stage with `stage.toCanvas({ x: 0, y: 0, width, height })`, which bakes in the stage's zoom/pan transform. The captured buffer was the *viewport* (the document scaled and offset by the current zoom/pan), not the document in its own coordinate space, so tools sampled and wrote the wrong pixels: the paint bucket produced a misplaced black rectangle instead of flood-filling the click, the eyedropper read the wrong colour, the magic wand selected the wrong region, and PNG/clipboard export silently produced a scaled/offset image at any zoom other than 100%.

Add `captureDocumentCanvas()`, which normalizes the stage to the document size with an identity transform, renders, captures, and restores -- all synchronously, so there is no visible flicker. Route every pixel capture through it: fill, magic wand, clone stamp, eyedropper, dodge/burn, blur/sharpen/smudge, the adjustments histogram, and the exporter.

The 'rulers render as black bars' part of #259 was fixed in the preceding editor-layout change (#258).

Adds editor-tool-coordinates.spec.ts asserting the paint bucket fills at the clicked location.
2026-06-17 14:21:35 +08:00

58 lines
2.4 KiB
TypeScript

import { expect, loadTestImage, test } from "./helpers";
// Regression coverage for issue #259: editor pixel tools captured the document
// *through* the stage's zoom/pan transform (`stage.toCanvas`), so they read and
// wrote the wrong pixels — the paint bucket produced a misplaced rectangle
// instead of flood-filling the clicked region, the eyedropper sampled the wrong
// colour, etc. The shared `captureDocumentCanvas` helper now normalizes the
// transform before capturing.
test.describe("Editor tool coordinates (issue #259)", () => {
test("paint bucket fills at the clicked location, not a misplaced rectangle", async ({
editorPage: page,
}) => {
await loadTestImage(page);
await page.waitForTimeout(500);
await page.locator('[data-tool="fill"]').click();
await page.waitForTimeout(200);
// The first canvas inside the editor is the main content layer.
const canvas = page.locator('[data-testid="editor-canvas"] canvas').first();
const box = await canvas.boundingBox();
if (!box) throw new Error("editor canvas has no bounding box");
const clickX = box.x + box.width / 2;
const clickY = box.y + box.height / 2;
await page.mouse.click(clickX, clickY);
await page.waitForTimeout(400);
// The foreground defaults to black and a flood fill always recolours its
// seed pixel, so the on-screen pixel under the click must end up (near)
// black. Before the fix the fill landed elsewhere and the click point kept
// the original image colour.
const pixel = await page.evaluate(
({ x, y }) => {
const el = document.querySelector<HTMLCanvasElement>(
'[data-testid="editor-canvas"] canvas',
);
if (!el) return null;
const rect = el.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const px = Math.round((x - rect.left) * dpr);
const py = Math.round((y - rect.top) * dpr);
const ctx = el.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
const d = ctx.getImageData(px, py, 1, 1).data;
return { r: d[0], g: d[1], b: d[2], a: d[3] };
},
{ x: clickX, y: clickY },
);
expect(pixel).not.toBeNull();
expect(pixel?.a ?? 0).toBeGreaterThan(200); // opaque — the seed was filled
expect(pixel?.r ?? 255).toBeLessThan(50);
expect(pixel?.g ?? 255).toBeLessThan(50);
expect(pixel?.b ?? 255).toBeLessThan(50);
});
});