mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||||
|
import { captureDocumentCanvas } from "@/components/editor/stage-capture";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
@@ -96,32 +97,24 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const fmtOpt = FORMAT_OPTIONS.find((o) => o.value === settings.format);
|
const fmtOpt = FORMAT_OPTIONS.find((o) => o.value === settings.format);
|
||||||
const previewMime = fmtOpt?.needsServerConvert ? "image/png" : getMimeType(settings.format);
|
const previewMime = fmtOpt?.needsServerConvert ? "image/png" : getMimeType(settings.format);
|
||||||
|
|
||||||
const url = stage.toDataURL({
|
const url = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height, scale).toDataURL(
|
||||||
pixelRatio: scale,
|
previewMime,
|
||||||
mimeType: previewMime,
|
settings.quality / 100,
|
||||||
quality: settings.quality / 100,
|
);
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
|
|
||||||
const pixelRatio = settings.width / canvasSize.width;
|
const pixelRatio = settings.width / canvasSize.width;
|
||||||
const fullUrl = stage.toDataURL({
|
const fullUrl = captureDocumentCanvas(
|
||||||
|
stage,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
mimeType: previewMime,
|
).toDataURL(previewMime, settings.quality / 100);
|
||||||
quality: settings.quality / 100,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
fetch(fullUrl)
|
fetch(fullUrl)
|
||||||
.then((res) => res.blob())
|
.then((res) => res.blob())
|
||||||
.then((blob) => setEstimatedSize(blob.size))
|
.then((blob) => setEstimatedSize(blob.size))
|
||||||
.catch(() => setEstimatedSize(null));
|
.catch(() => setEstimatedSize(null));
|
||||||
}, [canvasSize, settings.format, settings.quality, settings.width, settings.height]);
|
}, [canvasSize, settings.format, settings.quality, settings.width]);
|
||||||
|
|
||||||
// Generate preview thumbnail on format/transparency change
|
// Generate preview thumbnail on format/transparency change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,13 +167,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
if (formatOption?.needsServerConvert) {
|
if (formatOption?.needsServerConvert) {
|
||||||
let stageCanvas: HTMLCanvasElement;
|
let stageCanvas: HTMLCanvasElement;
|
||||||
if (!settings.transparent || settings.format === "jpeg") {
|
if (!settings.transparent || settings.format === "jpeg") {
|
||||||
const raw = stage.toCanvas({
|
const raw = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height, pixelRatio);
|
||||||
pixelRatio,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
const exportCanvas = document.createElement("canvas");
|
const exportCanvas = document.createElement("canvas");
|
||||||
exportCanvas.width = raw.width;
|
exportCanvas.width = raw.width;
|
||||||
exportCanvas.height = raw.height;
|
exportCanvas.height = raw.height;
|
||||||
@@ -191,13 +178,7 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
ctx.drawImage(raw, 0, 0);
|
ctx.drawImage(raw, 0, 0);
|
||||||
stageCanvas = exportCanvas;
|
stageCanvas = exportCanvas;
|
||||||
} else {
|
} else {
|
||||||
stageCanvas = stage.toCanvas({
|
stageCanvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height, pixelRatio);
|
||||||
pixelRatio,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stageCanvas.toBlob(async (blob) => {
|
stageCanvas.toBlob(async (blob) => {
|
||||||
@@ -235,13 +216,12 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
|
|
||||||
if (!settings.transparent || settings.format === "jpeg") {
|
if (!settings.transparent || settings.format === "jpeg") {
|
||||||
// Create canvas with white background for non-transparent exports
|
// Create canvas with white background for non-transparent exports
|
||||||
const stageCanvas = stage.toCanvas({
|
const stageCanvas = captureDocumentCanvas(
|
||||||
|
stage,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
x: 0,
|
);
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
const exportCanvas = document.createElement("canvas");
|
const exportCanvas = document.createElement("canvas");
|
||||||
exportCanvas.width = stageCanvas.width;
|
exportCanvas.width = stageCanvas.width;
|
||||||
exportCanvas.height = stageCanvas.height;
|
exportCanvas.height = stageCanvas.height;
|
||||||
@@ -255,15 +235,15 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
settings.format === "png" ? undefined : settings.quality / 100,
|
settings.format === "png" ? undefined : settings.quality / 100,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dataUrl = stage.toDataURL({
|
dataUrl = captureDocumentCanvas(
|
||||||
|
stage,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
mimeType: getMimeType(settings.format),
|
).toDataURL(
|
||||||
quality: settings.format === "png" ? undefined : settings.quality / 100,
|
getMimeType(settings.format),
|
||||||
x: 0,
|
settings.format === "png" ? undefined : settings.quality / 100,
|
||||||
y: 0,
|
);
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatOpt = FORMAT_OPTIONS.find((f) => f.value === settings.format);
|
const formatOpt = FORMAT_OPTIONS.find((f) => f.value === settings.format);
|
||||||
@@ -319,14 +299,12 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
|
||||||
const pixelRatio = settings.width / canvasSize.width;
|
const pixelRatio = settings.width / canvasSize.width;
|
||||||
const dataUrl = stage.toDataURL({
|
const dataUrl = captureDocumentCanvas(
|
||||||
|
stage,
|
||||||
|
canvasSize.width,
|
||||||
|
canvasSize.height,
|
||||||
pixelRatio,
|
pixelRatio,
|
||||||
mimeType: "image/png",
|
).toDataURL("image/png");
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(dataUrl);
|
const res = await fetch(dataUrl);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { SliderRow } from "@/components/editor/common/slider-row";
|
import { SliderRow } from "@/components/editor/common/slider-row";
|
||||||
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||||
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
|
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
|
||||||
|
import { captureDocumentCanvas } from "@/components/editor/stage-capture";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { AdjustmentValues } from "@/types/editor";
|
import type { AdjustmentValues } from "@/types/editor";
|
||||||
@@ -1046,13 +1047,7 @@ export function AdjustmentsPanel() {
|
|||||||
const stage = editorStageRefHolder.current;
|
const stage = editorStageRefHolder.current;
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
try {
|
try {
|
||||||
const canvas = stage.toCanvas({
|
const canvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// apps/web/src/components/editor/stage-capture.ts
|
||||||
|
import type Konva from "konva";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the editor document as a flat HTMLCanvasElement at document-pixel
|
||||||
|
* resolution, independent of the current zoom/pan.
|
||||||
|
*
|
||||||
|
* The Stage carries the editor's zoom/pan as a transform (scaleX/scaleY/x/y).
|
||||||
|
* `stage.toCanvas()` bakes that transform into its output, so the naive
|
||||||
|
* `stage.toCanvas({ x: 0, y: 0, width, height })` returns the *viewport* — the
|
||||||
|
* document scaled and offset by the current zoom/pan, clipped to the on-screen
|
||||||
|
* stage size — instead of the document in its own coordinate space (issue
|
||||||
|
* #259). Every pixel tool (fill, magic wand, clone stamp, eyedropper,
|
||||||
|
* dodge/burn, blur/sharpen/smudge) and the exporter need the document pixels,
|
||||||
|
* so we temporarily normalize the stage to the document size with an identity
|
||||||
|
* transform, render, capture, then restore.
|
||||||
|
*
|
||||||
|
* This runs synchronously inside the calling event handler, so the browser
|
||||||
|
* never paints the intermediate (resized) state — there is no visible flicker.
|
||||||
|
* The stage is always restored, even if the capture throws.
|
||||||
|
*/
|
||||||
|
export function captureDocumentCanvas(
|
||||||
|
stage: Konva.Stage,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
pixelRatio = 1,
|
||||||
|
): HTMLCanvasElement {
|
||||||
|
const prev = {
|
||||||
|
width: stage.width(),
|
||||||
|
height: stage.height(),
|
||||||
|
scaleX: stage.scaleX(),
|
||||||
|
scaleY: stage.scaleY(),
|
||||||
|
x: stage.x(),
|
||||||
|
y: stage.y(),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
stage.size({ width, height });
|
||||||
|
stage.scale({ x: 1, y: 1 });
|
||||||
|
stage.position({ x: 0, y: 0 });
|
||||||
|
stage.draw();
|
||||||
|
return stage.toCanvas({ pixelRatio, x: 0, y: 0, width, height });
|
||||||
|
} finally {
|
||||||
|
stage.size({ width: prev.width, height: prev.height });
|
||||||
|
stage.scale({ x: prev.scaleX, y: prev.scaleY });
|
||||||
|
stage.position({ x: prev.x, y: prev.y });
|
||||||
|
stage.draw();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useCallback, useRef } from "react";
|
|||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { CanvasObject } from "@/types/editor";
|
import type { CanvasObject } from "@/types/editor";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
interface StampState {
|
interface StampState {
|
||||||
objectId: string;
|
objectId: string;
|
||||||
@@ -55,14 +56,8 @@ export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>)
|
|||||||
|
|
||||||
if (!cloneSource) return;
|
if (!cloneSource) return;
|
||||||
|
|
||||||
// Capture a snapshot of the current stage pixels
|
// Capture a snapshot of the document pixels at document resolution.
|
||||||
const stageCanvas = stage.toCanvas({
|
const stageCanvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageCtx = stageCanvas.getContext("2d");
|
const stageCtx = stageCanvas.getContext("2d");
|
||||||
if (!stageCtx) return;
|
if (!stageCtx) return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useRef } from "react";
|
|||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { CanvasObject, ToolType } from "@/types/editor";
|
import type { CanvasObject, ToolType } from "@/types/editor";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
const DODGE_BURN_TOOLS = new Set<ToolType>(["dodge", "burn", "sponge"]);
|
const DODGE_BURN_TOOLS = new Set<ToolType>(["dodge", "burn", "sponge"]);
|
||||||
|
|
||||||
@@ -112,14 +113,8 @@ export function useDodgeBurnTool(stageRef: React.RefObject<Konva.Stage | null>)
|
|||||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||||
|
|
||||||
// Snapshot current stage pixels
|
// Snapshot the document pixels at document resolution.
|
||||||
const stageCanvas = stage.toCanvas({
|
const stageCanvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageCtx = stageCanvas.getContext("2d");
|
const stageCtx = stageCanvas.getContext("2d");
|
||||||
if (!stageCtx) return;
|
if (!stageCtx) return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type Konva from "konva";
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { SampleSize } from "../options/eyedropper-options";
|
import type { SampleSize } from "../options/eyedropper-options";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sample a single pixel or averaged region from a canvas context at (x, y).
|
* Sample a single pixel or averaged region from a canvas context at (x, y).
|
||||||
@@ -64,23 +65,15 @@ export function useEyedropperTool({
|
|||||||
const canvasCache = useRef<HTMLCanvasElement | null>(null);
|
const canvasCache = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export the visible stage to a flat canvas for pixel sampling.
|
* Export the document to a flat canvas at document resolution for pixel
|
||||||
* Uses explicit viewport options to get a consistent unzoomed canvas,
|
* sampling, ignoring the current zoom/pan transform.
|
||||||
* excluding zoom/pan transforms and device pixel ratio.
|
|
||||||
* Cached so repeated clicks during one drag don't re-export.
|
* Cached so repeated clicks during one drag don't re-export.
|
||||||
*/
|
*/
|
||||||
const getStageCanvas = useCallback((): HTMLCanvasElement | null => {
|
const getStageCanvas = useCallback((): HTMLCanvasElement | null => {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
if (!stage) return null;
|
if (!stage) return null;
|
||||||
|
|
||||||
// Specify explicit viewport to exclude zoom/pan transforms
|
const canvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
const canvas = stage.toCanvas({
|
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
canvasCache.current = canvas;
|
canvasCache.current = canvas;
|
||||||
return canvas;
|
return canvas;
|
||||||
}, [stageRef, canvasSize]);
|
}, [stageRef, canvasSize]);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback } from "react";
|
|||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { CanvasObject } from "@/types/editor";
|
import type { CanvasObject } from "@/types/editor";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
function colorDistance(
|
function colorDistance(
|
||||||
r1: number,
|
r1: number,
|
||||||
@@ -160,14 +161,8 @@ export function useFillTool(stageRef: React.RefObject<Konva.Stage | null>) {
|
|||||||
|
|
||||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||||
|
|
||||||
// Export the current stage to a canvas for pixel access
|
// Capture the document pixels at document resolution (ignoring zoom/pan).
|
||||||
const stageCanvas = stage.toCanvas({
|
const stageCanvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctx = stageCanvas.getContext("2d");
|
const ctx = stageCanvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useRef } from "react";
|
|||||||
import { generateId } from "@/lib/utils";
|
import { generateId } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { CanvasObject, ToolType } from "@/types/editor";
|
import type { CanvasObject, ToolType } from "@/types/editor";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]);
|
const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]);
|
||||||
|
|
||||||
@@ -40,14 +41,8 @@ export function usePixelBrushTool(stageRef: React.RefObject<Konva.Stage | null>)
|
|||||||
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
const y = Math.floor((pointer.y - panOffset.y) / zoom);
|
||||||
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
|
||||||
|
|
||||||
// Snapshot stage
|
// Snapshot the document pixels at document resolution.
|
||||||
const stageCanvas = stage.toCanvas({
|
const stageCanvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stageCtx = stageCanvas.getContext("2d");
|
const stageCtx = stageCanvas.getContext("2d");
|
||||||
if (!stageCtx) return;
|
if (!stageCtx) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { Ellipse, Group, Line, Rect, Shape } from "react-konva";
|
import { Ellipse, Group, Line, Rect, Shape } from "react-konva";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type { SelectionMode, SelectionState } from "@/types/editor";
|
import type { SelectionMode, SelectionState } from "@/types/editor";
|
||||||
|
import { captureDocumentCanvas } from "../stage-capture";
|
||||||
|
|
||||||
type SelectionType = "rect" | "ellipse" | "lasso";
|
type SelectionType = "rect" | "ellipse" | "lasso";
|
||||||
|
|
||||||
@@ -580,15 +581,8 @@ export function useSelectionTool(): SelectionToolApi {
|
|||||||
|
|
||||||
const magicWandSelect = useCallback(
|
const magicWandSelect = useCallback(
|
||||||
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
||||||
// Use explicit viewport options to get a consistent unzoomed canvas,
|
// Capture the document at document resolution, ignoring zoom/pan.
|
||||||
// ignoring zoom/pan transforms and device pixel ratio
|
const canvas = captureDocumentCanvas(stage, canvasSize.width, canvasSize.height);
|
||||||
const canvas = stage.toCanvas({
|
|
||||||
pixelRatio: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height,
|
|
||||||
});
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user