feat: add selection, crop, transform tools and supporting infrastructure for image editor

Implements Agent 2 deliverables for the image editor: move tool with Konva
Transformer and smart guide snapping, rectangular/elliptical marquee and
lasso selection with marching ants animation, magic wand flood fill, crop
tool with darkened overlay and rule-of-thirds grid, free transform with
numeric inputs, context menu with z-ordering and clipboard operations,
rulers with drag-to-create guides, smart alignment guides, and canvas/image
resize dialogs. Also creates the shared editor types and Zustand store with
full layer management, z-ordering, clipboard, guide, and document operations.
This commit is contained in:
SnapOtter
2026-05-06 23:29:29 +08:00
parent c9b24ada78
commit 01dcfa624b
18 changed files with 4452 additions and 1 deletions
+5 -1
View File
@@ -12,25 +12,29 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@snapotter/shared": "workspace:*",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@sentry/react": "^10.49.0",
"@snapotter/shared": "workspace:*",
"@use-gesture/react": "^10.3.1",
"clsx": "^2.1.0",
"fflate": "^0.8.2",
"jszip": "^3.10.1",
"konva": "^10",
"leaflet": "^1.9.4",
"lucide-react": "^0.469.0",
"posthog-js": "^1.370.0",
"qr-code-styling": "^1.9.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hotkeys-hook": "^5.3.2",
"react-image-crop": "^11.0.10",
"react-konva": "^19",
"react-router-dom": "^7.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"use-image": "^1.1.4",
"zustand": "^5.0.0"
},
"devDependencies": {
@@ -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}
/>
);
}
+706
View File
@@ -0,0 +1,706 @@
import { create } from "zustand";
import { generateId } from "@/lib/utils";
import type {
AdjustmentValues,
AnchorPosition,
CropState,
EditorLayer,
EditorState,
GuideOrientation,
SelectionMode,
SelectionState,
ToolType,
} from "@/types/editor";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createDefaultLayer(name = "Layer 1"): EditorLayer {
return {
id: generateId(),
name,
visible: true,
locked: false,
opacity: 1,
blendMode: "source-over",
thumbnail: null,
};
}
const DEFAULT_ADJUSTMENTS: AdjustmentValues = {
brightness: 0,
contrast: 0,
hue: 0,
saturation: 0,
luminance: 0,
exposure: 0,
vibrance: 0,
warmth: 0,
};
function nextLayerName(layers: EditorLayer[]): string {
const max = layers.reduce((n, l) => {
const m = l.name.match(/^Layer (\d+)/);
return m ? Math.max(n, Number(m[1])) : n;
}, 0);
return `Layer ${max + 1}`;
}
// ---------------------------------------------------------------------------
// Anchor offset calculation for canvas resize
// ---------------------------------------------------------------------------
function anchorOffset(
anchor: AnchorPosition,
oldW: number,
oldH: number,
newW: number,
newH: number,
): { dx: number; dy: number } {
const dw = newW - oldW;
const dh = newH - oldH;
let dx = 0;
let dy = 0;
if (anchor.includes("center") && !anchor.includes("left") && !anchor.includes("right")) {
dx = dw / 2;
} else if (anchor.includes("right")) {
dx = dw;
}
if (anchor === "center" || anchor === "center-left" || anchor === "center-right") {
dy = dh / 2;
} else if (anchor.startsWith("bottom")) {
dy = dh;
}
return { dx, dy };
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const defaultLayer = createDefaultLayer();
export const useEditorStore = create<EditorState>()((set, get) => ({
// Canvas
canvasSize: { width: 1920, height: 1080 },
zoom: 1,
panOffset: { x: 0, y: 0 },
// Image
sourceImageUrl: null,
sourceImageSize: null,
// Active tool
activeTool: "move",
previousTool: null,
// Brush/Eraser
brushSize: 12,
brushOpacity: 1,
brushHardness: 1,
// Colors
foregroundColor: "#000000",
backgroundColor: "#ffffff",
recentColors: [],
// Layers
layers: [defaultLayer],
activeLayerId: defaultLayer.id,
// Objects
objects: [],
selectedObjectIds: [],
clipboard: [],
// Selection
selection: null,
selectionMode: "new" as SelectionMode,
// Crop
cropState: null,
isCropping: false,
// Adjustments & Filters
adjustments: { ...DEFAULT_ADJUSTMENTS },
filters: [],
// Text
editingTextId: null,
// Shape settings
shapeFill: "#3b82f6",
shapeStroke: "#000000",
shapeStrokeWidth: 2,
shapeCornerRadius: 0,
shapePolygonSides: 6,
shapeStarPoints: 5,
// Guides
guides: [],
showRulers: true,
showGuides: true,
snapToGuides: true,
// UI
rightPanelTab: "layers",
rightPanelVisible: true,
// Document
isDirty: false,
// History
lastAction: "",
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
setTool: (tool: ToolType) => set((s) => ({ activeTool: tool, previousTool: s.activeTool })),
setZoom: (zoom: number) => set({ zoom: Math.max(0.01, Math.min(64, zoom)) }),
setPanOffset: (offset) => set({ panOffset: offset }),
loadImage: (url, width, height) =>
set({
sourceImageUrl: url,
sourceImageSize: { width, height },
canvasSize: { width, height },
zoom: 1,
panOffset: { x: 0, y: 0 },
isDirty: false,
}),
// -- Colors --
setForegroundColor: (color) =>
set((s) => {
const recent = [color, ...s.recentColors.filter((c) => c !== color)].slice(0, 12);
return { foregroundColor: color, recentColors: recent };
}),
setBackgroundColor: (color) => set({ backgroundColor: color }),
swapColors: () =>
set((s) => ({
foregroundColor: s.backgroundColor,
backgroundColor: s.foregroundColor,
})),
resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }),
// -- Objects --
addObject: (obj) =>
set((s) => ({
objects: [...s.objects, { ...obj, layerId: s.activeLayerId }],
isDirty: true,
lastAction: "Add Object",
})),
updateObject: (id, attrs) =>
set((s) => ({
objects: s.objects.map((o) => (o.id === id ? { ...o, attrs: { ...o.attrs, ...attrs } } : o)),
isDirty: true,
})),
removeObjects: (ids) =>
set((s) => ({
objects: s.objects.filter((o) => !ids.includes(o.id)),
selectedObjectIds: s.selectedObjectIds.filter((i) => !ids.includes(i)),
isDirty: true,
lastAction: "Delete Object",
})),
setSelectedObjects: (ids) => set({ selectedObjectIds: ids }),
duplicateObjects: (ids) => {
const s = get();
const dupes = s.objects
.filter((o) => ids.includes(o.id))
.map((o) => ({
...o,
id: generateId(),
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) + 10,
y: ((o.attrs.y as number) ?? 0) + 10,
},
}));
set({
objects: [...s.objects, ...dupes],
selectedObjectIds: dupes.map((d) => d.id),
isDirty: true,
lastAction: "Duplicate",
});
},
// -- Z-ordering --
bringToFront: (id) =>
set((s) => {
const obj = s.objects.find((o) => o.id === id);
if (!obj) return s;
const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id);
const others = s.objects.filter((o) => o.layerId !== obj.layerId);
return {
objects: [...others, ...layerObjs, obj],
isDirty: true,
lastAction: "Bring to Front",
};
}),
bringForward: (id) =>
set((s) => {
const idx = s.objects.findIndex((o) => o.id === id);
if (idx === -1) return s;
const obj = s.objects[idx];
// Find the next object in the same layer
let nextIdx = -1;
for (let i = idx + 1; i < s.objects.length; i++) {
if (s.objects[i].layerId === obj.layerId) {
nextIdx = i;
break;
}
}
if (nextIdx === -1) return s;
const copy = [...s.objects];
copy.splice(idx, 1);
copy.splice(nextIdx, 0, obj);
return { objects: copy, isDirty: true, lastAction: "Bring Forward" };
}),
sendBackward: (id) =>
set((s) => {
const idx = s.objects.findIndex((o) => o.id === id);
if (idx === -1) return s;
const obj = s.objects[idx];
let prevIdx = -1;
for (let i = idx - 1; i >= 0; i--) {
if (s.objects[i].layerId === obj.layerId) {
prevIdx = i;
break;
}
}
if (prevIdx === -1) return s;
const copy = [...s.objects];
copy.splice(idx, 1);
copy.splice(prevIdx, 0, obj);
return { objects: copy, isDirty: true, lastAction: "Send Backward" };
}),
sendToBack: (id) =>
set((s) => {
const obj = s.objects.find((o) => o.id === id);
if (!obj) return s;
const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id);
const others = s.objects.filter((o) => o.layerId !== obj.layerId);
return {
objects: [...others, obj, ...layerObjs],
isDirty: true,
lastAction: "Send to Back",
};
}),
// -- Clipboard --
copyObjects: (ids) => {
const s = get();
const copied = s.objects.filter((o) => ids.includes(o.id));
set({ clipboard: copied });
},
cutObjects: (ids) => {
const s = get();
const copied = s.objects.filter((o) => ids.includes(o.id));
set({
clipboard: copied,
objects: s.objects.filter((o) => !ids.includes(o.id)),
selectedObjectIds: [],
isDirty: true,
lastAction: "Cut",
});
},
pasteObjects: () => {
const s = get();
if (s.clipboard.length === 0) return;
const pasted = s.clipboard.map((o) => ({
...o,
id: generateId(),
layerId: s.activeLayerId,
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) + 10,
y: ((o.attrs.y as number) ?? 0) + 10,
},
}));
set({
objects: [...s.objects, ...pasted],
selectedObjectIds: pasted.map((p) => p.id),
isDirty: true,
lastAction: "Paste",
});
},
// -- Layers --
addLayer: () => {
const s = get();
const layer = createDefaultLayer(nextLayerName(s.layers));
const idx = s.layers.findIndex((l) => l.id === s.activeLayerId);
const copy = [...s.layers];
copy.splice(idx + 1, 0, layer);
set({
layers: copy,
activeLayerId: layer.id,
isDirty: true,
lastAction: "Add Layer",
});
},
removeLayer: (id) =>
set((s) => {
if (s.layers.length <= 1) return s;
const idx = s.layers.findIndex((l) => l.id === id);
const remaining = s.layers.filter((l) => l.id !== id);
const newActive =
s.activeLayerId === id
? remaining[Math.min(idx, remaining.length - 1)].id
: s.activeLayerId;
return {
layers: remaining,
objects: s.objects.filter((o) => o.layerId !== id),
activeLayerId: newActive,
isDirty: true,
lastAction: "Delete Layer",
};
}),
duplicateLayer: (id) => {
const s = get();
const src = s.layers.find((l) => l.id === id);
if (!src) return;
const newId = generateId();
const dup: EditorLayer = {
...src,
id: newId,
name: `${src.name} (copy)`,
thumbnail: null,
};
const dupeObjs = s.objects
.filter((o) => o.layerId === id)
.map((o) => ({ ...o, id: generateId(), layerId: newId }));
const idx = s.layers.findIndex((l) => l.id === id);
const copy = [...s.layers];
copy.splice(idx + 1, 0, dup);
set({
layers: copy,
objects: [...s.objects, ...dupeObjs],
activeLayerId: newId,
isDirty: true,
lastAction: "Duplicate Layer",
});
},
setActiveLayer: (id) => set({ activeLayerId: id }),
updateLayer: (id, updates) =>
set((s) => ({
layers: s.layers.map((l) => (l.id === id ? { ...l, ...updates } : l)),
isDirty: true,
})),
reorderLayers: (fromIndex, toIndex) =>
set((s) => {
const copy = [...s.layers];
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return { layers: copy, isDirty: true, lastAction: "Reorder Layers" };
}),
mergeDown: (id) =>
set((s) => {
const idx = s.layers.findIndex((l) => l.id === id);
if (idx <= 0) return s;
const below = s.layers[idx - 1];
const merged = s.objects.map((o) => (o.layerId === id ? { ...o, layerId: below.id } : o));
return {
layers: s.layers.filter((l) => l.id !== id),
objects: merged,
activeLayerId: below.id,
isDirty: true,
lastAction: "Merge Down",
};
}),
flattenAll: () =>
set((s) => {
const first = s.layers[0];
if (!first) return s;
return {
layers: [{ ...first, name: "Layer 1" }],
objects: s.objects.map((o) => ({ ...o, layerId: first.id })),
activeLayerId: first.id,
isDirty: true,
lastAction: "Flatten All",
};
}),
// -- Adjustments & Filters --
setAdjustment: (key, value) =>
set((s) => ({
adjustments: { ...s.adjustments, [key]: value },
isDirty: true,
})),
resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }),
toggleFilter: (type) =>
set((s) => {
const existing = s.filters.find((f) => f.type === type);
if (existing) {
return {
filters: s.filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
isDirty: true,
};
}
return {
filters: [...s.filters, { type, enabled: true, params: {} }],
isDirty: true,
};
}),
setFilterParam: (type, key, value) =>
set((s) => ({
filters: s.filters.map((f) =>
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
),
isDirty: true,
})),
// -- Selection --
setSelection: (selection: SelectionState | null) => set({ selection }),
setSelectionMode: (mode: SelectionMode) => set({ selectionMode: mode }),
invertSelection: () =>
set((s) => {
if (!s.selection) return s;
// Invert: swap to full canvas bounds minus current selection
return {
selection: {
...s.selection,
bounds: {
x: 0,
y: 0,
width: s.canvasSize.width,
height: s.canvasSize.height,
},
},
isDirty: true,
};
}),
// -- Crop --
setCropState: (state: CropState | null) => set({ cropState: state, isCropping: state !== null }),
applyCrop: () =>
set((s) => {
if (!s.cropState) return s;
const { x, y, width, height } = s.cropState;
return {
canvasSize: { width, height },
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) - x,
y: ((o.attrs.y as number) ?? 0) - y,
},
})),
cropState: null,
isCropping: false,
isDirty: true,
lastAction: "Crop",
};
}),
// -- Brush --
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(500, size)) }),
setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }),
setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }),
// -- Guides --
addGuide: (orientation: GuideOrientation, position: number) =>
set((s) => ({
guides: [...s.guides, { id: generateId(), orientation, position }],
})),
removeGuide: (id) => set((s) => ({ guides: s.guides.filter((g) => g.id !== id) })),
updateGuide: (id, position) =>
set((s) => ({
guides: s.guides.map((g) => (g.id === id ? { ...g, position } : g)),
})),
toggleRulers: () => set((s) => ({ showRulers: !s.showRulers })),
toggleGuides: () => set((s) => ({ showGuides: !s.showGuides })),
toggleSnapping: () => set((s) => ({ snapToGuides: !s.snapToGuides })),
// -- Document operations --
resizeCanvas: (width, height, anchor, _fill) =>
set((s) => {
const { dx, dy } = anchorOffset(
anchor,
s.canvasSize.width,
s.canvasSize.height,
width,
height,
);
return {
canvasSize: { width, height },
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) + dx,
y: ((o.attrs.y as number) ?? 0) + dy,
},
})),
isDirty: true,
lastAction: "Resize Canvas",
};
}),
resizeImage: (width, height) =>
set((s) => {
const sx = width / s.canvasSize.width;
const sy = height / s.canvasSize.height;
return {
canvasSize: { width, height },
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) * sx,
y: ((o.attrs.y as number) ?? 0) * sy,
width: o.attrs.width ? (o.attrs.width as number) * sx : undefined,
height: o.attrs.height ? (o.attrs.height as number) * sy : undefined,
},
})),
isDirty: true,
lastAction: "Resize Image",
};
}),
rotateCanvas: (degrees) =>
set((s) => {
const { width: w, height: h } = s.canvasSize;
const swap = degrees === 90 || degrees === 270;
const nw = swap ? h : w;
const nh = swap ? w : h;
return {
canvasSize: { width: nw, height: nh },
objects: s.objects.map((o) => {
const ox = (o.attrs.x as number) ?? 0;
const oy = (o.attrs.y as number) ?? 0;
let nx: number;
let ny: number;
if (degrees === 90) {
nx = h - oy;
ny = ox;
} else if (degrees === 180) {
nx = w - ox;
ny = h - oy;
} else {
nx = oy;
ny = w - ox;
}
return { ...o, attrs: { ...o.attrs, x: nx, y: ny } };
}),
isDirty: true,
lastAction: "Rotate Canvas",
};
}),
flipCanvasHorizontal: () =>
set((s) => ({
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
x: s.canvasSize.width - ((o.attrs.x as number) ?? 0),
scaleX: -((o.attrs.scaleX as number) ?? 1),
},
})),
isDirty: true,
lastAction: "Flip Horizontal",
})),
flipCanvasVertical: () =>
set((s) => ({
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
y: s.canvasSize.height - ((o.attrs.y as number) ?? 0),
scaleY: -((o.attrs.scaleY as number) ?? 1),
},
})),
isDirty: true,
lastAction: "Flip Vertical",
})),
trimCanvas: () =>
set((s) => {
if (s.objects.length === 0) return s;
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
for (const o of s.objects) {
const x = (o.attrs.x as number) ?? 0;
const y = (o.attrs.y as number) ?? 0;
const w = (o.attrs.width as number) ?? 0;
const h = (o.attrs.height as number) ?? 0;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
}
const tw = Math.max(1, maxX - minX);
const th = Math.max(1, maxY - minY);
return {
canvasSize: { width: tw, height: th },
objects: s.objects.map((o) => ({
...o,
attrs: {
...o.attrs,
x: ((o.attrs.x as number) ?? 0) - minX,
y: ((o.attrs.y as number) ?? 0) - minY,
},
})),
isDirty: true,
lastAction: "Trim Canvas",
};
}),
// -- UI --
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
toggleRightPanel: () => set((s) => ({ rightPanelVisible: !s.rightPanelVisible })),
markDirty: () => set({ isDirty: true }),
markClean: () => set({ isDirty: false }),
}));
+279
View File
@@ -0,0 +1,279 @@
// ---------------------------------------------------------------------------
// Editor Types -- shared across all editor components and the Zustand store
// ---------------------------------------------------------------------------
export type ToolType =
| "move"
| "marquee-rect"
| "marquee-ellipse"
| "lasso-free"
| "lasso-poly"
| "magic-wand"
| "crop"
| "eyedropper"
| "brush"
| "eraser"
| "pencil"
| "fill"
| "gradient"
| "shape-rect"
| "shape-ellipse"
| "shape-line"
| "shape-arrow"
| "shape-polygon"
| "shape-star"
| "text"
| "hand"
| "zoom"
| "transform";
export type CanvasObjectType =
| "line"
| "rect"
| "ellipse"
| "polygon"
| "star"
| "arrow"
| "text"
| "image"
| "path";
export interface CanvasObject {
id: string;
type: CanvasObjectType;
layerId: string;
attrs: Record<string, unknown>;
}
export interface EditorLayer {
id: string;
name: string;
visible: boolean;
locked: boolean;
opacity: number;
blendMode: string;
thumbnail: string | null;
}
export type SelectionType = "rect" | "ellipse" | "lasso";
export interface SelectionState {
type: SelectionType;
points: number[];
bounds: { x: number; y: number; width: number; height: number };
}
export type SelectionMode = "new" | "add" | "subtract";
export interface CropState {
x: number;
y: number;
width: number;
height: number;
aspectRatio: string | null;
}
export interface AdjustmentValues {
brightness: number;
contrast: number;
hue: number;
saturation: number;
luminance: number;
exposure: number;
vibrance: number;
warmth: number;
}
export interface FilterConfig {
type: string;
enabled: boolean;
params: Record<string, number>;
}
export type GuideOrientation = "horizontal" | "vertical";
export interface Guide {
id: string;
orientation: GuideOrientation;
position: number;
}
export type AnchorPosition =
| "top-left"
| "top-center"
| "top-right"
| "center-left"
| "center"
| "center-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
export type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos";
export interface SmartGuide {
orientation: GuideOrientation;
position: number;
type: "edge" | "center" | "canvas";
}
export interface EditorState {
// Canvas
canvasSize: { width: number; height: number };
zoom: number;
panOffset: { x: number; y: number };
// Image
sourceImageUrl: string | null;
sourceImageSize: { width: number; height: number } | null;
// Active tool
activeTool: ToolType;
previousTool: ToolType | null;
// Brush/Eraser settings
brushSize: number;
brushOpacity: number;
brushHardness: number;
// Colors
foregroundColor: string;
backgroundColor: string;
recentColors: string[];
// Layers
layers: EditorLayer[];
activeLayerId: string;
// Objects
objects: CanvasObject[];
selectedObjectIds: string[];
clipboard: CanvasObject[];
// Selection
selection: SelectionState | null;
selectionMode: SelectionMode;
// Crop
cropState: CropState | null;
isCropping: boolean;
// Adjustments & Filters
adjustments: AdjustmentValues;
filters: FilterConfig[];
// Text
editingTextId: string | null;
// Shape settings
shapeFill: string;
shapeStroke: string;
shapeStrokeWidth: number;
shapeCornerRadius: number;
shapePolygonSides: number;
shapeStarPoints: number;
// Guides
guides: Guide[];
showRulers: boolean;
showGuides: boolean;
snapToGuides: boolean;
// UI
rightPanelTab: "layers" | "adjustments" | "history";
rightPanelVisible: boolean;
// Document
isDirty: boolean;
// History tracking
lastAction: string;
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
// Tool
setTool: (tool: ToolType) => void;
// Canvas
setZoom: (zoom: number) => void;
setPanOffset: (offset: { x: number; y: number }) => void;
loadImage: (url: string, width: number, height: number) => void;
// Colors
setForegroundColor: (color: string) => void;
setBackgroundColor: (color: string) => void;
swapColors: () => void;
resetColors: () => void;
// Objects
addObject: (obj: CanvasObject) => void;
updateObject: (id: string, attrs: Partial<CanvasObject["attrs"]>) => void;
removeObjects: (ids: string[]) => void;
setSelectedObjects: (ids: string[]) => void;
duplicateObjects: (ids: string[]) => void;
// Z-ordering
bringToFront: (id: string) => void;
bringForward: (id: string) => void;
sendBackward: (id: string) => void;
sendToBack: (id: string) => void;
// Clipboard
copyObjects: (ids: string[]) => void;
cutObjects: (ids: string[]) => void;
pasteObjects: () => void;
// Layers
addLayer: () => void;
removeLayer: (id: string) => void;
duplicateLayer: (id: string) => void;
setActiveLayer: (id: string) => void;
updateLayer: (id: string, updates: Partial<EditorLayer>) => void;
reorderLayers: (fromIndex: number, toIndex: number) => void;
mergeDown: (id: string) => void;
flattenAll: () => void;
// Adjustments & Filters
setAdjustment: (key: keyof AdjustmentValues, value: number) => void;
resetAdjustments: () => void;
toggleFilter: (type: string) => void;
setFilterParam: (type: string, key: string, value: number) => void;
// Selection
setSelection: (selection: SelectionState | null) => void;
setSelectionMode: (mode: SelectionMode) => void;
invertSelection: () => void;
// Crop
setCropState: (state: CropState | null) => void;
applyCrop: () => void;
// Brush
setBrushSize: (size: number) => void;
setBrushOpacity: (opacity: number) => void;
setBrushHardness: (hardness: number) => void;
// Guides
addGuide: (orientation: GuideOrientation, position: number) => void;
removeGuide: (id: string) => void;
updateGuide: (id: string, position: number) => void;
toggleRulers: () => void;
toggleGuides: () => void;
toggleSnapping: () => void;
// Document operations
resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill: string) => void;
resizeImage: (width: number, height: number) => void;
rotateCanvas: (degrees: 90 | 180 | 270) => void;
flipCanvasHorizontal: () => void;
flipCanvasVertical: () => void;
trimCanvas: () => void;
// UI
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
toggleRightPanel: () => void;
markDirty: () => void;
markClean: () => void;
}
+99
View File
@@ -278,6 +278,9 @@ importers:
jszip:
specifier: ^3.10.1
version: 3.10.1
konva:
specifier: ^10
version: 10.3.0
leaflet:
specifier: ^1.9.4
version: 1.9.4
@@ -296,9 +299,15 @@ importers:
react-dom:
specifier: ^19.0.0
version: 19.2.4(react@19.2.4)
react-hotkeys-hook:
specifier: ^5.3.2
version: 5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@19.2.4)
react-konva:
specifier: ^19
version: 19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-router-dom:
specifier: ^7.1.0
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -308,6 +317,9 @@ importers:
tailwind-merge:
specifier: ^2.6.0
version: 2.6.1
use-image:
specifier: ^1.1.4
version: 1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
zustand:
specifier: ^5.0.0
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
@@ -3400,6 +3412,16 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-reconciler@0.28.9':
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
peerDependencies:
'@types/react': '*'
'@types/react-reconciler@0.33.0':
resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==}
peerDependencies:
'@types/react': '*'
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
@@ -4852,6 +4874,11 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
its-fine@2.0.0:
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
peerDependencies:
react: ^19.0.0
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
@@ -4944,6 +4971,9 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
konva@10.3.0:
resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==}
lazystream@1.0.1:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
@@ -5889,6 +5919,12 @@ packages:
peerDependencies:
react: ^19.2.4
react-hotkeys-hook@5.3.2:
resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
@@ -5897,6 +5933,19 @@ packages:
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-konva@19.2.3:
resolution: {integrity: sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==}
peerDependencies:
konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0
react: ^19.2.0
react-dom: ^19.2.0
react-reconciler@0.33.0:
resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
engines: {node: '>=0.10.0'}
peerDependencies:
react: ^19.2.0
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -6608,6 +6657,12 @@ packages:
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
use-image@1.1.4:
resolution: {integrity: sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
utif@2.0.1:
resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==}
@@ -9771,6 +9826,14 @@ snapshots:
dependencies:
'@types/react': 19.2.14
'@types/react-reconciler@0.28.9(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
'@types/react-reconciler@0.33.0(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
'@types/react@19.2.14':
dependencies:
csstype: 3.2.3
@@ -11265,6 +11328,13 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.14)
react: 19.2.4
transitivePeerDependencies:
- '@types/react'
jackspeak@3.4.3:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -11377,6 +11447,8 @@ snapshots:
kind-of@6.0.3: {}
konva@10.3.0: {}
lazystream@1.0.1:
dependencies:
readable-stream: 2.3.8
@@ -12334,12 +12406,34 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-hotkeys-hook@5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-image-crop@11.0.10(react@19.2.4):
dependencies:
react: 19.2.4
react-is@17.0.2: {}
react-konva@19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@types/react-reconciler': 0.33.0(@types/react@19.2.14)
its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4)
konva: 10.3.0
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-reconciler: 0.33.0(react@19.2.4)
scheduler: 0.27.0
transitivePeerDependencies:
- '@types/react'
react-reconciler@0.33.0(react@19.2.4):
dependencies:
react: 19.2.4
scheduler: 0.27.0
react-refresh@0.17.0: {}
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -13159,6 +13253,11 @@ snapshots:
url-join@5.0.0: {}
use-image@1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
utif@2.0.1:
dependencies:
pako: 1.0.11