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,170 @@
import { X } from "lucide-react";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { AnchorPosition } from "@/types/editor";
// ---------------------------------------------------------------------------
// CanvasResizeDialog -- modal with W/H, 9-point anchor grid, fill color
// ---------------------------------------------------------------------------
const ANCHOR_POSITIONS: AnchorPosition[] = [
"top-left",
"top-center",
"top-right",
"center-left",
"center",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
];
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [anchor, setAnchor] = useState<AnchorPosition>("center");
const [fill, setFill] = useState("#ffffff");
const handleApply = useCallback(() => {
resizeCanvas(width, height, anchor, fill);
onClose();
}, [width, height, anchor, fill, resizeCanvas, onClose]);
if (!open) return null;
const inputCn = cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">Canvas Size</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-4 py-4">
{/* Current size info */}
<p className="text-xs text-muted-foreground">
Current: {canvasSize.width} x {canvasSize.height} px
</p>
{/* Width / Height */}
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="canvas-w" className="mb-1 block text-xs text-muted-foreground">
Width (px)
</label>
<input
id="canvas-w"
type="number"
min={1}
max={16384}
value={width}
onChange={(e) => setWidth(Math.max(1, Number(e.target.value) || 1))}
className={inputCn}
/>
</div>
<div>
<label htmlFor="canvas-h" className="mb-1 block text-xs text-muted-foreground">
Height (px)
</label>
<input
id="canvas-h"
type="number"
min={1}
max={16384}
value={height}
onChange={(e) => setHeight(Math.max(1, Number(e.target.value) || 1))}
className={inputCn}
/>
</div>
</div>
{/* Anchor grid */}
<div>
<p className="mb-2 text-xs text-muted-foreground">Anchor:</p>
<div className="inline-grid grid-cols-3 gap-1 rounded border border-border p-1.5">
{ANCHOR_POSITIONS.map((pos) => (
<button
key={pos}
type="button"
onClick={() => setAnchor(pos)}
title={pos.replace("-", " ")}
aria-label={`Anchor ${pos.replace("-", " ")}`}
aria-pressed={anchor === pos}
className={cn(
"h-5 w-5 rounded-sm transition-colors",
anchor === pos ? "bg-primary" : "bg-muted hover:bg-muted-foreground/20",
)}
/>
))}
</div>
</div>
{/* Background fill */}
<div className="flex items-center gap-2">
<label htmlFor="canvas-fill" className="text-xs text-muted-foreground">
Background:
</label>
<input
id="canvas-fill"
type="color"
value={fill}
onChange={(e) => setFill(e.target.value)}
className="h-7 w-7 cursor-pointer rounded border border-border"
/>
<input
type="text"
value={fill}
onChange={(e) => setFill(e.target.value)}
className={cn(
"h-7 w-20 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
)}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
type="button"
onClick={onClose}
className={cn(
"h-8 rounded border border-border px-3 text-sm",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className={cn(
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
"hover:bg-primary/90 transition-colors",
)}
>
Apply
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,272 @@
import {
ArrowDown,
ArrowUp,
ClipboardPaste,
Copy,
CopyPlus,
ImageIcon,
Maximize,
MousePointer,
Scissors,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// Context menu state
// ---------------------------------------------------------------------------
interface MenuPosition {
x: number;
y: number;
}
interface MenuItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
shortcut?: string;
action: () => void;
disabled?: boolean;
dividerAfter?: boolean;
}
// ---------------------------------------------------------------------------
// Hook: useContextMenu
// ---------------------------------------------------------------------------
export function useContextMenu() {
const [position, setPosition] = useState<MenuPosition | null>(null);
const [menuType, setMenuType] = useState<"object" | "canvas">("canvas");
const handleContextMenu = useCallback((e: React.MouseEvent, hasSelectedObject: boolean) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setMenuType(hasSelectedObject ? "object" : "canvas");
}, []);
const close = useCallback(() => {
setPosition(null);
}, []);
return { position, menuType, handleContextMenu, close };
}
// ---------------------------------------------------------------------------
// ContextMenu component
// ---------------------------------------------------------------------------
export function ContextMenu({
position,
menuType,
onClose,
}: {
position: MenuPosition;
menuType: "object" | "canvas";
onClose: () => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const copyObjects = useEditorStore((s) => s.copyObjects);
const cutObjects = useEditorStore((s) => s.cutObjects);
const pasteObjects = useEditorStore((s) => s.pasteObjects);
const removeObjects = useEditorStore((s) => s.removeObjects);
const duplicateObjects = useEditorStore((s) => s.duplicateObjects);
const bringToFront = useEditorStore((s) => s.bringToFront);
const bringForward = useEditorStore((s) => s.bringForward);
const sendBackward = useEditorStore((s) => s.sendBackward);
const sendToBack = useEditorStore((s) => s.sendToBack);
const clipboard = useEditorStore((s) => s.clipboard);
const setSelection = useEditorStore((s) => s.setSelection);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Close on click outside or Escape
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
const objectItems: MenuItem[] = [
{
label: "Cut",
icon: Scissors,
shortcut: "Ctrl+X",
action: () => {
cutObjects(selectedObjectIds);
onClose();
},
},
{
label: "Copy",
icon: Copy,
shortcut: "Ctrl+C",
action: () => {
copyObjects(selectedObjectIds);
onClose();
},
},
{
label: "Paste",
icon: ClipboardPaste,
shortcut: "Ctrl+V",
action: () => {
pasteObjects();
onClose();
},
disabled: clipboard.length === 0,
},
{
label: "Duplicate",
icon: CopyPlus,
shortcut: "Ctrl+D",
action: () => {
duplicateObjects(selectedObjectIds);
onClose();
},
dividerAfter: true,
},
{
label: "Bring to Front",
icon: ArrowUp,
action: () => {
for (const id of selectedObjectIds) bringToFront(id);
onClose();
},
},
{
label: "Bring Forward",
icon: ArrowUp,
action: () => {
for (const id of selectedObjectIds) bringForward(id);
onClose();
},
},
{
label: "Send Backward",
icon: ArrowDown,
action: () => {
for (const id of selectedObjectIds) sendBackward(id);
onClose();
},
},
{
label: "Send to Back",
icon: ArrowDown,
action: () => {
for (const id of selectedObjectIds) sendToBack(id);
onClose();
},
dividerAfter: true,
},
{
label: "Delete",
icon: Trash2,
shortcut: "Del",
action: () => {
removeObjects(selectedObjectIds);
onClose();
},
},
];
const canvasItems: MenuItem[] = [
{
label: "Paste",
icon: ClipboardPaste,
shortcut: "Ctrl+V",
action: () => {
pasteObjects();
onClose();
},
disabled: clipboard.length === 0,
},
{
label: "Select All",
icon: MousePointer,
shortcut: "Ctrl+A",
action: () => {
setSelection({
type: "rect",
points: [],
bounds: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
},
});
onClose();
},
dividerAfter: true,
},
{
label: "Canvas Size...",
icon: Maximize,
action: () => {
// Trigger canvas resize dialog (handled by parent)
onClose();
},
},
{
label: "Image Size...",
icon: ImageIcon,
action: () => {
// Trigger image resize dialog (handled by parent)
onClose();
},
},
];
const items = menuType === "object" ? objectItems : canvasItems;
// Adjust position to stay within viewport
const adjustedX = Math.min(position.x, window.innerWidth - 220);
const adjustedY = Math.min(position.y, window.innerHeight - items.length * 36);
return (
<div
ref={menuRef}
className={cn(
"fixed z-50 min-w-[200px] rounded-lg border border-border bg-card py-1 shadow-lg",
"animate-in fade-in-0 zoom-in-95",
)}
style={{ left: adjustedX, top: adjustedY }}
>
{items.map((item) => (
<div key={item.label}>
<button
type="button"
onClick={item.action}
disabled={item.disabled}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm",
"text-foreground hover:bg-muted transition-colors",
"disabled:cursor-not-allowed disabled:opacity-40",
)}
>
<item.icon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="text-xs text-muted-foreground">{item.shortcut}</span>
)}
</button>
{item.dividerAfter && <div className="my-1 h-px bg-border" />}
</div>
))}
</div>
);
}
@@ -0,0 +1,110 @@
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { Group, Line } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// GuideLines -- draggable guide lines rendered as Konva.Line
// ---------------------------------------------------------------------------
const GUIDE_COLOR = "#22d3ee"; // cyan-400
const GUIDE_WIDTH = 1;
export function GuideLines() {
const guides = useEditorStore((s) => s.guides);
const showGuides = useEditorStore((s) => s.showGuides);
const canvasSize = useEditorStore((s) => s.canvasSize);
const updateGuide = useEditorStore((s) => s.updateGuide);
const removeGuide = useEditorStore((s) => s.removeGuide);
if (!showGuides || guides.length === 0) return null;
return (
<Group>
{guides.map((guide) => (
<DraggableGuide
key={guide.id}
id={guide.id}
orientation={guide.orientation}
position={guide.position}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
onPositionChange={(pos) => updateGuide(guide.id, pos)}
onRemove={() => removeGuide(guide.id)}
/>
))}
</Group>
);
}
// ---------------------------------------------------------------------------
// DraggableGuide -- individual guide line
// ---------------------------------------------------------------------------
function DraggableGuide({
id,
orientation,
position,
canvasWidth,
canvasHeight,
onPositionChange,
onRemove,
}: {
id: string;
orientation: "horizontal" | "vertical";
position: number;
canvasWidth: number;
canvasHeight: number;
onPositionChange: (pos: number) => void;
onRemove: () => void;
}) {
const lineRef = useRef<Konva.Line>(null);
const isHorizontal = orientation === "horizontal";
const points = isHorizontal
? [0, position, canvasWidth, position]
: [position, 0, position, canvasHeight];
const handleDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
if (isHorizontal) {
const newY = node.y() + position;
node.y(0); // reset drag offset
onPositionChange(newY);
} else {
const newX = node.x() + position;
node.x(0);
onPositionChange(newX);
}
},
[isHorizontal, position, onPositionChange],
);
const handleDblClick = useCallback(() => {
onRemove();
}, [onRemove]);
return (
<Line
ref={lineRef}
id={`guide-${id}`}
points={points}
stroke={GUIDE_COLOR}
strokeWidth={GUIDE_WIDTH}
dash={[8, 4]}
draggable
dragBoundFunc={(pos) => {
// Constrain drag to the guide's axis
if (isHorizontal) {
return { x: 0, y: pos.y };
}
return { x: pos.x, y: 0 };
}}
onDragEnd={handleDragEnd}
onDblClick={handleDblClick}
hitStrokeWidth={8}
/>
);
}
@@ -0,0 +1,198 @@
import { Lock, Unlock, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { ResampleMethod } from "@/types/editor";
// ---------------------------------------------------------------------------
// ImageResizeDialog -- modal with W/H, aspect lock, resampling method
// ---------------------------------------------------------------------------
const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [
{ value: "nearest", label: "Nearest Neighbor (fast)" },
{ value: "bilinear", label: "Bilinear" },
{ value: "bicubic", label: "Bicubic (smooth)" },
{ value: "lanczos", label: "Lanczos (sharp)" },
];
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage);
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [lockAspect, setLockAspect] = useState(true);
const [resample, setResample] = useState<ResampleMethod>("bicubic");
const aspectRatio = canvasSize.width / canvasSize.height;
// Sync when dialog opens
useEffect(() => {
if (open) {
setWidth(canvasSize.width);
setHeight(canvasSize.height);
}
}, [open, canvasSize]);
const handleWidthChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const w = Math.max(1, Number(e.target.value) || 1);
setWidth(w);
if (lockAspect) {
setHeight(Math.round(w / aspectRatio));
}
},
[lockAspect, aspectRatio],
);
const handleHeightChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const h = Math.max(1, Number(e.target.value) || 1);
setHeight(h);
if (lockAspect) {
setWidth(Math.round(h * aspectRatio));
}
},
[lockAspect, aspectRatio],
);
const handleApply = useCallback(() => {
resizeImage(width, height);
onClose();
}, [width, height, resizeImage, onClose]);
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
const pctHeight =
canvasSize.height > 0 ? ((height / canvasSize.height) * 100).toFixed(1) : "100.0";
if (!open) return null;
const inputCn = cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">Image Size</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-4 py-4">
{/* Current size info */}
<p className="text-xs text-muted-foreground">
Original: {canvasSize.width} x {canvasSize.height} px
</p>
{/* Width / Lock / Height */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="img-w" className="mb-1 block text-xs text-muted-foreground">
Width (px)
</label>
<input
id="img-w"
type="number"
min={1}
max={16384}
value={width}
onChange={handleWidthChange}
className={inputCn}
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctWidth}%</p>
</div>
<button
type="button"
onClick={() => setLockAspect(!lockAspect)}
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-label={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-pressed={lockAspect}
className={cn(
"mb-4 flex h-8 w-8 items-center justify-center rounded transition-colors",
lockAspect
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{lockAspect ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
</button>
<div className="flex-1">
<label htmlFor="img-h" className="mb-1 block text-xs text-muted-foreground">
Height (px)
</label>
<input
id="img-h"
type="number"
min={1}
max={16384}
value={height}
onChange={handleHeightChange}
className={inputCn}
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctHeight}%</p>
</div>
</div>
{/* Resampling method */}
<div>
<label htmlFor="resample" className="mb-1 block text-xs text-muted-foreground">
Resampling:
</label>
<select
id="resample"
value={resample}
onChange={(e) => setResample(e.target.value as ResampleMethod)}
className={cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
)}
>
{RESAMPLE_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
type="button"
onClick={onClose}
className={cn(
"h-8 rounded border border-border px-3 text-sm",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className={cn(
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
"hover:bg-primary/90 transition-colors",
)}
>
Apply
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// Ruler configuration
// ---------------------------------------------------------------------------
const RULER_SIZE = 20; // px
const TICK_COLOR = "var(--color-muted-foreground)";
const BG_COLOR = "var(--color-card)";
const TEXT_COLOR = "var(--color-muted-foreground)";
// ---------------------------------------------------------------------------
// Helper: pick tick spacing based on zoom level
// ---------------------------------------------------------------------------
function getTickInterval(zoom: number): { major: number; minor: number } {
// Adjust tick spacing so labels don't overlap at any zoom
if (zoom >= 4) return { major: 25, minor: 5 };
if (zoom >= 2) return { major: 50, minor: 10 };
if (zoom >= 1) return { major: 100, minor: 10 };
if (zoom >= 0.5) return { major: 200, minor: 50 };
if (zoom >= 0.25) return { major: 500, minor: 100 };
return { major: 1000, minor: 200 };
}
// ---------------------------------------------------------------------------
// HorizontalRuler
// ---------------------------------------------------------------------------
export function HorizontalRuler() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
const showRulers = useEditorStore((s) => s.showRulers);
const addGuide = useEditorStore((s) => s.addGuide);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = RULER_SIZE;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
const startPx = -panOffset.x / zoom;
const endPx = (w - panOffset.x) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.font = "9px sans-serif";
ctx.textBaseline = "top";
for (let t = startTick; t <= endPx + minor; t += minor) {
const screenX = t * zoom + panOffset.x;
if (screenX < 0 || screenX > w) continue;
const isMajor = t % major === 0;
const tickHeight = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(screenX, h);
ctx.lineTo(screenX, h - tickHeight);
ctx.lineWidth = isMajor ? 0.8 : 0.4;
ctx.stroke();
if (isMajor) {
ctx.fillText(String(Math.round(t)), screenX + 2, 2);
}
}
// Bottom border
ctx.strokeStyle = TICK_COLOR;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h - 0.5);
ctx.lineTo(w, h - 0.5);
ctx.stroke();
// canvasSize referenced to trigger redraw when canvas dimensions change
void canvasSize;
}, [zoom, panOffset, canvasSize]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [draw]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Drag from ruler to create a horizontal guide
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientY - startY) > 10) {
const canvasY = (me.clientY - startY) / zoom;
addGuide("horizontal", canvasY);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[zoom, addGuide],
);
if (!showRulers) return null;
return (
<canvas
ref={canvasRef}
className={cn("block w-full cursor-col-resize select-none")}
style={{ height: RULER_SIZE }}
onMouseDown={handleMouseDown}
/>
);
}
// ---------------------------------------------------------------------------
// VerticalRuler
// ---------------------------------------------------------------------------
export function VerticalRuler() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
const showRulers = useEditorStore((s) => s.showRulers);
const addGuide = useEditorStore((s) => s.addGuide);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = RULER_SIZE;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
const startPx = -panOffset.y / zoom;
const endPx = (h - panOffset.y) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.font = "9px sans-serif";
for (let t = startTick; t <= endPx + minor; t += minor) {
const screenY = t * zoom + panOffset.y;
if (screenY < 0 || screenY > h) continue;
const isMajor = t % major === 0;
const tickWidth = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(w, screenY);
ctx.lineTo(w - tickWidth, screenY);
ctx.lineWidth = isMajor ? 0.8 : 0.4;
ctx.stroke();
if (isMajor) {
ctx.save();
ctx.translate(3, screenY + 2);
ctx.rotate(-Math.PI / 2);
ctx.textBaseline = "top";
ctx.fillText(String(Math.round(t)), 0, 0);
ctx.restore();
}
}
// Right border
ctx.strokeStyle = TICK_COLOR;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(w - 0.5, 0);
ctx.lineTo(w - 0.5, h);
ctx.stroke();
void canvasSize;
}, [zoom, panOffset, canvasSize]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [draw]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const startX = e.clientX;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 10) {
const canvasX = (me.clientX - startX) / zoom;
addGuide("vertical", canvasX);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[zoom, addGuide],
);
if (!showRulers) return null;
return (
<canvas
ref={canvasRef}
className={cn("block h-full cursor-row-resize select-none")}
style={{ width: RULER_SIZE }}
onMouseDown={handleMouseDown}
/>
);
}
export { RULER_SIZE };
@@ -0,0 +1,208 @@
import { Group, Line } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
import type { SmartGuide } from "@/types/editor";
// ---------------------------------------------------------------------------
// Smart guide calculation utilities (exported for testing)
// ---------------------------------------------------------------------------
const SNAP_THRESHOLD = 5;
interface ObjectBounds {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export function findAlignmentGuides(
dragging: ObjectBounds,
others: ObjectBounds[],
canvasWidth: number,
canvasHeight: number,
threshold = SNAP_THRESHOLD,
): SmartGuide[] {
const guides: SmartGuide[] = [];
const dragEdges = {
left: dragging.x,
right: dragging.x + dragging.width,
centerX: dragging.x + dragging.width / 2,
top: dragging.y,
bottom: dragging.y + dragging.height,
centerY: dragging.y + dragging.height / 2,
};
// Canvas alignment
const canvasTargets = [
{ pos: 0, orient: "vertical" as const, type: "canvas" as const },
{
pos: canvasWidth / 2,
orient: "vertical" as const,
type: "canvas" as const,
},
{ pos: canvasWidth, orient: "vertical" as const, type: "canvas" as const },
{ pos: 0, orient: "horizontal" as const, type: "canvas" as const },
{
pos: canvasHeight / 2,
orient: "horizontal" as const,
type: "canvas" as const,
},
{
pos: canvasHeight,
orient: "horizontal" as const,
type: "canvas" as const,
},
];
for (const ct of canvasTargets) {
const edges =
ct.orient === "vertical"
? [dragEdges.left, dragEdges.centerX, dragEdges.right]
: [dragEdges.top, dragEdges.centerY, dragEdges.bottom];
for (const edge of edges) {
if (Math.abs(edge - ct.pos) < threshold) {
guides.push({
orientation: ct.orient,
position: ct.pos,
type: ct.type,
});
}
}
}
// Object-to-object alignment
for (const other of others) {
if (other.id === dragging.id) continue;
const otherEdges = {
left: other.x,
right: other.x + other.width,
centerX: other.x + other.width / 2,
top: other.y,
bottom: other.y + other.height,
centerY: other.y + other.height / 2,
};
// Vertical guides (x-axis alignment)
const vPairs: [number, number, "edge" | "center"][] = [
[dragEdges.left, otherEdges.left, "edge"],
[dragEdges.left, otherEdges.right, "edge"],
[dragEdges.right, otherEdges.left, "edge"],
[dragEdges.right, otherEdges.right, "edge"],
[dragEdges.centerX, otherEdges.centerX, "center"],
];
for (const [dragVal, otherVal, type] of vPairs) {
if (Math.abs(dragVal - otherVal) < threshold) {
guides.push({ orientation: "vertical", position: otherVal, type });
}
}
// Horizontal guides (y-axis alignment)
const hPairs: [number, number, "edge" | "center"][] = [
[dragEdges.top, otherEdges.top, "edge"],
[dragEdges.top, otherEdges.bottom, "edge"],
[dragEdges.bottom, otherEdges.top, "edge"],
[dragEdges.bottom, otherEdges.bottom, "edge"],
[dragEdges.centerY, otherEdges.centerY, "center"],
];
for (const [dragVal, otherVal, type] of hPairs) {
if (Math.abs(dragVal - otherVal) < threshold) {
guides.push({ orientation: "horizontal", position: otherVal, type });
}
}
}
return guides;
}
export function snapToGuides(
pos: { x: number; y: number },
size: { width: number; height: number },
guides: SmartGuide[],
threshold = SNAP_THRESHOLD,
): { x: number; y: number } {
let { x, y } = pos;
for (const g of guides) {
if (g.orientation === "vertical") {
// Snap left edge, center, or right edge
if (Math.abs(x - g.position) < threshold) {
x = g.position;
} else if (Math.abs(x + size.width / 2 - g.position) < threshold) {
x = g.position - size.width / 2;
} else if (Math.abs(x + size.width - g.position) < threshold) {
x = g.position - size.width;
}
} else {
if (Math.abs(y - g.position) < threshold) {
y = g.position;
} else if (Math.abs(y + size.height / 2 - g.position) < threshold) {
y = g.position - size.height / 2;
} else if (Math.abs(y + size.height - g.position) < threshold) {
y = g.position - size.height;
}
}
}
return { x, y };
}
// ---------------------------------------------------------------------------
// SmartGuidesOverlay -- renders temporary guide lines during drag
// ---------------------------------------------------------------------------
const GUIDE_COLORS = {
edge: "#f43f5e",
center: "#8b5cf6",
canvas: "#22c55e",
};
export function SmartGuidesOverlay({ guides }: { guides: SmartGuide[] }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
if (guides.length === 0) return null;
// Deduplicate guides by position + orientation
const seen = new Set<string>();
const unique = guides.filter((g) => {
const key = `${g.orientation}-${g.position}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return (
<Group listening={false}>
{unique.map((g) => {
const color = GUIDE_COLORS[g.type];
if (g.orientation === "vertical") {
return (
<Line
key={`sg-${g.orientation}-${g.position}`}
points={[g.position, 0, g.position, canvasSize.height]}
stroke={color}
strokeWidth={0.5}
dash={[4, 4]}
listening={false}
/>
);
}
return (
<Line
key={`sg-${g.orientation}-${g.position}`}
points={[0, g.position, canvasSize.width, g.position]}
stroke={color}
strokeWidth={0.5}
dash={[4, 4]}
listening={false}
/>
);
})}
</Group>
);
}
@@ -0,0 +1,177 @@
import { ArrowLeftRight, Check, X } from "lucide-react";
import { useCallback, useState } from "react";
import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// CropOptions -- aspect ratio dropdown, W/H inputs, apply/cancel
// ---------------------------------------------------------------------------
export function CropOptions() {
const cropState = useEditorStore((s) => s.cropState);
const setCropState = useEditorStore((s) => s.setCropState);
const applyCrop = useEditorStore((s) => s.applyCrop);
const [selectedRatio, setSelectedRatio] = useState("Free");
const handleAspectChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const label = e.target.value;
setSelectedRatio(label);
if (!cropState) return;
const preset = ASPECT_RATIOS.find((p) => p.label === label);
if (!preset || !preset.value) {
setCropState({ ...cropState, aspectRatio: null });
return;
}
const ratio = preset.value;
let w = cropState.width;
let h = w / ratio;
if (h > cropState.height * 1.5) {
h = cropState.height;
w = h * ratio;
}
setCropState({ ...cropState, width: w, height: h, aspectRatio: label });
},
[cropState, setCropState],
);
const handleWidthChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!cropState) return;
const w = Math.max(1, Number(e.target.value) || 1);
setCropState({ ...cropState, width: w });
},
[cropState, setCropState],
);
const handleHeightChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!cropState) return;
const h = Math.max(1, Number(e.target.value) || 1);
setCropState({ ...cropState, height: h });
},
[cropState, setCropState],
);
const handleSwap = useCallback(() => {
if (!cropState) return;
setCropState({
...cropState,
width: cropState.height,
height: cropState.width,
});
}, [cropState, setCropState]);
const handleApply = useCallback(() => {
applyCrop();
}, [applyCrop]);
const handleCancel = useCallback(() => {
setCropState(null);
}, [setCropState]);
const inputCn = cn(
"h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
);
return (
<div className="flex items-center gap-3">
{/* Aspect ratio dropdown */}
<div className="flex items-center gap-1.5">
<label htmlFor="crop-aspect" className="text-xs text-muted-foreground">
Ratio:
</label>
<select
id="crop-aspect"
value={selectedRatio}
onChange={handleAspectChange}
className={cn(
"h-6 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
)}
>
{ASPECT_RATIOS.map((r) => (
<option key={r.label} value={r.label}>
{r.label}
</option>
))}
</select>
</div>
<div className="h-4 w-px bg-border" />
{/* Width and Height inputs */}
<div className="flex items-center gap-1.5">
<label htmlFor="crop-width" className="text-xs text-muted-foreground">
W:
</label>
<input
id="crop-width"
type="number"
min={1}
value={cropState ? Math.round(cropState.width) : ""}
onChange={handleWidthChange}
className={inputCn}
/>
</div>
<button
type="button"
onClick={handleSwap}
title="Swap dimensions"
aria-label="Swap dimensions"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeftRight className="h-3.5 w-3.5" />
</button>
<div className="flex items-center gap-1.5">
<label htmlFor="crop-height" className="text-xs text-muted-foreground">
H:
</label>
<input
id="crop-height"
type="number"
min={1}
value={cropState ? Math.round(cropState.height) : ""}
onChange={handleHeightChange}
className={inputCn}
/>
</div>
<div className="h-4 w-px bg-border" />
{/* Apply / Cancel */}
<button
type="button"
onClick={handleApply}
title="Apply Crop (Enter)"
aria-label="Apply Crop"
className={cn(
"flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground",
"hover:bg-primary/90 transition-colors",
)}
>
<Check className="h-3.5 w-3.5" />
Apply
</button>
<button
type="button"
onClick={handleCancel}
title="Cancel Crop (Escape)"
aria-label="Cancel Crop"
className={cn(
"flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
)}
>
<X className="h-3.5 w-3.5" />
Cancel
</button>
</div>
);
}
@@ -0,0 +1,133 @@
import {
AlignCenterHorizontal,
AlignCenterVertical,
AlignEndHorizontal,
AlignEndVertical,
AlignStartHorizontal,
AlignStartVertical,
ArrowLeftRight,
ArrowUpDown,
} from "lucide-react";
import { alignObjects } from "@/components/editor/tools/move-tool";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// MoveOptions -- alignment and distribute buttons in the options bar
// ---------------------------------------------------------------------------
function OptionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={label}
aria-label={label}
className={cn(
"flex h-7 w-7 items-center justify-center rounded",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"disabled:cursor-not-allowed disabled:opacity-40",
"transition-colors",
)}
>
<Icon className="h-4 w-4" />
</button>
);
}
export function MoveOptions() {
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects);
const updateObject = useEditorStore((s) => s.updateObject);
const hasSelection = selectedObjectIds.length >= 2;
const hasThreeOrMore = selectedObjectIds.length >= 3;
const handleAlign = (
direction:
| "left"
| "center-h"
| "right"
| "top"
| "center-v"
| "bottom"
| "distribute-h"
| "distribute-v",
) => {
alignObjects(direction, selectedObjectIds, objects, updateObject);
};
return (
<div className="flex items-center gap-1">
<span className="mr-2 text-xs text-muted-foreground">Align:</span>
<OptionButton
icon={AlignStartHorizontal}
label="Align Left"
onClick={() => handleAlign("left")}
disabled={!hasSelection}
/>
<OptionButton
icon={AlignCenterHorizontal}
label="Align Center Horizontal"
onClick={() => handleAlign("center-h")}
disabled={!hasSelection}
/>
<OptionButton
icon={AlignEndHorizontal}
label="Align Right"
onClick={() => handleAlign("right")}
disabled={!hasSelection}
/>
<div className="mx-1 h-4 w-px bg-border" />
<OptionButton
icon={AlignStartVertical}
label="Align Top"
onClick={() => handleAlign("top")}
disabled={!hasSelection}
/>
<OptionButton
icon={AlignCenterVertical}
label="Align Center Vertical"
onClick={() => handleAlign("center-v")}
disabled={!hasSelection}
/>
<OptionButton
icon={AlignEndVertical}
label="Align Bottom"
onClick={() => handleAlign("bottom")}
disabled={!hasSelection}
/>
<div className="mx-1 h-4 w-px bg-border" />
<span className="mr-1 text-xs text-muted-foreground">Distribute:</span>
<OptionButton
icon={ArrowLeftRight}
label="Distribute Horizontally"
onClick={() => handleAlign("distribute-h")}
disabled={!hasThreeOrMore}
/>
<OptionButton
icon={ArrowUpDown}
label="Distribute Vertically"
onClick={() => handleAlign("distribute-v")}
disabled={!hasThreeOrMore}
/>
</div>
);
}
@@ -0,0 +1,191 @@
import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react";
import { useCallback } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { SelectionMode, SelectionType, ToolType } from "@/types/editor";
// ---------------------------------------------------------------------------
// SelectionOptions -- selection type toggle, mode buttons, feather input
// ---------------------------------------------------------------------------
function ToggleButton({
active,
onClick,
label,
children,
}: {
active: boolean;
onClick: () => void;
label: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
aria-pressed={active}
className={cn(
"flex h-7 items-center justify-center rounded px-2",
"text-xs transition-colors",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{children}
</button>
);
}
export function SelectionOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const setTool = useEditorStore((s) => s.setTool);
const selectionMode = useEditorStore((s) => s.selectionMode);
const setSelectionMode = useEditorStore((s) => s.setSelectionMode);
const selectionType: SelectionType =
activeTool === "marquee-ellipse"
? "ellipse"
: activeTool === "lasso-free" || activeTool === "lasso-poly"
? "lasso"
: "rect";
const handleTypeChange = useCallback(
(type: SelectionType) => {
const toolMap: Record<SelectionType, ToolType> = {
rect: "marquee-rect",
ellipse: "marquee-ellipse",
lasso: "lasso-free",
};
setTool(toolMap[type]);
},
[setTool],
);
const handleModeChange = useCallback(
(mode: SelectionMode) => {
setSelectionMode(mode);
},
[setSelectionMode],
);
const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse";
const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly";
const isMagicWand = activeTool === "magic-wand";
return (
<div className="flex items-center gap-3">
{/* Selection type toggle */}
{!isMagicWand && (
<div className="flex items-center gap-1">
<span className="mr-1 text-xs text-muted-foreground">Type:</span>
<ToggleButton
active={selectionType === "rect" && isMarquee}
onClick={() => handleTypeChange("rect")}
label="Rectangular"
>
<Square className="mr-1 h-3.5 w-3.5" />
Rect
</ToggleButton>
<ToggleButton
active={selectionType === "ellipse" && isMarquee}
onClick={() => handleTypeChange("ellipse")}
label="Elliptical"
>
<Circle className="mr-1 h-3.5 w-3.5" />
Ellipse
</ToggleButton>
<ToggleButton active={isLasso} onClick={() => handleTypeChange("lasso")} label="Lasso">
<PenTool className="mr-1 h-3.5 w-3.5" />
Lasso
</ToggleButton>
</div>
)}
{isMagicWand && (
<div className="flex items-center gap-1">
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Magic Wand</span>
</div>
)}
<div className="h-4 w-px bg-border" />
{/* Selection mode buttons */}
<div className="flex items-center gap-1">
<span className="mr-1 text-xs text-muted-foreground">Mode:</span>
<ToggleButton
active={selectionMode === "new"}
onClick={() => handleModeChange("new")}
label="New Selection"
>
New
</ToggleButton>
<ToggleButton
active={selectionMode === "add"}
onClick={() => handleModeChange("add")}
label="Add to Selection"
>
<Plus className="mr-0.5 h-3 w-3" />
Add
</ToggleButton>
<ToggleButton
active={selectionMode === "subtract"}
onClick={() => handleModeChange("subtract")}
label="Subtract from Selection"
>
<Minus className="mr-0.5 h-3 w-3" />
Sub
</ToggleButton>
</div>
{/* Lasso sub-type toggle */}
{isLasso && (
<>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-1">
<ToggleButton
active={activeTool === "lasso-free"}
onClick={() => setTool("lasso-free")}
label="Freehand Lasso"
>
Freehand
</ToggleButton>
<ToggleButton
active={activeTool === "lasso-poly"}
onClick={() => setTool("lasso-poly")}
label="Polygonal Lasso"
>
Polygonal
</ToggleButton>
</div>
</>
)}
{/* Magic Wand tolerance */}
{isMagicWand && (
<>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<label htmlFor="wand-tolerance" className="text-xs text-muted-foreground">
Tolerance:
</label>
<input
id="wand-tolerance"
type="number"
min={0}
max={255}
defaultValue={32}
className={cn(
"h-6 w-14 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
)}
/>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,142 @@
import { FlipHorizontal2, FlipVertical2, Lock, Unlock } from "lucide-react";
import { useCallback } from "react";
import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// TransformOptions -- X/Y/W/H/rotation inputs, aspect lock, flip buttons
// ---------------------------------------------------------------------------
function NumericInput({
id,
label,
value,
onChange,
min,
max,
step,
}: {
id: string;
label: string;
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
step?: number;
}) {
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = Number(e.target.value);
if (Number.isFinite(v)) onChange(v);
},
[onChange],
);
return (
<div className="flex items-center gap-1">
<label htmlFor={id} className="text-xs text-muted-foreground">
{label}:
</label>
<input
id={id}
type="number"
value={Math.round(value)}
onChange={handleChange}
min={min}
max={max}
step={step ?? 1}
className={cn(
"h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
)}
/>
</div>
);
}
export function TransformOptions({ api }: { api: TransformToolApi }) {
const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical } = api;
return (
<div className="flex items-center gap-3">
<NumericInput
id="transform-x"
label="X"
value={values.x}
onChange={(v) => setValues({ x: v })}
/>
<NumericInput
id="transform-y"
label="Y"
value={values.y}
onChange={(v) => setValues({ y: v })}
/>
<div className="h-4 w-px bg-border" />
<NumericInput
id="transform-w"
label="W"
value={values.width}
min={1}
onChange={(v) => setValues({ width: v })}
/>
<button
type="button"
onClick={() => setLockedAspect(!lockedAspect)}
title={lockedAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-label={lockedAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-pressed={lockedAspect}
className={cn(
"flex h-6 w-6 items-center justify-center rounded transition-colors",
lockedAspect
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{lockedAspect ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
</button>
<NumericInput
id="transform-h"
label="H"
value={values.height}
min={1}
onChange={(v) => setValues({ height: v })}
/>
<div className="h-4 w-px bg-border" />
<NumericInput
id="transform-rotation"
label="Rotation"
value={values.rotation}
min={-360}
max={360}
onChange={(v) => setValues({ rotation: v })}
/>
<div className="h-4 w-px bg-border" />
<button
type="button"
onClick={flipHorizontal}
title="Flip Horizontal"
aria-label="Flip Horizontal"
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<FlipHorizontal2 className="h-4 w-4" />
</button>
<button
type="button"
onClick={flipVertical}
title="Flip Vertical"
aria-label="Flip Vertical"
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<FlipVertical2 className="h-4 w-4" />
</button>
</div>
);
}
@@ -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}
/>
);
}