From 01dcfa624bd18a9d2b4173b28756baf847355662 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:29:29 +0800 Subject: [PATCH] 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. --- apps/web/package.json | 6 +- .../editor/common/canvas-resize-dialog.tsx | 170 +++++ .../components/editor/common/context-menu.tsx | 272 +++++++ .../components/editor/common/guide-lines.tsx | 110 +++ .../editor/common/image-resize-dialog.tsx | 198 +++++ .../src/components/editor/common/rulers.tsx | 253 +++++++ .../components/editor/common/smart-guides.tsx | 208 ++++++ .../editor/options/crop-options.tsx | 177 +++++ .../editor/options/move-options.tsx | 133 ++++ .../editor/options/selection-options.tsx | 191 +++++ .../editor/options/transform-options.tsx | 142 ++++ .../src/components/editor/tools/crop-tool.tsx | 283 +++++++ .../src/components/editor/tools/move-tool.tsx | 428 +++++++++++ .../editor/tools/selection-tool.tsx | 561 ++++++++++++++ .../editor/tools/transform-tool.tsx | 237 ++++++ apps/web/src/stores/editor-store.ts | 706 ++++++++++++++++++ apps/web/src/types/editor.ts | 279 +++++++ pnpm-lock.yaml | 99 +++ 18 files changed, 4452 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/editor/common/canvas-resize-dialog.tsx create mode 100644 apps/web/src/components/editor/common/context-menu.tsx create mode 100644 apps/web/src/components/editor/common/guide-lines.tsx create mode 100644 apps/web/src/components/editor/common/image-resize-dialog.tsx create mode 100644 apps/web/src/components/editor/common/rulers.tsx create mode 100644 apps/web/src/components/editor/common/smart-guides.tsx create mode 100644 apps/web/src/components/editor/options/crop-options.tsx create mode 100644 apps/web/src/components/editor/options/move-options.tsx create mode 100644 apps/web/src/components/editor/options/selection-options.tsx create mode 100644 apps/web/src/components/editor/options/transform-options.tsx create mode 100644 apps/web/src/components/editor/tools/crop-tool.tsx create mode 100644 apps/web/src/components/editor/tools/move-tool.tsx create mode 100644 apps/web/src/components/editor/tools/selection-tool.tsx create mode 100644 apps/web/src/components/editor/tools/transform-tool.tsx create mode 100644 apps/web/src/stores/editor-store.ts create mode 100644 apps/web/src/types/editor.ts diff --git a/apps/web/package.json b/apps/web/package.json index d0db389a..56839c4f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/components/editor/common/canvas-resize-dialog.tsx b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx new file mode 100644 index 00000000..3deac2a6 --- /dev/null +++ b/apps/web/src/components/editor/common/canvas-resize-dialog.tsx @@ -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("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 ( +
+
+ {/* Header */} +
+

Canvas Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Current: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Height */} +
+
+ + setWidth(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + setHeight(Math.max(1, Number(e.target.value) || 1))} + className={inputCn} + /> +
+
+ + {/* Anchor grid */} +
+

Anchor:

