Merge branch 'worktree-agent-a82b6128' into feat/image-editor

# Conflicts:
#	apps/web/package.json
#	apps/web/src/stores/editor-store.ts
#	apps/web/src/types/editor.ts
#	pnpm-lock.yaml
This commit is contained in:
SnapOtter
2026-05-06 23:30:42 +08:00
14 changed files with 3363 additions and 0 deletions
@@ -0,0 +1,283 @@
import type Konva from "konva";
import { useCallback, useEffect, useRef, useState } from "react";
import { Group, Line, Rect, Transformer } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
import type { CropState } from "@/types/editor";
// ---------------------------------------------------------------------------
// Aspect ratio presets
// ---------------------------------------------------------------------------
export const ASPECT_RATIOS = [
{ label: "Free", value: null },
{ label: "1:1", value: 1 },
{ label: "4:3", value: 4 / 3 },
{ label: "3:4", value: 3 / 4 },
{ label: "16:9", value: 16 / 9 },
{ label: "9:16", value: 9 / 16 },
{ label: "3:2", value: 3 / 2 },
{ label: "2:3", value: 2 / 3 },
] as const;
// ---------------------------------------------------------------------------
// Hook: useCropTool
// ---------------------------------------------------------------------------
export interface CropToolApi {
cropRef: React.RefObject<Konva.Rect | null>;
transformerRef: React.RefObject<Konva.Transformer | null>;
cropState: CropState | null;
aspectRatio: string;
setAspectRatio: (label: string) => void;
initCrop: () => void;
applyCrop: () => void;
cancelCrop: () => void;
updateCropSize: (w: number, h: number) => void;
swapDimensions: () => void;
}
export function useCropTool(): CropToolApi {
const cropRef = useRef<Konva.Rect | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
const [aspectRatio, setAspectRatioState] = useState("Free");
const cropState = useEditorStore((s) => s.cropState);
const setCropState = useEditorStore((s) => s.setCropState);
const applyCropAction = useEditorStore((s) => s.applyCrop);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Attach transformer to crop rect
useEffect(() => {
const tr = transformerRef.current;
const node = cropRef.current;
if (tr && node && cropState) {
tr.nodes([node]);
tr.getLayer()?.batchDraw();
}
}, [cropState]);
const initCrop = useCallback(() => {
const margin = 0.1;
setCropState({
x: canvasSize.width * margin,
y: canvasSize.height * margin,
width: canvasSize.width * (1 - 2 * margin),
height: canvasSize.height * (1 - 2 * margin),
aspectRatio: null,
});
}, [canvasSize, setCropState]);
const setAspectRatio = useCallback(
(label: string) => {
setAspectRatioState(label);
const preset = ASPECT_RATIOS.find((p) => p.label === label);
if (!preset || !preset.value || !cropState) return;
const ratio = preset.value;
let w = cropState.width;
let h = w / ratio;
if (h > canvasSize.height) {
h = canvasSize.height * 0.8;
w = h * ratio;
}
setCropState({
...cropState,
width: w,
height: h,
aspectRatio: label,
});
},
[cropState, canvasSize, setCropState],
);
const applyCrop = useCallback(() => {
applyCropAction();
}, [applyCropAction]);
const cancelCrop = useCallback(() => {
setCropState(null);
}, [setCropState]);
const updateCropSize = useCallback(
(w: number, h: number) => {
if (!cropState) return;
setCropState({ ...cropState, width: w, height: h });
},
[cropState, setCropState],
);
const swapDimensions = useCallback(() => {
if (!cropState) return;
setCropState({
...cropState,
width: cropState.height,
height: cropState.width,
});
}, [cropState, setCropState]);
return {
cropRef,
transformerRef,
cropState,
aspectRatio,
setAspectRatio,
initCrop,
applyCrop,
cancelCrop,
updateCropSize,
swapDimensions,
};
}
// ---------------------------------------------------------------------------
// CropOverlay -- renders darkened overlay + crop region + rule-of-thirds
// ---------------------------------------------------------------------------
export function CropOverlay() {
const cropState = useEditorStore((s) => s.cropState);
const canvasSize = useEditorStore((s) => s.canvasSize);
const setCropState = useEditorStore((s) => s.setCropState);
const cropRectRef = useRef<Konva.Rect | null>(null);
const trRef = useRef<Konva.Transformer | null>(null);
useEffect(() => {
if (trRef.current && cropRectRef.current && cropState) {
trRef.current.nodes([cropRectRef.current]);
trRef.current.getLayer()?.batchDraw();
}
}, [cropState]);
if (!cropState) return null;
const { x, y, width, height } = cropState;
const cw = canvasSize.width;
const ch = canvasSize.height;
const overlayFill = "rgba(0, 0, 0, 0.5)";
// Rule-of-thirds grid lines
const thirdW = width / 3;
const thirdH = height / 3;
const handleTransformEnd = () => {
const node = cropRectRef.current;
if (!node) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newW = Math.max(10, node.width() * scaleX);
const newH = Math.max(10, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
setCropState({
...cropState,
x: node.x(),
y: node.y(),
width: newW,
height: newH,
});
};
const handleDragEnd = () => {
const node = cropRectRef.current;
if (!node) return;
setCropState({
...cropState,
x: Math.max(0, Math.min(node.x(), cw - width)),
y: Math.max(0, Math.min(node.y(), ch - height)),
});
};
return (
<Group listening={false}>
{/* Darkened overlays: top, bottom, left, right */}
<Rect x={0} y={0} width={cw} height={y} fill={overlayFill} listening={false} />
<Rect
x={0}
y={y + height}
width={cw}
height={ch - y - height}
fill={overlayFill}
listening={false}
/>
<Rect x={0} y={y} width={x} height={height} fill={overlayFill} listening={false} />
<Rect
x={x + width}
y={y}
width={cw - x - width}
height={height}
fill={overlayFill}
listening={false}
/>
{/* Crop region */}
<Rect
ref={cropRectRef}
x={x}
y={y}
width={width}
height={height}
stroke="#ffffff"
strokeWidth={1}
draggable
listening
onDragEnd={handleDragEnd}
onTransformEnd={handleTransformEnd}
/>
{/* Rule-of-thirds grid */}
<Line
points={[x + thirdW, y, x + thirdW, y + height]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x + thirdW * 2, y, x + thirdW * 2, y + height]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x, y + thirdH, x + width, y + thirdH]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
<Line
points={[x, y + thirdH * 2, x + width, y + thirdH * 2]}
stroke="rgba(255,255,255,0.4)"
strokeWidth={0.5}
listening={false}
/>
{/* Transformer */}
<Transformer
ref={trRef}
rotateEnabled={false}
flipEnabled={false}
keepRatio={false}
anchorSize={8}
anchorStroke="#ffffff"
anchorFill="#3b82f6"
anchorCornerRadius={1}
borderStroke="#ffffff"
borderStrokeWidth={1}
enabledAnchors={[
"top-left",
"top-center",
"top-right",
"middle-left",
"middle-right",
"bottom-left",
"bottom-center",
"bottom-right",
]}
boundBoxFunc={(_oldBox, newBox) => ({
...newBox,
width: Math.max(10, newBox.width),
height: Math.max(10, newBox.height),
})}
/>
</Group>
);
}
@@ -0,0 +1,428 @@
import type Konva from "konva";
import { useCallback, useEffect, useRef } from "react";
import { Transformer } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
import type { SmartGuide } from "@/types/editor";
// ---------------------------------------------------------------------------
// Smart guide calculation
// ---------------------------------------------------------------------------
function findSmartGuides(
node: Konva.Node,
allNodes: Konva.Node[],
canvasWidth: number,
canvasHeight: number,
threshold: number,
): SmartGuide[] {
const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined });
const guides: SmartGuide[] = [];
const dragEdges = {
left: box.x,
right: box.x + box.width,
centerX: box.x + box.width / 2,
top: box.y,
bottom: box.y + box.height,
centerY: box.y + box.height / 2,
};
// Canvas edges + center
const canvasSnaps = [
{ pos: 0, type: "canvas" as const, orient: "vertical" as const },
{
pos: canvasWidth / 2,
type: "canvas" as const,
orient: "vertical" as const,
},
{ pos: canvasWidth, type: "canvas" as const, orient: "vertical" as const },
{ pos: 0, type: "canvas" as const, orient: "horizontal" as const },
{
pos: canvasHeight / 2,
type: "canvas" as const,
orient: "horizontal" as const,
},
{
pos: canvasHeight,
type: "canvas" as const,
orient: "horizontal" as const,
},
];
for (const snap of canvasSnaps) {
if (snap.orient === "vertical") {
for (const edge of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
if (Math.abs(edge - snap.pos) < threshold) {
guides.push({
orientation: "vertical",
position: snap.pos,
type: snap.type,
});
}
}
} else {
for (const edge of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
if (Math.abs(edge - snap.pos) < threshold) {
guides.push({
orientation: "horizontal",
position: snap.pos,
type: snap.type,
});
}
}
}
}
// Other objects
for (const other of allNodes) {
if (other === node) continue;
const ob = other.getClientRect({
relativeTo: other.getParent() ?? undefined,
});
const targetEdges = {
left: ob.x,
right: ob.x + ob.width,
centerX: ob.x + ob.width / 2,
top: ob.y,
bottom: ob.y + ob.height,
centerY: ob.y + ob.height / 2,
};
for (const edgeVal of [targetEdges.left, targetEdges.centerX, targetEdges.right]) {
for (const dragVal of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
if (Math.abs(dragVal - edgeVal) < threshold) {
guides.push({
orientation: "vertical",
position: edgeVal,
type: dragVal === dragEdges.centerX ? "center" : "edge",
});
}
}
}
for (const edgeVal of [targetEdges.top, targetEdges.centerY, targetEdges.bottom]) {
for (const dragVal of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
if (Math.abs(dragVal - edgeVal) < threshold) {
guides.push({
orientation: "horizontal",
position: edgeVal,
type: dragVal === dragEdges.centerY ? "center" : "edge",
});
}
}
}
}
return guides;
}
function snapPosition(
pos: { x: number; y: number },
guides: SmartGuide[],
box: { width: number; height: number },
threshold: number,
): { x: number; y: number } {
let { x, y } = pos;
for (const g of guides) {
if (g.orientation === "vertical") {
if (Math.abs(x - g.position) < threshold) x = g.position;
else if (Math.abs(x + box.width / 2 - g.position) < threshold) x = g.position - box.width / 2;
else if (Math.abs(x + box.width - g.position) < threshold) x = g.position - box.width;
} else {
if (Math.abs(y - g.position) < threshold) y = g.position;
else if (Math.abs(y + box.height / 2 - g.position) < threshold)
y = g.position - box.height / 2;
else if (Math.abs(y + box.height - g.position) < threshold) y = g.position - box.height;
}
}
return { x, y };
}
// ---------------------------------------------------------------------------
// Exported helpers for alignment
// ---------------------------------------------------------------------------
export function alignObjects(
direction:
| "left"
| "center-h"
| "right"
| "top"
| "center-v"
| "bottom"
| "distribute-h"
| "distribute-v",
objectIds: string[],
objects: { id: string; attrs: Record<string, unknown> }[],
updateObject: (id: string, attrs: Record<string, unknown>) => void,
): void {
const selected = objects.filter((o) => objectIds.includes(o.id));
if (selected.length < 2 && !direction.startsWith("distribute")) return;
if (selected.length < 3 && direction.startsWith("distribute")) return;
const bounds = selected.map((o) => ({
id: o.id,
x: (o.attrs.x as number) ?? 0,
y: (o.attrs.y as number) ?? 0,
w: (o.attrs.width as number) ?? 0,
h: (o.attrs.height as number) ?? 0,
}));
switch (direction) {
case "left": {
const minX = Math.min(...bounds.map((b) => b.x));
for (const b of bounds) updateObject(b.id, { x: minX });
break;
}
case "center-h": {
const minX = Math.min(...bounds.map((b) => b.x));
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
const center = (minX + maxX) / 2;
for (const b of bounds) updateObject(b.id, { x: center - b.w / 2 });
break;
}
case "right": {
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
for (const b of bounds) updateObject(b.id, { x: maxX - b.w });
break;
}
case "top": {
const minY = Math.min(...bounds.map((b) => b.y));
for (const b of bounds) updateObject(b.id, { y: minY });
break;
}
case "center-v": {
const minY = Math.min(...bounds.map((b) => b.y));
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
const center = (minY + maxY) / 2;
for (const b of bounds) updateObject(b.id, { y: center - b.h / 2 });
break;
}
case "bottom": {
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
for (const b of bounds) updateObject(b.id, { y: maxY - b.h });
break;
}
case "distribute-h": {
const sorted = [...bounds].sort((a, b) => a.x - b.x);
const totalW = sorted.reduce((s, b) => s + b.w, 0);
const minX = sorted[0].x;
const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].w;
const gap = (maxX - minX - totalW) / (sorted.length - 1);
let cx = minX;
for (const b of sorted) {
updateObject(b.id, { x: cx });
cx += b.w + gap;
}
break;
}
case "distribute-v": {
const sorted = [...bounds].sort((a, b) => a.y - b.y);
const totalH = sorted.reduce((s, b) => s + b.h, 0);
const minY = sorted[0].y;
const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].h;
const gap = (maxY - minY - totalH) / (sorted.length - 1);
let cy = minY;
for (const b of sorted) {
updateObject(b.id, { y: cy });
cy += b.h + gap;
}
break;
}
}
}
// ---------------------------------------------------------------------------
// Hook: useMoveTool
// ---------------------------------------------------------------------------
export interface MoveToolApi {
transformerRef: React.RefObject<Konva.Transformer | null>;
smartGuides: SmartGuide[];
onSelect: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onStageClick: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragStart: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
nudge: (dx: number, dy: number) => void;
}
export function useMoveTool(): MoveToolApi {
const transformerRef = useRef<Konva.Transformer | null>(null);
const smartGuidesRef = useRef<SmartGuide[]>([]);
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects);
const updateObject = useEditorStore((s) => s.updateObject);
const canvasSize = useEditorStore((s) => s.canvasSize);
const snapToGuides = useEditorStore((s) => s.snapToGuides);
// Attach transformer to selected nodes
useEffect(() => {
const tr = transformerRef.current;
if (!tr) return;
const stage = tr.getStage();
if (!stage) return;
const nodes = selectedObjectIds
.map((id) => stage.findOne(`#${id}`))
.filter(Boolean) as Konva.Node[];
tr.nodes(nodes);
tr.getLayer()?.batchDraw();
}, [selectedObjectIds]);
const onSelect = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
const target = e.target;
const id = target.id();
if (!id) return;
if (e.evt.shiftKey) {
// Toggle multi-select
if (selectedObjectIds.includes(id)) {
setSelectedObjects(selectedObjectIds.filter((i) => i !== id));
} else {
setSelectedObjects([...selectedObjectIds, id]);
}
} else {
setSelectedObjects([id]);
}
},
[selectedObjectIds, setSelectedObjects],
);
const onStageClick = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// Clicked on stage background - deselect
if (e.target === e.target.getStage()) {
setSelectedObjects([]);
}
},
[setSelectedObjects],
);
const onDragStart = useCallback((_e: Konva.KonvaEventObject<DragEvent>) => {
// No-op -- selection already handled by onClick
}, []);
const onDragMove = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
if (!snapToGuides) return;
const stage = node.getStage();
if (!stage) return;
const allNodes = stage
.find("Rect, Ellipse, Text, Image, Line, Arrow, RegularPolygon, Star")
.filter((n) => n.id() && n.id() !== node.id());
const guides = findSmartGuides(node, allNodes, canvasSize.width, canvasSize.height, 5);
smartGuidesRef.current = guides;
if (guides.length > 0) {
const box = node.getClientRect({
relativeTo: node.getParent() ?? undefined,
});
const snapped = snapPosition(
{ x: node.x(), y: node.y() },
guides,
{ width: box.width, height: box.height },
5,
);
node.position(snapped);
}
},
[canvasSize, snapToGuides],
);
const onDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
smartGuidesRef.current = [];
updateObject(node.id(), {
x: node.x(),
y: node.y(),
});
},
[updateObject],
);
const onTransformEnd = useCallback(
(e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Normalize scale into width/height
const newWidth = Math.max(1, node.width() * scaleX);
const newHeight = Math.max(1, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
updateObject(node.id(), {
x: node.x(),
y: node.y(),
width: newWidth,
height: newHeight,
rotation: node.rotation(),
});
},
[updateObject],
);
const nudge = useCallback(
(dx: number, dy: number) => {
for (const id of selectedObjectIds) {
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
if (!obj) continue;
updateObject(id, {
x: ((obj.attrs.x as number) ?? 0) + dx,
y: ((obj.attrs.y as number) ?? 0) + dy,
});
}
},
[selectedObjectIds, updateObject],
);
return {
transformerRef,
smartGuides: smartGuidesRef.current,
onSelect,
onStageClick,
onDragStart,
onDragMove,
onDragEnd,
onTransformEnd,
nudge,
};
}
// ---------------------------------------------------------------------------
// MoveToolTransformer -- Konva Transformer component
// ---------------------------------------------------------------------------
export function MoveToolTransformer({
transformerRef,
}: {
transformerRef: React.RefObject<Konva.Transformer | null>;
}) {
return (
<Transformer
ref={transformerRef}
rotateEnabled
flipEnabled
keepRatio={false}
anchorSize={8}
anchorStroke="#3b82f6"
anchorFill="#ffffff"
anchorCornerRadius={2}
borderStroke="#3b82f6"
borderStrokeWidth={1}
padding={2}
/>
);
}
@@ -0,0 +1,561 @@
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, SelectionType } from "@/types/editor";
// ---------------------------------------------------------------------------
// 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 setSelection = useEditorStore((s) => s.setSelection);
const selectionMode = useEditorStore((s) => s.selectionMode);
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}
/>
);
}
@@ -0,0 +1,237 @@
import type Konva from "konva";
import { useCallback, useEffect, useRef, useState } from "react";
import { Transformer } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// Transform state -- position/size/rotation for the options bar
// ---------------------------------------------------------------------------
export interface TransformValues {
x: number;
y: number;
width: number;
height: number;
rotation: number;
}
// ---------------------------------------------------------------------------
// Hook: useTransformTool
// ---------------------------------------------------------------------------
export interface TransformToolApi {
transformerRef: React.RefObject<Konva.Transformer | null>;
isTransforming: boolean;
values: TransformValues;
lockedAspect: boolean;
setLockedAspect: (v: boolean) => void;
activate: () => void;
applyTransform: () => void;
cancelTransform: () => void;
setValues: (v: Partial<TransformValues>) => void;
flipHorizontal: () => void;
flipVertical: () => void;
}
export function useTransformTool(): TransformToolApi {
const transformerRef = useRef<Konva.Transformer | null>(null);
const [isTransforming, setIsTransforming] = useState(false);
const [lockedAspect, setLockedAspect] = useState(false);
const [values, setValuesState] = useState<TransformValues>({
x: 0,
y: 0,
width: 0,
height: 0,
rotation: 0,
});
const preTransformRef = useRef<TransformValues | null>(null);
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects);
const updateObject = useEditorStore((s) => s.updateObject);
const setTool = useEditorStore((s) => s.setTool);
// Read values from selected object(s)
useEffect(() => {
if (!isTransforming || selectedObjectIds.length === 0) return;
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
if (!obj) return;
const v: TransformValues = {
x: (obj.attrs.x as number) ?? 0,
y: (obj.attrs.y as number) ?? 0,
width: (obj.attrs.width as number) ?? 0,
height: (obj.attrs.height as number) ?? 0,
rotation: (obj.attrs.rotation as number) ?? 0,
};
setValuesState(v);
}, [isTransforming, selectedObjectIds, objects]);
// Attach transformer
useEffect(() => {
const tr = transformerRef.current;
if (!tr || !isTransforming) return;
const stage = tr.getStage();
if (!stage) return;
const nodes = selectedObjectIds
.map((id) => stage.findOne(`#${id}`))
.filter(Boolean) as Konva.Node[];
tr.nodes(nodes);
tr.getLayer()?.batchDraw();
}, [isTransforming, selectedObjectIds]);
const activate = useCallback(() => {
if (selectedObjectIds.length === 0) return;
setIsTransforming(true);
// Store pre-transform state for cancel
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
if (obj) {
preTransformRef.current = {
x: (obj.attrs.x as number) ?? 0,
y: (obj.attrs.y as number) ?? 0,
width: (obj.attrs.width as number) ?? 0,
height: (obj.attrs.height as number) ?? 0,
rotation: (obj.attrs.rotation as number) ?? 0,
};
}
}, [selectedObjectIds, objects]);
const applyTransform = useCallback(() => {
setIsTransforming(false);
preTransformRef.current = null;
setTool("move");
}, [setTool]);
const cancelTransform = useCallback(() => {
// Restore pre-transform state
if (preTransformRef.current && selectedObjectIds.length > 0) {
const prev = preTransformRef.current;
for (const id of selectedObjectIds) {
updateObject(id, {
x: prev.x,
y: prev.y,
width: prev.width,
height: prev.height,
rotation: prev.rotation,
});
}
}
setIsTransforming(false);
preTransformRef.current = null;
setTool("move");
}, [selectedObjectIds, updateObject, setTool]);
const setValues = useCallback(
(v: Partial<TransformValues>) => {
setValuesState((prev) => {
const next = { ...prev, ...v };
// If aspect is locked, derive height from width ratio or vice versa
if (lockedAspect && prev.width > 0 && prev.height > 0) {
const ratio = prev.width / prev.height;
if (v.width !== undefined && v.height === undefined) {
next.height = next.width / ratio;
} else if (v.height !== undefined && v.width === undefined) {
next.width = next.height * ratio;
}
}
// Apply to selected objects
for (const id of selectedObjectIds) {
updateObject(id, {
x: next.x,
y: next.y,
width: next.width,
height: next.height,
rotation: next.rotation,
});
}
return next;
});
},
[selectedObjectIds, updateObject, lockedAspect],
);
const flipHorizontal = useCallback(() => {
for (const id of selectedObjectIds) {
const obj = objects.find((o) => o.id === id);
if (!obj) continue;
const currentScale = (obj.attrs.scaleX as number) ?? 1;
updateObject(id, { scaleX: -currentScale });
}
}, [selectedObjectIds, objects, updateObject]);
const flipVertical = useCallback(() => {
for (const id of selectedObjectIds) {
const obj = objects.find((o) => o.id === id);
if (!obj) continue;
const currentScale = (obj.attrs.scaleY as number) ?? 1;
updateObject(id, { scaleY: -currentScale });
}
}, [selectedObjectIds, objects, updateObject]);
return {
transformerRef,
isTransforming,
values,
lockedAspect,
setLockedAspect,
activate,
applyTransform,
cancelTransform,
setValues,
flipHorizontal,
flipVertical,
};
}
// ---------------------------------------------------------------------------
// TransformToolTransformer -- Konva Transformer for free transform mode
// ---------------------------------------------------------------------------
export function TransformToolTransformer({
transformerRef,
onTransformEnd,
}: {
transformerRef: React.RefObject<Konva.Transformer | null>;
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
}) {
const handleTransformEnd = useCallback(
(e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
const newW = Math.max(1, node.width() * scaleX);
const newH = Math.max(1, node.height() * scaleY);
node.scaleX(1);
node.scaleY(1);
node.width(newW);
node.height(newH);
onTransformEnd?.(e);
},
[onTransformEnd],
);
return (
<Transformer
ref={transformerRef}
rotateEnabled
flipEnabled
keepRatio={false}
rotationSnaps={[
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285,
300, 315, 330, 345,
]}
anchorSize={8}
anchorStroke="#3b82f6"
anchorFill="#ffffff"
anchorCornerRadius={2}
borderStroke="#3b82f6"
borderStrokeWidth={1}
borderDash={[4, 4]}
padding={0}
onTransformEnd={handleTransformEnd}
/>
);
}