mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Store: loadImage resets state, applyCrop updates sourceImageSize, setTool clears stale cropState, layer ordering preserved across layers, rotateCanvas/flipCanvas account for object dimensions, trimCanvas implemented, resizeCanvas supports anchor positioning - Canvas: crop overlay interactive, move tool drag events wired, hand tool panning, stage ref effect mount-only, checkerboard tracks pan offset, filter cache clearing - Panels: hslToHex color fix for hue 240-360, adjustments single setAdjustment call, curves draggingIndex after sort, history panel fresh temporal reads, export transparent setting, canvas resize dialog re-sync, hex input respects picker target - Tools: text tool fixed positioning and event cleanup, shape tool deferred addObject, selection mode wired to store, magic wand tolerance controlled, eyedropper coordinate transform - Options: dodge-burn/shape/clone/fill/gradient/pixel-brush use proper store actions instead of raw setState - Added 20+ store actions for tool settings - Updated tests for corrected rotation/flip/trim behavior - Excluded e2e-editor from vitest config
565 lines
16 KiB
TypeScript
565 lines
16 KiB
TypeScript
import type Konva from "konva";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Ellipse, Group, Line, Rect } from "react-konva";
|
|
import { useEditorStore } from "@/stores/editor-store";
|
|
import type { SelectionMode, SelectionState } from "@/types/editor";
|
|
|
|
type SelectionType = "rect" | "ellipse" | "lasso";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Marching ants animation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DASH = [6, 4];
|
|
const MARCH_SPEED = 1;
|
|
|
|
function useMarchingAnts(layerRef: React.RefObject<Konva.Layer | null>) {
|
|
const dashOffsetRef = useRef(0);
|
|
const animRef = useRef<Konva.Animation | null>(null);
|
|
|
|
useEffect(() => {
|
|
const layer = layerRef.current;
|
|
if (!layer) return;
|
|
|
|
// Dynamically import Konva for Animation
|
|
import("konva").then((KonvaModule) => {
|
|
const anim = new KonvaModule.default.Animation(() => {
|
|
dashOffsetRef.current -= MARCH_SPEED;
|
|
}, layer);
|
|
animRef.current = anim;
|
|
anim.start();
|
|
});
|
|
|
|
return () => {
|
|
animRef.current?.stop();
|
|
};
|
|
}, [layerRef]);
|
|
|
|
return dashOffsetRef;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Magic wand -- flood fill to generate selection mask
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function floodFillMask(
|
|
imageData: ImageData,
|
|
startX: number,
|
|
startY: number,
|
|
tolerance: number,
|
|
contiguous: boolean,
|
|
): boolean[][] {
|
|
const { width, height, data } = imageData;
|
|
const mask: boolean[][] = Array.from(
|
|
{ length: height },
|
|
() => Array(width).fill(false) as boolean[],
|
|
);
|
|
|
|
const sx = Math.round(startX);
|
|
const sy = Math.round(startY);
|
|
if (sx < 0 || sx >= width || sy < 0 || sy >= height) return mask;
|
|
|
|
const idx = (sy * width + sx) * 4;
|
|
const targetR = data[idx];
|
|
const targetG = data[idx + 1];
|
|
const targetB = data[idx + 2];
|
|
|
|
function colorDist(i: number): number {
|
|
const dr = data[i] - targetR;
|
|
const dg = data[i + 1] - targetG;
|
|
const db = data[i + 2] - targetB;
|
|
return Math.sqrt(dr * dr + dg * dg + db * db);
|
|
}
|
|
|
|
if (contiguous) {
|
|
// Scanline flood fill
|
|
const stack: [number, number][] = [[sx, sy]];
|
|
while (stack.length > 0) {
|
|
const item = stack.pop();
|
|
if (!item) break;
|
|
const [cx, cy] = item;
|
|
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
|
if (mask[cy][cx]) continue;
|
|
const ci = (cy * width + cx) * 4;
|
|
if (colorDist(ci) > tolerance) continue;
|
|
mask[cy][cx] = true;
|
|
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
|
}
|
|
} else {
|
|
// Select all matching pixels
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const ci = (y * width + x) * 4;
|
|
if (colorDist(ci) <= tolerance) {
|
|
mask[y][x] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return mask;
|
|
}
|
|
|
|
function maskToBounds(
|
|
mask: boolean[][],
|
|
): { x: number; y: number; width: number; height: number } | null {
|
|
let minX = Number.POSITIVE_INFINITY;
|
|
let minY = Number.POSITIVE_INFINITY;
|
|
let maxX = Number.NEGATIVE_INFINITY;
|
|
let maxY = Number.NEGATIVE_INFINITY;
|
|
let found = false;
|
|
|
|
for (let y = 0; y < mask.length; y++) {
|
|
for (let x = 0; x < mask[y].length; x++) {
|
|
if (mask[y][x]) {
|
|
found = true;
|
|
minX = Math.min(minX, x);
|
|
minY = Math.min(minY, y);
|
|
maxX = Math.max(maxX, x);
|
|
maxY = Math.max(maxY, y);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!found) return null;
|
|
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Selection mask modification utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function expandMask(mask: boolean[][], amount: number): boolean[][] {
|
|
const h = mask.length;
|
|
const w = mask[0]?.length ?? 0;
|
|
const result: boolean[][] = Array.from({ length: h }, () => Array(w).fill(false) as boolean[]);
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
if (!mask[y][x]) continue;
|
|
for (let dy = -amount; dy <= amount; dy++) {
|
|
for (let dx = -amount; dx <= amount; dx++) {
|
|
const ny = y + dy;
|
|
const nx = x + dx;
|
|
if (ny >= 0 && ny < h && nx >= 0 && nx < w) {
|
|
if (dx * dx + dy * dy <= amount * amount) {
|
|
result[ny][nx] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function contractMask(mask: boolean[][], amount: number): boolean[][] {
|
|
const inverted = mask.map((row) => row.map((v) => !v));
|
|
const expanded = expandMask(inverted, amount);
|
|
return expanded.map((row) => row.map((v) => !v));
|
|
}
|
|
|
|
export function featherMask(mask: boolean[][], radius: number): number[][] {
|
|
const h = mask.length;
|
|
const w = mask[0]?.length ?? 0;
|
|
const result: number[][] = Array.from({ length: h }, () => Array(w).fill(0) as number[]);
|
|
|
|
for (let y = 0; y < h; y++) {
|
|
for (let x = 0; x < w; x++) {
|
|
if (mask[y][x]) {
|
|
result[y][x] = 1;
|
|
continue;
|
|
}
|
|
// Distance to nearest mask pixel within radius
|
|
let minDist = radius + 1;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
const ny = y + dy;
|
|
const nx = x + dx;
|
|
if (ny >= 0 && ny < h && nx >= 0 && nx < w && mask[ny][nx]) {
|
|
const d = Math.sqrt(dx * dx + dy * dy);
|
|
minDist = Math.min(minDist, d);
|
|
}
|
|
}
|
|
}
|
|
if (minDist <= radius) {
|
|
result[y][x] = 1 - minDist / radius;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function invertMask(mask: boolean[][]): boolean[][] {
|
|
return mask.map((row) => row.map((v) => !v));
|
|
}
|
|
|
|
/** Ray-casting point-in-polygon test */
|
|
export function pointInPolygon(x: number, y: number, points: number[]): boolean {
|
|
let inside = false;
|
|
const n = points.length / 2;
|
|
for (let i = 0, j = n - 1; i < n; j = i++) {
|
|
const xi = points[i * 2];
|
|
const yi = points[i * 2 + 1];
|
|
const xj = points[j * 2];
|
|
const yj = points[j * 2 + 1];
|
|
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
|
inside = !inside;
|
|
}
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook: useSelectionTool
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SelectionToolApi {
|
|
selectionType: SelectionType;
|
|
setSelectionType: (t: SelectionType) => void;
|
|
isDrawing: boolean;
|
|
currentPoints: number[];
|
|
onMouseDown: (pos: { x: number; y: number }, stage?: Konva.Stage) => void;
|
|
onMouseMove: (pos: { x: number; y: number }) => void;
|
|
onMouseUp: () => void;
|
|
onDoubleClick: () => void;
|
|
selectAll: () => void;
|
|
deselect: () => void;
|
|
magicWandSelect: (
|
|
stage: Konva.Stage,
|
|
x: number,
|
|
y: number,
|
|
tolerance: number,
|
|
contiguous: boolean,
|
|
) => void;
|
|
}
|
|
|
|
export function useSelectionTool(): SelectionToolApi {
|
|
const [selectionType, setSelectionType] = useState<SelectionType>("rect");
|
|
const [isDrawing, setIsDrawing] = useState(false);
|
|
const [currentPoints, setCurrentPoints] = useState<number[]>([]);
|
|
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
|
|
const selectionMode = useEditorStore((s) => s.selectionMode);
|
|
|
|
const setSelection = useEditorStore((s) => s.setSelection);
|
|
const canvasSize = useEditorStore((s) => s.canvasSize);
|
|
const existingSelection = useEditorStore((s) => s.selection);
|
|
|
|
const mergeSelection = useCallback(
|
|
(newSel: SelectionState, mode: SelectionMode) => {
|
|
if (mode === "new" || !existingSelection) {
|
|
setSelection(newSel);
|
|
return;
|
|
}
|
|
|
|
const eb = existingSelection.bounds;
|
|
const nb = newSel.bounds;
|
|
|
|
if (mode === "add") {
|
|
const x = Math.min(eb.x, nb.x);
|
|
const y = Math.min(eb.y, nb.y);
|
|
setSelection({
|
|
...newSel,
|
|
bounds: {
|
|
x,
|
|
y,
|
|
width: Math.max(eb.x + eb.width, nb.x + nb.width) - x,
|
|
height: Math.max(eb.y + eb.height, nb.y + nb.height) - y,
|
|
},
|
|
});
|
|
} else {
|
|
// subtract: use the new bounds minus overlap (simplified)
|
|
setSelection(newSel);
|
|
}
|
|
},
|
|
[existingSelection, setSelection],
|
|
);
|
|
|
|
const onMouseDown = useCallback(
|
|
(pos: { x: number; y: number }, _stage?: Konva.Stage) => {
|
|
setIsDrawing(true);
|
|
startRef.current = pos;
|
|
if (selectionType === "lasso") {
|
|
setCurrentPoints([pos.x, pos.y]);
|
|
} else {
|
|
setCurrentPoints([]);
|
|
}
|
|
},
|
|
[selectionType],
|
|
);
|
|
|
|
const onMouseMove = useCallback(
|
|
(pos: { x: number; y: number }) => {
|
|
if (!isDrawing) return;
|
|
|
|
if (selectionType === "lasso") {
|
|
setCurrentPoints((prev) => [...prev, pos.x, pos.y]);
|
|
} else {
|
|
const s = startRef.current;
|
|
setCurrentPoints([s.x, s.y, pos.x, pos.y]);
|
|
}
|
|
},
|
|
[isDrawing, selectionType],
|
|
);
|
|
|
|
const onMouseUp = useCallback(() => {
|
|
if (!isDrawing) return;
|
|
setIsDrawing(false);
|
|
|
|
if (selectionType === "lasso") {
|
|
if (currentPoints.length < 6) {
|
|
setSelection(null);
|
|
setCurrentPoints([]);
|
|
return;
|
|
}
|
|
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
|
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
|
const bounds = {
|
|
x: Math.min(...xs),
|
|
y: Math.min(...ys),
|
|
width: Math.max(...xs) - Math.min(...xs),
|
|
height: Math.max(...ys) - Math.min(...ys),
|
|
};
|
|
mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode);
|
|
} else {
|
|
if (currentPoints.length < 4) {
|
|
setCurrentPoints([]);
|
|
return;
|
|
}
|
|
const [x1, y1, x2, y2] = currentPoints;
|
|
const x = Math.min(x1, x2);
|
|
const y = Math.min(y1, y2);
|
|
const w = Math.abs(x2 - x1);
|
|
const h = Math.abs(y2 - y1);
|
|
if (w < 2 || h < 2) {
|
|
setSelection(null);
|
|
setCurrentPoints([]);
|
|
return;
|
|
}
|
|
mergeSelection(
|
|
{
|
|
type: selectionType,
|
|
points: [],
|
|
bounds: { x, y, width: w, height: h },
|
|
},
|
|
selectionMode,
|
|
);
|
|
}
|
|
setCurrentPoints([]);
|
|
}, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]);
|
|
|
|
const onDoubleClick = useCallback(() => {
|
|
// Close polygonal lasso
|
|
if (selectionType === "lasso" && currentPoints.length >= 6) {
|
|
setIsDrawing(false);
|
|
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
|
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
|
mergeSelection(
|
|
{
|
|
type: "lasso",
|
|
points: currentPoints,
|
|
bounds: {
|
|
x: Math.min(...xs),
|
|
y: Math.min(...ys),
|
|
width: Math.max(...xs) - Math.min(...xs),
|
|
height: Math.max(...ys) - Math.min(...ys),
|
|
},
|
|
},
|
|
selectionMode,
|
|
);
|
|
setCurrentPoints([]);
|
|
}
|
|
}, [selectionType, currentPoints, selectionMode, mergeSelection]);
|
|
|
|
const selectAll = useCallback(() => {
|
|
setSelection({
|
|
type: "rect",
|
|
points: [],
|
|
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
|
|
});
|
|
}, [canvasSize, setSelection]);
|
|
|
|
const deselect = useCallback(() => {
|
|
setSelection(null);
|
|
}, [setSelection]);
|
|
|
|
const magicWandSelect = useCallback(
|
|
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
|
const canvas = stage.toCanvas();
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const mask = floodFillMask(imageData, x, y, tolerance, contiguous);
|
|
const bounds = maskToBounds(mask);
|
|
if (!bounds) return;
|
|
mergeSelection({ type: "rect", points: [], bounds }, selectionMode);
|
|
},
|
|
[selectionMode, mergeSelection],
|
|
);
|
|
|
|
return {
|
|
selectionType,
|
|
setSelectionType,
|
|
isDrawing,
|
|
currentPoints,
|
|
onMouseDown,
|
|
onMouseMove,
|
|
onMouseUp,
|
|
onDoubleClick,
|
|
selectAll,
|
|
deselect,
|
|
magicWandSelect,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SelectionOverlay -- renders selection outline with marching ants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva.Layer | null> }) {
|
|
const selection = useEditorStore((s) => s.selection);
|
|
const dashOffset = useMarchingAnts(layerRef);
|
|
|
|
if (!selection) return null;
|
|
|
|
const { type, bounds, points } = selection;
|
|
|
|
if (type === "lasso" && points.length >= 6) {
|
|
return (
|
|
<Group>
|
|
<Line
|
|
points={points}
|
|
closed
|
|
stroke="#000000"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current}
|
|
listening={false}
|
|
/>
|
|
<Line
|
|
points={points}
|
|
closed
|
|
stroke="#ffffff"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current + DASH[0]}
|
|
listening={false}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (type === "ellipse") {
|
|
const rx = bounds.width / 2;
|
|
const ry = bounds.height / 2;
|
|
return (
|
|
<Group>
|
|
<Ellipse
|
|
x={bounds.x + rx}
|
|
y={bounds.y + ry}
|
|
radiusX={rx}
|
|
radiusY={ry}
|
|
stroke="#000000"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current}
|
|
listening={false}
|
|
/>
|
|
<Ellipse
|
|
x={bounds.x + rx}
|
|
y={bounds.y + ry}
|
|
radiusX={rx}
|
|
radiusY={ry}
|
|
stroke="#ffffff"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current + DASH[0]}
|
|
listening={false}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
// Rectangular selection
|
|
return (
|
|
<Group>
|
|
<Rect
|
|
x={bounds.x}
|
|
y={bounds.y}
|
|
width={bounds.width}
|
|
height={bounds.height}
|
|
stroke="#000000"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current}
|
|
listening={false}
|
|
/>
|
|
<Rect
|
|
x={bounds.x}
|
|
y={bounds.y}
|
|
width={bounds.width}
|
|
height={bounds.height}
|
|
stroke="#ffffff"
|
|
strokeWidth={1}
|
|
dash={DASH}
|
|
dashOffset={dashOffset.current + DASH[0]}
|
|
listening={false}
|
|
/>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ActiveSelectionPreview -- rendered during drag to show selection shape
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function ActiveSelectionPreview({
|
|
type,
|
|
points,
|
|
}: {
|
|
type: SelectionType;
|
|
points: number[];
|
|
}) {
|
|
if (type === "lasso" && points.length >= 4) {
|
|
return (
|
|
<Line points={points} stroke="#3b82f6" strokeWidth={1} dash={[4, 4]} listening={false} />
|
|
);
|
|
}
|
|
|
|
if (points.length < 4) return null;
|
|
|
|
const [x1, y1, x2, y2] = points;
|
|
const x = Math.min(x1, x2);
|
|
const y = Math.min(y1, y2);
|
|
const w = Math.abs(x2 - x1);
|
|
const h = Math.abs(y2 - y1);
|
|
|
|
if (type === "ellipse") {
|
|
return (
|
|
<Ellipse
|
|
x={x + w / 2}
|
|
y={y + h / 2}
|
|
radiusX={w / 2}
|
|
radiusY={h / 2}
|
|
stroke="#3b82f6"
|
|
strokeWidth={1}
|
|
dash={[4, 4]}
|
|
listening={false}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Rect
|
|
x={x}
|
|
y={y}
|
|
width={w}
|
|
height={h}
|
|
stroke="#3b82f6"
|
|
strokeWidth={1}
|
|
dash={[4, 4]}
|
|
listening={false}
|
|
/>
|
|
);
|
|
}
|