+
+ {ANCHOR_POSITIONS.map((pos) => ( +
+
+ + {/* Background fill */} +
+ + setFill(e.target.value)} + className="h-7 w-7 cursor-pointer rounded border border-border" + /> + 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", + )} + /> +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/context-menu.tsx b/apps/web/src/components/editor/common/context-menu.tsx new file mode 100644 index 00000000..ca8d599a --- /dev/null +++ b/apps/web/src/components/editor/common/context-menu.tsx @@ -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(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(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 ( +
+ {items.map((item) => ( +
+ + {item.dividerAfter &&
} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/editor/common/guide-lines.tsx b/apps/web/src/components/editor/common/guide-lines.tsx new file mode 100644 index 00000000..39b47442 --- /dev/null +++ b/apps/web/src/components/editor/common/guide-lines.tsx @@ -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 ( + + {guides.map((guide) => ( + updateGuide(guide.id, pos)} + onRemove={() => removeGuide(guide.id)} + /> + ))} + + ); +} + +// --------------------------------------------------------------------------- +// 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(null); + + const isHorizontal = orientation === "horizontal"; + + const points = isHorizontal + ? [0, position, canvasWidth, position] + : [position, 0, position, canvasHeight]; + + const handleDragEnd = useCallback( + (e: Konva.KonvaEventObject) => { + 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 ( + { + // 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} + /> + ); +} diff --git a/apps/web/src/components/editor/common/image-resize-dialog.tsx b/apps/web/src/components/editor/common/image-resize-dialog.tsx new file mode 100644 index 00000000..980a1d99 --- /dev/null +++ b/apps/web/src/components/editor/common/image-resize-dialog.tsx @@ -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("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) => { + 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) => { + 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 ( +
+
+ {/* Header */} +
+

Image Size

+ +
+ + {/* Body */} +
+ {/* Current size info */} +

+ Original: {canvasSize.width} x {canvasSize.height} px +

+ + {/* Width / Lock / Height */} +
+
+ + +

{pctWidth}%

+
+ + + +
+ + +

{pctHeight}%

+
+
+ + {/* Resampling method */} +
+ + +
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/editor/common/rulers.tsx b/apps/web/src/components/editor/common/rulers.tsx new file mode 100644 index 00000000..6be03c1b --- /dev/null +++ b/apps/web/src/components/editor/common/rulers.tsx @@ -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(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 ( + + ); +} + +// --------------------------------------------------------------------------- +// VerticalRuler +// --------------------------------------------------------------------------- + +export function VerticalRuler() { + const canvasRef = useRef(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 ( + + ); +} + +export { RULER_SIZE }; diff --git a/apps/web/src/components/editor/common/smart-guides.tsx b/apps/web/src/components/editor/common/smart-guides.tsx new file mode 100644 index 00000000..01439a60 --- /dev/null +++ b/apps/web/src/components/editor/common/smart-guides.tsx @@ -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(); + const unique = guides.filter((g) => { + const key = `${g.orientation}-${g.position}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + return ( + + {unique.map((g) => { + const color = GUIDE_COLORS[g.type]; + if (g.orientation === "vertical") { + return ( + + ); + } + return ( + + ); + })} + + ); +} diff --git a/apps/web/src/components/editor/options/crop-options.tsx b/apps/web/src/components/editor/options/crop-options.tsx new file mode 100644 index 00000000..05a5cc66 --- /dev/null +++ b/apps/web/src/components/editor/options/crop-options.tsx @@ -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) => { + 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) => { + 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) => { + 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 ( +
+ {/* Aspect ratio dropdown */} +
+ + +
+ +
+ + {/* Width and Height inputs */} +
+ + +
+ + + +
+ + +
+ +
+ + {/* Apply / Cancel */} + + +
+ ); +} diff --git a/apps/web/src/components/editor/options/move-options.tsx b/apps/web/src/components/editor/options/move-options.tsx new file mode 100644 index 00000000..4f432a91 --- /dev/null +++ b/apps/web/src/components/editor/options/move-options.tsx @@ -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 ( + + ); +} + +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 ( +
+ Align: + + handleAlign("left")} + disabled={!hasSelection} + /> + handleAlign("center-h")} + disabled={!hasSelection} + /> + handleAlign("right")} + disabled={!hasSelection} + /> + +
+ + handleAlign("top")} + disabled={!hasSelection} + /> + handleAlign("center-v")} + disabled={!hasSelection} + /> + handleAlign("bottom")} + disabled={!hasSelection} + /> + +
+ + Distribute: + + handleAlign("distribute-h")} + disabled={!hasThreeOrMore} + /> + handleAlign("distribute-v")} + disabled={!hasThreeOrMore} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/options/selection-options.tsx b/apps/web/src/components/editor/options/selection-options.tsx new file mode 100644 index 00000000..9ba03596 --- /dev/null +++ b/apps/web/src/components/editor/options/selection-options.tsx @@ -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 ( + + ); +} + +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 = { + 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 ( +
+ {/* Selection type toggle */} + {!isMagicWand && ( +
+ Type: + handleTypeChange("rect")} + label="Rectangular" + > + + Rect + + handleTypeChange("ellipse")} + label="Elliptical" + > + + Ellipse + + handleTypeChange("lasso")} label="Lasso"> + + Lasso + +
+ )} + + {isMagicWand && ( +
+ + Magic Wand +
+ )} + +
+ + {/* Selection mode buttons */} +
+ Mode: + handleModeChange("new")} + label="New Selection" + > + New + + handleModeChange("add")} + label="Add to Selection" + > + + Add + + handleModeChange("subtract")} + label="Subtract from Selection" + > + + Sub + +
+ + {/* Lasso sub-type toggle */} + {isLasso && ( + <> +
+
+ setTool("lasso-free")} + label="Freehand Lasso" + > + Freehand + + setTool("lasso-poly")} + label="Polygonal Lasso" + > + Polygonal + +
+ + )} + + {/* Magic Wand tolerance */} + {isMagicWand && ( + <> +
+
+ + +
+ + )} +
+ ); +} diff --git a/apps/web/src/components/editor/options/transform-options.tsx b/apps/web/src/components/editor/options/transform-options.tsx new file mode 100644 index 00000000..69a80c84 --- /dev/null +++ b/apps/web/src/components/editor/options/transform-options.tsx @@ -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) => { + const v = Number(e.target.value); + if (Number.isFinite(v)) onChange(v); + }, + [onChange], + ); + + return ( +
+ + +
+ ); +} + +export function TransformOptions({ api }: { api: TransformToolApi }) { + const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical } = api; + + return ( +
+ setValues({ x: v })} + /> + setValues({ y: v })} + /> + +
+ + setValues({ width: v })} + /> + + + + setValues({ height: v })} + /> + +
+ + setValues({ rotation: v })} + /> + +
+ + + +
+ ); +} diff --git a/apps/web/src/components/editor/tools/crop-tool.tsx b/apps/web/src/components/editor/tools/crop-tool.tsx new file mode 100644 index 00000000..0026646c --- /dev/null +++ b/apps/web/src/components/editor/tools/crop-tool.tsx @@ -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; + transformerRef: React.RefObject; + 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(null); + const transformerRef = useRef(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(null); + const trRef = useRef(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 ( + + {/* Darkened overlays: top, bottom, left, right */} + + + + + + {/* Crop region */} + + + {/* Rule-of-thirds grid */} + + + + + + {/* Transformer */} + ({ + ...newBox, + width: Math.max(10, newBox.width), + height: Math.max(10, newBox.height), + })} + /> + + ); +} diff --git a/apps/web/src/components/editor/tools/move-tool.tsx b/apps/web/src/components/editor/tools/move-tool.tsx new file mode 100644 index 00000000..3b47fd39 --- /dev/null +++ b/apps/web/src/components/editor/tools/move-tool.tsx @@ -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 }[], + updateObject: (id: string, attrs: Record) => 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; + smartGuides: SmartGuide[]; + onSelect: (e: Konva.KonvaEventObject) => void; + onStageClick: (e: Konva.KonvaEventObject) => void; + onDragStart: (e: Konva.KonvaEventObject) => void; + onDragMove: (e: Konva.KonvaEventObject) => void; + onDragEnd: (e: Konva.KonvaEventObject) => void; + onTransformEnd: (e: Konva.KonvaEventObject) => void; + nudge: (dx: number, dy: number) => void; +} + +export function useMoveTool(): MoveToolApi { + const transformerRef = useRef(null); + const smartGuidesRef = useRef([]); + + 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) => { + 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) => { + // Clicked on stage background - deselect + if (e.target === e.target.getStage()) { + setSelectedObjects([]); + } + }, + [setSelectedObjects], + ); + + const onDragStart = useCallback((_e: Konva.KonvaEventObject) => { + // No-op -- selection already handled by onClick + }, []); + + const onDragMove = useCallback( + (e: Konva.KonvaEventObject) => { + 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) => { + const node = e.target; + smartGuidesRef.current = []; + updateObject(node.id(), { + x: node.x(), + y: node.y(), + }); + }, + [updateObject], + ); + + const onTransformEnd = useCallback( + (e: Konva.KonvaEventObject) => { + 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; +}) { + return ( + + ); +} diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx new file mode 100644 index 00000000..3f12f84c --- /dev/null +++ b/apps/web/src/components/editor/tools/selection-tool.tsx @@ -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) { + const dashOffsetRef = useRef(0); + const animRef = useRef(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("rect"); + const [isDrawing, setIsDrawing] = useState(false); + const [currentPoints, setCurrentPoints] = useState([]); + 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 }) { + 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 ( + + + + + ); + } + + if (type === "ellipse") { + const rx = bounds.width / 2; + const ry = bounds.height / 2; + return ( + + + + + ); + } + + // Rectangular selection + return ( + + + + + ); +} + +// --------------------------------------------------------------------------- +// ActiveSelectionPreview -- rendered during drag to show selection shape +// --------------------------------------------------------------------------- + +export function ActiveSelectionPreview({ + type, + points, +}: { + type: SelectionType; + points: number[]; +}) { + if (type === "lasso" && points.length >= 4) { + return ( + + ); + } + + 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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/components/editor/tools/transform-tool.tsx b/apps/web/src/components/editor/tools/transform-tool.tsx new file mode 100644 index 00000000..9baa6586 --- /dev/null +++ b/apps/web/src/components/editor/tools/transform-tool.tsx @@ -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; + isTransforming: boolean; + values: TransformValues; + lockedAspect: boolean; + setLockedAspect: (v: boolean) => void; + activate: () => void; + applyTransform: () => void; + cancelTransform: () => void; + setValues: (v: Partial) => void; + flipHorizontal: () => void; + flipVertical: () => void; +} + +export function useTransformTool(): TransformToolApi { + const transformerRef = useRef(null); + const [isTransforming, setIsTransforming] = useState(false); + const [lockedAspect, setLockedAspect] = useState(false); + const [values, setValuesState] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + rotation: 0, + }); + const preTransformRef = useRef(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) => { + 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; + onTransformEnd?: (e: Konva.KonvaEventObject) => void; +}) { + const handleTransformEnd = useCallback( + (e: Konva.KonvaEventObject) => { + 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 ( + + ); +} diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts new file mode 100644 index 00000000..e4e958f8 --- /dev/null +++ b/apps/web/src/stores/editor-store.ts @@ -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()((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 }), +})); diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts new file mode 100644 index 00000000..72e51e8e --- /dev/null +++ b/apps/web/src/types/editor.ts @@ -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; +} + +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; +} + +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) => 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) => 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6e03bf5..5293135c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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