mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add selection, crop, transform tools and supporting infrastructure for image editor
Implements Agent 2 deliverables for the image editor: move tool with Konva Transformer and smart guide snapping, rectangular/elliptical marquee and lasso selection with marching ants animation, magic wand flood fill, crop tool with darkened overlay and rule-of-thirds grid, free transform with numeric inputs, context menu with z-ordering and clipboard operations, rulers with drag-to-create guides, smart alignment guides, and canvas/image resize dialogs. Also creates the shared editor types and Zustand store with full layer management, z-ordering, clipboard, guide, and document operations.
This commit is contained in:
@@ -12,25 +12,29 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@sentry/react": "^10.49.0",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"jszip": "^3.10.1",
|
||||
"konva": "^10",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"posthog-js": "^1.370.0",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-konva": "^19",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"use-image": "^1.1.4",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { AnchorPosition } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasResizeDialog -- modal with W/H, 9-point anchor grid, fill color
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ANCHOR_POSITIONS: AnchorPosition[] = [
|
||||
"top-left",
|
||||
"top-center",
|
||||
"top-right",
|
||||
"center-left",
|
||||
"center",
|
||||
"center-right",
|
||||
"bottom-left",
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
];
|
||||
|
||||
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
|
||||
|
||||
const [width, setWidth] = useState(canvasSize.width);
|
||||
const [height, setHeight] = useState(canvasSize.height);
|
||||
const [anchor, setAnchor] = useState<AnchorPosition>("center");
|
||||
const [fill, setFill] = useState("#ffffff");
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
resizeCanvas(width, height, anchor, fill);
|
||||
onClose();
|
||||
}, [width, height, anchor, fill, resizeCanvas, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputCn = cn(
|
||||
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-foreground">Canvas Size</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{/* Current size info */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current: {canvasSize.width} x {canvasSize.height} px
|
||||
</p>
|
||||
|
||||
{/* Width / Height */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="canvas-w" className="mb-1 block text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="canvas-w"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
value={width}
|
||||
onChange={(e) => setWidth(Math.max(1, Number(e.target.value) || 1))}
|
||||
className={inputCn}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="canvas-h" className="mb-1 block text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="canvas-h"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
value={height}
|
||||
onChange={(e) => setHeight(Math.max(1, Number(e.target.value) || 1))}
|
||||
className={inputCn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anchor grid */}
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-muted-foreground">Anchor:</p>
|
||||
<div className="inline-grid grid-cols-3 gap-1 rounded border border-border p-1.5">
|
||||
{ANCHOR_POSITIONS.map((pos) => (
|
||||
<button
|
||||
key={pos}
|
||||
type="button"
|
||||
onClick={() => setAnchor(pos)}
|
||||
title={pos.replace("-", " ")}
|
||||
aria-label={`Anchor ${pos.replace("-", " ")}`}
|
||||
aria-pressed={anchor === pos}
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-sm transition-colors",
|
||||
anchor === pos ? "bg-primary" : "bg-muted hover:bg-muted-foreground/20",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background fill */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="canvas-fill" className="text-xs text-muted-foreground">
|
||||
Background:
|
||||
</label>
|
||||
<input
|
||||
id="canvas-fill"
|
||||
type="color"
|
||||
value={fill}
|
||||
onChange={(e) => setFill(e.target.value)}
|
||||
className="h-7 w-7 cursor-pointer rounded border border-border"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={fill}
|
||||
onChange={(e) => setFill(e.target.value)}
|
||||
className={cn(
|
||||
"h-7 w-20 rounded border border-border bg-card px-1.5 text-xs text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"h-8 rounded border border-border px-3 text-sm",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
className={cn(
|
||||
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
|
||||
"hover:bg-primary/90 transition-colors",
|
||||
)}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
CopyPlus,
|
||||
ImageIcon,
|
||||
Maximize,
|
||||
MousePointer,
|
||||
Scissors,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context menu state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
dividerAfter?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useContextMenu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useContextMenu() {
|
||||
const [position, setPosition] = useState<MenuPosition | null>(null);
|
||||
const [menuType, setMenuType] = useState<"object" | "canvas">("canvas");
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, hasSelectedObject: boolean) => {
|
||||
e.preventDefault();
|
||||
setPosition({ x: e.clientX, y: e.clientY });
|
||||
setMenuType(hasSelectedObject ? "object" : "canvas");
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setPosition(null);
|
||||
}, []);
|
||||
|
||||
return { position, menuType, handleContextMenu, close };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ContextMenu component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ContextMenu({
|
||||
position,
|
||||
menuType,
|
||||
onClose,
|
||||
}: {
|
||||
position: MenuPosition;
|
||||
menuType: "object" | "canvas";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const copyObjects = useEditorStore((s) => s.copyObjects);
|
||||
const cutObjects = useEditorStore((s) => s.cutObjects);
|
||||
const pasteObjects = useEditorStore((s) => s.pasteObjects);
|
||||
const removeObjects = useEditorStore((s) => s.removeObjects);
|
||||
const duplicateObjects = useEditorStore((s) => s.duplicateObjects);
|
||||
const bringToFront = useEditorStore((s) => s.bringToFront);
|
||||
const bringForward = useEditorStore((s) => s.bringForward);
|
||||
const sendBackward = useEditorStore((s) => s.sendBackward);
|
||||
const sendToBack = useEditorStore((s) => s.sendToBack);
|
||||
const clipboard = useEditorStore((s) => s.clipboard);
|
||||
const setSelection = useEditorStore((s) => s.setSelection);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
|
||||
// Close on click outside or Escape
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const objectItems: MenuItem[] = [
|
||||
{
|
||||
label: "Cut",
|
||||
icon: Scissors,
|
||||
shortcut: "Ctrl+X",
|
||||
action: () => {
|
||||
cutObjects(selectedObjectIds);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Copy",
|
||||
icon: Copy,
|
||||
shortcut: "Ctrl+C",
|
||||
action: () => {
|
||||
copyObjects(selectedObjectIds);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Paste",
|
||||
icon: ClipboardPaste,
|
||||
shortcut: "Ctrl+V",
|
||||
action: () => {
|
||||
pasteObjects();
|
||||
onClose();
|
||||
},
|
||||
disabled: clipboard.length === 0,
|
||||
},
|
||||
{
|
||||
label: "Duplicate",
|
||||
icon: CopyPlus,
|
||||
shortcut: "Ctrl+D",
|
||||
action: () => {
|
||||
duplicateObjects(selectedObjectIds);
|
||||
onClose();
|
||||
},
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Bring to Front",
|
||||
icon: ArrowUp,
|
||||
action: () => {
|
||||
for (const id of selectedObjectIds) bringToFront(id);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Bring Forward",
|
||||
icon: ArrowUp,
|
||||
action: () => {
|
||||
for (const id of selectedObjectIds) bringForward(id);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Send Backward",
|
||||
icon: ArrowDown,
|
||||
action: () => {
|
||||
for (const id of selectedObjectIds) sendBackward(id);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Send to Back",
|
||||
icon: ArrowDown,
|
||||
action: () => {
|
||||
for (const id of selectedObjectIds) sendToBack(id);
|
||||
onClose();
|
||||
},
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
shortcut: "Del",
|
||||
action: () => {
|
||||
removeObjects(selectedObjectIds);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const canvasItems: MenuItem[] = [
|
||||
{
|
||||
label: "Paste",
|
||||
icon: ClipboardPaste,
|
||||
shortcut: "Ctrl+V",
|
||||
action: () => {
|
||||
pasteObjects();
|
||||
onClose();
|
||||
},
|
||||
disabled: clipboard.length === 0,
|
||||
},
|
||||
{
|
||||
label: "Select All",
|
||||
icon: MousePointer,
|
||||
shortcut: "Ctrl+A",
|
||||
action: () => {
|
||||
setSelection({
|
||||
type: "rect",
|
||||
points: [],
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
},
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Canvas Size...",
|
||||
icon: Maximize,
|
||||
action: () => {
|
||||
// Trigger canvas resize dialog (handled by parent)
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Image Size...",
|
||||
icon: ImageIcon,
|
||||
action: () => {
|
||||
// Trigger image resize dialog (handled by parent)
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const items = menuType === "object" ? objectItems : canvasItems;
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const adjustedX = Math.min(position.x, window.innerWidth - 220);
|
||||
const adjustedY = Math.min(position.y, window.innerHeight - items.length * 36);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn(
|
||||
"fixed z-50 min-w-[200px] rounded-lg border border-border bg-card py-1 shadow-lg",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.action}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm",
|
||||
"text-foreground hover:bg-muted transition-colors",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="text-xs text-muted-foreground">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
{item.dividerAfter && <div className="my-1 h-px bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { Group, Line } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GuideLines -- draggable guide lines rendered as Konva.Line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GUIDE_COLOR = "#22d3ee"; // cyan-400
|
||||
const GUIDE_WIDTH = 1;
|
||||
|
||||
export function GuideLines() {
|
||||
const guides = useEditorStore((s) => s.guides);
|
||||
const showGuides = useEditorStore((s) => s.showGuides);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const updateGuide = useEditorStore((s) => s.updateGuide);
|
||||
const removeGuide = useEditorStore((s) => s.removeGuide);
|
||||
|
||||
if (!showGuides || guides.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{guides.map((guide) => (
|
||||
<DraggableGuide
|
||||
key={guide.id}
|
||||
id={guide.id}
|
||||
orientation={guide.orientation}
|
||||
position={guide.position}
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
onPositionChange={(pos) => updateGuide(guide.id, pos)}
|
||||
onRemove={() => removeGuide(guide.id)}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DraggableGuide -- individual guide line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DraggableGuide({
|
||||
id,
|
||||
orientation,
|
||||
position,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
onPositionChange,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
orientation: "horizontal" | "vertical";
|
||||
position: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
onPositionChange: (pos: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const lineRef = useRef<Konva.Line>(null);
|
||||
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
||||
const points = isHorizontal
|
||||
? [0, position, canvasWidth, position]
|
||||
: [position, 0, position, canvasHeight];
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
if (isHorizontal) {
|
||||
const newY = node.y() + position;
|
||||
node.y(0); // reset drag offset
|
||||
onPositionChange(newY);
|
||||
} else {
|
||||
const newX = node.x() + position;
|
||||
node.x(0);
|
||||
onPositionChange(newX);
|
||||
}
|
||||
},
|
||||
[isHorizontal, position, onPositionChange],
|
||||
);
|
||||
|
||||
const handleDblClick = useCallback(() => {
|
||||
onRemove();
|
||||
}, [onRemove]);
|
||||
|
||||
return (
|
||||
<Line
|
||||
ref={lineRef}
|
||||
id={`guide-${id}`}
|
||||
points={points}
|
||||
stroke={GUIDE_COLOR}
|
||||
strokeWidth={GUIDE_WIDTH}
|
||||
dash={[8, 4]}
|
||||
draggable
|
||||
dragBoundFunc={(pos) => {
|
||||
// Constrain drag to the guide's axis
|
||||
if (isHorizontal) {
|
||||
return { x: 0, y: pos.y };
|
||||
}
|
||||
return { x: pos.x, y: 0 };
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDblClick={handleDblClick}
|
||||
hitStrokeWidth={8}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Lock, Unlock, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ResampleMethod } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ImageResizeDialog -- modal with W/H, aspect lock, resampling method
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [
|
||||
{ value: "nearest", label: "Nearest Neighbor (fast)" },
|
||||
{ value: "bilinear", label: "Bilinear" },
|
||||
{ value: "bicubic", label: "Bicubic (smooth)" },
|
||||
{ value: "lanczos", label: "Lanczos (sharp)" },
|
||||
];
|
||||
|
||||
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const resizeImage = useEditorStore((s) => s.resizeImage);
|
||||
|
||||
const [width, setWidth] = useState(canvasSize.width);
|
||||
const [height, setHeight] = useState(canvasSize.height);
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
const [resample, setResample] = useState<ResampleMethod>("bicubic");
|
||||
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
|
||||
// Sync when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setWidth(canvasSize.width);
|
||||
setHeight(canvasSize.height);
|
||||
}
|
||||
}, [open, canvasSize]);
|
||||
|
||||
const handleWidthChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const w = Math.max(1, Number(e.target.value) || 1);
|
||||
setWidth(w);
|
||||
if (lockAspect) {
|
||||
setHeight(Math.round(w / aspectRatio));
|
||||
}
|
||||
},
|
||||
[lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const h = Math.max(1, Number(e.target.value) || 1);
|
||||
setHeight(h);
|
||||
if (lockAspect) {
|
||||
setWidth(Math.round(h * aspectRatio));
|
||||
}
|
||||
},
|
||||
[lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
resizeImage(width, height);
|
||||
onClose();
|
||||
}, [width, height, resizeImage, onClose]);
|
||||
|
||||
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
|
||||
const pctHeight =
|
||||
canvasSize.height > 0 ? ((height / canvasSize.height) * 100).toFixed(1) : "100.0";
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const inputCn = cn(
|
||||
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-foreground">Image Size</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
{/* Current size info */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Original: {canvasSize.width} x {canvasSize.height} px
|
||||
</p>
|
||||
|
||||
{/* Width / Lock / Height */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="img-w" className="mb-1 block text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="img-w"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
value={width}
|
||||
onChange={handleWidthChange}
|
||||
className={inputCn}
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctWidth}%</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLockAspect(!lockAspect)}
|
||||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-label={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-pressed={lockAspect}
|
||||
className={cn(
|
||||
"mb-4 flex h-8 w-8 items-center justify-center rounded transition-colors",
|
||||
lockAspect
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{lockAspect ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<div className="flex-1">
|
||||
<label htmlFor="img-h" className="mb-1 block text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="img-h"
|
||||
type="number"
|
||||
min={1}
|
||||
max={16384}
|
||||
value={height}
|
||||
onChange={handleHeightChange}
|
||||
className={inputCn}
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctHeight}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resampling method */}
|
||||
<div>
|
||||
<label htmlFor="resample" className="mb-1 block text-xs text-muted-foreground">
|
||||
Resampling:
|
||||
</label>
|
||||
<select
|
||||
id="resample"
|
||||
value={resample}
|
||||
onChange={(e) => setResample(e.target.value as ResampleMethod)}
|
||||
className={cn(
|
||||
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
)}
|
||||
>
|
||||
{RESAMPLE_METHODS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"h-8 rounded border border-border px-3 text-sm",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
className={cn(
|
||||
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
|
||||
"hover:bg-primary/90 transition-colors",
|
||||
)}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruler configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RULER_SIZE = 20; // px
|
||||
const TICK_COLOR = "var(--color-muted-foreground)";
|
||||
const BG_COLOR = "var(--color-card)";
|
||||
const TEXT_COLOR = "var(--color-muted-foreground)";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: pick tick spacing based on zoom level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTickInterval(zoom: number): { major: number; minor: number } {
|
||||
// Adjust tick spacing so labels don't overlap at any zoom
|
||||
if (zoom >= 4) return { major: 25, minor: 5 };
|
||||
if (zoom >= 2) return { major: 50, minor: 10 };
|
||||
if (zoom >= 1) return { major: 100, minor: 10 };
|
||||
if (zoom >= 0.5) return { major: 200, minor: 50 };
|
||||
if (zoom >= 0.25) return { major: 500, minor: 100 };
|
||||
return { major: 1000, minor: 200 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HorizontalRuler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HorizontalRuler() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const showRulers = useEditorStore((s) => s.showRulers);
|
||||
const addGuide = useEditorStore((s) => s.addGuide);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth;
|
||||
const h = RULER_SIZE;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = BG_COLOR;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const { major, minor } = getTickInterval(zoom);
|
||||
const startPx = -panOffset.x / zoom;
|
||||
const endPx = (w - panOffset.x) / zoom;
|
||||
const startTick = Math.floor(startPx / minor) * minor;
|
||||
|
||||
ctx.strokeStyle = TICK_COLOR;
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px sans-serif";
|
||||
ctx.textBaseline = "top";
|
||||
|
||||
for (let t = startTick; t <= endPx + minor; t += minor) {
|
||||
const screenX = t * zoom + panOffset.x;
|
||||
if (screenX < 0 || screenX > w) continue;
|
||||
|
||||
const isMajor = t % major === 0;
|
||||
const tickHeight = isMajor ? 12 : 6;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(screenX, h);
|
||||
ctx.lineTo(screenX, h - tickHeight);
|
||||
ctx.lineWidth = isMajor ? 0.8 : 0.4;
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor) {
|
||||
ctx.fillText(String(Math.round(t)), screenX + 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
ctx.strokeStyle = TICK_COLOR;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, h - 0.5);
|
||||
ctx.lineTo(w, h - 0.5);
|
||||
ctx.stroke();
|
||||
// canvasSize referenced to trigger redraw when canvas dimensions change
|
||||
void canvasSize;
|
||||
}, [zoom, panOffset, canvasSize]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => draw();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [draw]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Drag from ruler to create a horizontal guide
|
||||
const startY = e.clientY;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientY - startY) > 10) {
|
||||
const canvasY = (me.clientY - startY) / zoom;
|
||||
addGuide("horizontal", canvasY);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[zoom, addGuide],
|
||||
);
|
||||
|
||||
if (!showRulers) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={cn("block w-full cursor-col-resize select-none")}
|
||||
style={{ height: RULER_SIZE }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VerticalRuler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function VerticalRuler() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const showRulers = useEditorStore((s) => s.showRulers);
|
||||
const addGuide = useEditorStore((s) => s.addGuide);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = RULER_SIZE;
|
||||
const h = canvas.clientHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.fillStyle = BG_COLOR;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const { major, minor } = getTickInterval(zoom);
|
||||
const startPx = -panOffset.y / zoom;
|
||||
const endPx = (h - panOffset.y) / zoom;
|
||||
const startTick = Math.floor(startPx / minor) * minor;
|
||||
|
||||
ctx.strokeStyle = TICK_COLOR;
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = "9px sans-serif";
|
||||
|
||||
for (let t = startTick; t <= endPx + minor; t += minor) {
|
||||
const screenY = t * zoom + panOffset.y;
|
||||
if (screenY < 0 || screenY > h) continue;
|
||||
|
||||
const isMajor = t % major === 0;
|
||||
const tickWidth = isMajor ? 12 : 6;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w, screenY);
|
||||
ctx.lineTo(w - tickWidth, screenY);
|
||||
ctx.lineWidth = isMajor ? 0.8 : 0.4;
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor) {
|
||||
ctx.save();
|
||||
ctx.translate(3, screenY + 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(String(Math.round(t)), 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Right border
|
||||
ctx.strokeStyle = TICK_COLOR;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(w - 0.5, 0);
|
||||
ctx.lineTo(w - 0.5, h);
|
||||
ctx.stroke();
|
||||
void canvasSize;
|
||||
}, [zoom, panOffset, canvasSize]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => draw();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [draw]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const startX = e.clientX;
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (Math.abs(me.clientX - startX) > 10) {
|
||||
const canvasX = (me.clientX - startX) / zoom;
|
||||
addGuide("vertical", canvasX);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[zoom, addGuide],
|
||||
);
|
||||
|
||||
if (!showRulers) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={cn("block h-full cursor-row-resize select-none")}
|
||||
style={{ width: RULER_SIZE }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { RULER_SIZE };
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Group, Line } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SmartGuide } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smart guide calculation utilities (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SNAP_THRESHOLD = 5;
|
||||
|
||||
interface ObjectBounds {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function findAlignmentGuides(
|
||||
dragging: ObjectBounds,
|
||||
others: ObjectBounds[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
threshold = SNAP_THRESHOLD,
|
||||
): SmartGuide[] {
|
||||
const guides: SmartGuide[] = [];
|
||||
|
||||
const dragEdges = {
|
||||
left: dragging.x,
|
||||
right: dragging.x + dragging.width,
|
||||
centerX: dragging.x + dragging.width / 2,
|
||||
top: dragging.y,
|
||||
bottom: dragging.y + dragging.height,
|
||||
centerY: dragging.y + dragging.height / 2,
|
||||
};
|
||||
|
||||
// Canvas alignment
|
||||
const canvasTargets = [
|
||||
{ pos: 0, orient: "vertical" as const, type: "canvas" as const },
|
||||
{
|
||||
pos: canvasWidth / 2,
|
||||
orient: "vertical" as const,
|
||||
type: "canvas" as const,
|
||||
},
|
||||
{ pos: canvasWidth, orient: "vertical" as const, type: "canvas" as const },
|
||||
{ pos: 0, orient: "horizontal" as const, type: "canvas" as const },
|
||||
{
|
||||
pos: canvasHeight / 2,
|
||||
orient: "horizontal" as const,
|
||||
type: "canvas" as const,
|
||||
},
|
||||
{
|
||||
pos: canvasHeight,
|
||||
orient: "horizontal" as const,
|
||||
type: "canvas" as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const ct of canvasTargets) {
|
||||
const edges =
|
||||
ct.orient === "vertical"
|
||||
? [dragEdges.left, dragEdges.centerX, dragEdges.right]
|
||||
: [dragEdges.top, dragEdges.centerY, dragEdges.bottom];
|
||||
|
||||
for (const edge of edges) {
|
||||
if (Math.abs(edge - ct.pos) < threshold) {
|
||||
guides.push({
|
||||
orientation: ct.orient,
|
||||
position: ct.pos,
|
||||
type: ct.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Object-to-object alignment
|
||||
for (const other of others) {
|
||||
if (other.id === dragging.id) continue;
|
||||
|
||||
const otherEdges = {
|
||||
left: other.x,
|
||||
right: other.x + other.width,
|
||||
centerX: other.x + other.width / 2,
|
||||
top: other.y,
|
||||
bottom: other.y + other.height,
|
||||
centerY: other.y + other.height / 2,
|
||||
};
|
||||
|
||||
// Vertical guides (x-axis alignment)
|
||||
const vPairs: [number, number, "edge" | "center"][] = [
|
||||
[dragEdges.left, otherEdges.left, "edge"],
|
||||
[dragEdges.left, otherEdges.right, "edge"],
|
||||
[dragEdges.right, otherEdges.left, "edge"],
|
||||
[dragEdges.right, otherEdges.right, "edge"],
|
||||
[dragEdges.centerX, otherEdges.centerX, "center"],
|
||||
];
|
||||
|
||||
for (const [dragVal, otherVal, type] of vPairs) {
|
||||
if (Math.abs(dragVal - otherVal) < threshold) {
|
||||
guides.push({ orientation: "vertical", position: otherVal, type });
|
||||
}
|
||||
}
|
||||
|
||||
// Horizontal guides (y-axis alignment)
|
||||
const hPairs: [number, number, "edge" | "center"][] = [
|
||||
[dragEdges.top, otherEdges.top, "edge"],
|
||||
[dragEdges.top, otherEdges.bottom, "edge"],
|
||||
[dragEdges.bottom, otherEdges.top, "edge"],
|
||||
[dragEdges.bottom, otherEdges.bottom, "edge"],
|
||||
[dragEdges.centerY, otherEdges.centerY, "center"],
|
||||
];
|
||||
|
||||
for (const [dragVal, otherVal, type] of hPairs) {
|
||||
if (Math.abs(dragVal - otherVal) < threshold) {
|
||||
guides.push({ orientation: "horizontal", position: otherVal, type });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return guides;
|
||||
}
|
||||
|
||||
export function snapToGuides(
|
||||
pos: { x: number; y: number },
|
||||
size: { width: number; height: number },
|
||||
guides: SmartGuide[],
|
||||
threshold = SNAP_THRESHOLD,
|
||||
): { x: number; y: number } {
|
||||
let { x, y } = pos;
|
||||
|
||||
for (const g of guides) {
|
||||
if (g.orientation === "vertical") {
|
||||
// Snap left edge, center, or right edge
|
||||
if (Math.abs(x - g.position) < threshold) {
|
||||
x = g.position;
|
||||
} else if (Math.abs(x + size.width / 2 - g.position) < threshold) {
|
||||
x = g.position - size.width / 2;
|
||||
} else if (Math.abs(x + size.width - g.position) < threshold) {
|
||||
x = g.position - size.width;
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(y - g.position) < threshold) {
|
||||
y = g.position;
|
||||
} else if (Math.abs(y + size.height / 2 - g.position) < threshold) {
|
||||
y = g.position - size.height / 2;
|
||||
} else if (Math.abs(y + size.height - g.position) < threshold) {
|
||||
y = g.position - size.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SmartGuidesOverlay -- renders temporary guide lines during drag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GUIDE_COLORS = {
|
||||
edge: "#f43f5e",
|
||||
center: "#8b5cf6",
|
||||
canvas: "#22c55e",
|
||||
};
|
||||
|
||||
export function SmartGuidesOverlay({ guides }: { guides: SmartGuide[] }) {
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
|
||||
if (guides.length === 0) return null;
|
||||
|
||||
// Deduplicate guides by position + orientation
|
||||
const seen = new Set<string>();
|
||||
const unique = guides.filter((g) => {
|
||||
const key = `${g.orientation}-${g.position}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Group listening={false}>
|
||||
{unique.map((g) => {
|
||||
const color = GUIDE_COLORS[g.type];
|
||||
if (g.orientation === "vertical") {
|
||||
return (
|
||||
<Line
|
||||
key={`sg-${g.orientation}-${g.position}`}
|
||||
points={[g.position, 0, g.position, canvasSize.height]}
|
||||
stroke={color}
|
||||
strokeWidth={0.5}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Line
|
||||
key={`sg-${g.orientation}-${g.position}`}
|
||||
points={[0, g.position, canvasSize.width, g.position]}
|
||||
stroke={color}
|
||||
strokeWidth={0.5}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { ArrowLeftRight, Check, X } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { ASPECT_RATIOS } from "@/components/editor/tools/crop-tool";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CropOptions -- aspect ratio dropdown, W/H inputs, apply/cancel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CropOptions() {
|
||||
const cropState = useEditorStore((s) => s.cropState);
|
||||
const setCropState = useEditorStore((s) => s.setCropState);
|
||||
const applyCrop = useEditorStore((s) => s.applyCrop);
|
||||
const [selectedRatio, setSelectedRatio] = useState("Free");
|
||||
|
||||
const handleAspectChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const label = e.target.value;
|
||||
setSelectedRatio(label);
|
||||
if (!cropState) return;
|
||||
|
||||
const preset = ASPECT_RATIOS.find((p) => p.label === label);
|
||||
if (!preset || !preset.value) {
|
||||
setCropState({ ...cropState, aspectRatio: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const ratio = preset.value;
|
||||
let w = cropState.width;
|
||||
let h = w / ratio;
|
||||
if (h > cropState.height * 1.5) {
|
||||
h = cropState.height;
|
||||
w = h * ratio;
|
||||
}
|
||||
setCropState({ ...cropState, width: w, height: h, aspectRatio: label });
|
||||
},
|
||||
[cropState, setCropState],
|
||||
);
|
||||
|
||||
const handleWidthChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!cropState) return;
|
||||
const w = Math.max(1, Number(e.target.value) || 1);
|
||||
setCropState({ ...cropState, width: w });
|
||||
},
|
||||
[cropState, setCropState],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!cropState) return;
|
||||
const h = Math.max(1, Number(e.target.value) || 1);
|
||||
setCropState({ ...cropState, height: h });
|
||||
},
|
||||
[cropState, setCropState],
|
||||
);
|
||||
|
||||
const handleSwap = useCallback(() => {
|
||||
if (!cropState) return;
|
||||
setCropState({
|
||||
...cropState,
|
||||
width: cropState.height,
|
||||
height: cropState.width,
|
||||
});
|
||||
}, [cropState, setCropState]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
applyCrop();
|
||||
}, [applyCrop]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setCropState(null);
|
||||
}, [setCropState]);
|
||||
|
||||
const inputCn = cn(
|
||||
"h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Aspect ratio dropdown */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="crop-aspect" className="text-xs text-muted-foreground">
|
||||
Ratio:
|
||||
</label>
|
||||
<select
|
||||
id="crop-aspect"
|
||||
value={selectedRatio}
|
||||
onChange={handleAspectChange}
|
||||
className={cn(
|
||||
"h-6 rounded border border-border bg-card px-1.5 text-xs text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
)}
|
||||
>
|
||||
{ASPECT_RATIOS.map((r) => (
|
||||
<option key={r.label} value={r.label}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Width and Height inputs */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="crop-width" className="text-xs text-muted-foreground">
|
||||
W:
|
||||
</label>
|
||||
<input
|
||||
id="crop-width"
|
||||
type="number"
|
||||
min={1}
|
||||
value={cropState ? Math.round(cropState.width) : ""}
|
||||
onChange={handleWidthChange}
|
||||
className={inputCn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwap}
|
||||
title="Swap dimensions"
|
||||
aria-label="Swap dimensions"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="crop-height" className="text-xs text-muted-foreground">
|
||||
H:
|
||||
</label>
|
||||
<input
|
||||
id="crop-height"
|
||||
type="number"
|
||||
min={1}
|
||||
value={cropState ? Math.round(cropState.height) : ""}
|
||||
onChange={handleHeightChange}
|
||||
className={inputCn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Apply / Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
title="Apply Crop (Enter)"
|
||||
aria-label="Apply Crop"
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1 rounded bg-primary px-2.5 text-xs text-primary-foreground",
|
||||
"hover:bg-primary/90 transition-colors",
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
title="Cancel Crop (Escape)"
|
||||
aria-label="Cancel Crop"
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1 rounded border border-border px-2.5 text-xs",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
AlignCenterHorizontal,
|
||||
AlignCenterVertical,
|
||||
AlignEndHorizontal,
|
||||
AlignEndVertical,
|
||||
AlignStartHorizontal,
|
||||
AlignStartVertical,
|
||||
ArrowLeftRight,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { alignObjects } from "@/components/editor/tools/move-tool";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveOptions -- alignment and distribute buttons in the options bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function OptionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded",
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoveOptions() {
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const objects = useEditorStore((s) => s.objects);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
|
||||
const hasSelection = selectedObjectIds.length >= 2;
|
||||
const hasThreeOrMore = selectedObjectIds.length >= 3;
|
||||
|
||||
const handleAlign = (
|
||||
direction:
|
||||
| "left"
|
||||
| "center-h"
|
||||
| "right"
|
||||
| "top"
|
||||
| "center-v"
|
||||
| "bottom"
|
||||
| "distribute-h"
|
||||
| "distribute-v",
|
||||
) => {
|
||||
alignObjects(direction, selectedObjectIds, objects, updateObject);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-2 text-xs text-muted-foreground">Align:</span>
|
||||
|
||||
<OptionButton
|
||||
icon={AlignStartHorizontal}
|
||||
label="Align Left"
|
||||
onClick={() => handleAlign("left")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
<OptionButton
|
||||
icon={AlignCenterHorizontal}
|
||||
label="Align Center Horizontal"
|
||||
onClick={() => handleAlign("center-h")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
<OptionButton
|
||||
icon={AlignEndHorizontal}
|
||||
label="Align Right"
|
||||
onClick={() => handleAlign("right")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
|
||||
<OptionButton
|
||||
icon={AlignStartVertical}
|
||||
label="Align Top"
|
||||
onClick={() => handleAlign("top")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
<OptionButton
|
||||
icon={AlignCenterVertical}
|
||||
label="Align Center Vertical"
|
||||
onClick={() => handleAlign("center-v")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
<OptionButton
|
||||
icon={AlignEndVertical}
|
||||
label="Align Bottom"
|
||||
onClick={() => handleAlign("bottom")}
|
||||
disabled={!hasSelection}
|
||||
/>
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
|
||||
<span className="mr-1 text-xs text-muted-foreground">Distribute:</span>
|
||||
|
||||
<OptionButton
|
||||
icon={ArrowLeftRight}
|
||||
label="Distribute Horizontally"
|
||||
onClick={() => handleAlign("distribute-h")}
|
||||
disabled={!hasThreeOrMore}
|
||||
/>
|
||||
<OptionButton
|
||||
icon={ArrowUpDown}
|
||||
label="Distribute Vertically"
|
||||
onClick={() => handleAlign("distribute-v")}
|
||||
disabled={!hasThreeOrMore}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SelectionMode, SelectionType, ToolType } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectionOptions -- selection type toggle, mode buttons, feather input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex h-7 items-center justify-center rounded px-2",
|
||||
"text-xs transition-colors",
|
||||
active
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectionOptions() {
|
||||
const activeTool = useEditorStore((s) => s.activeTool);
|
||||
const setTool = useEditorStore((s) => s.setTool);
|
||||
const selectionMode = useEditorStore((s) => s.selectionMode);
|
||||
const setSelectionMode = useEditorStore((s) => s.setSelectionMode);
|
||||
|
||||
const selectionType: SelectionType =
|
||||
activeTool === "marquee-ellipse"
|
||||
? "ellipse"
|
||||
: activeTool === "lasso-free" || activeTool === "lasso-poly"
|
||||
? "lasso"
|
||||
: "rect";
|
||||
|
||||
const handleTypeChange = useCallback(
|
||||
(type: SelectionType) => {
|
||||
const toolMap: Record<SelectionType, ToolType> = {
|
||||
rect: "marquee-rect",
|
||||
ellipse: "marquee-ellipse",
|
||||
lasso: "lasso-free",
|
||||
};
|
||||
setTool(toolMap[type]);
|
||||
},
|
||||
[setTool],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
(mode: SelectionMode) => {
|
||||
setSelectionMode(mode);
|
||||
},
|
||||
[setSelectionMode],
|
||||
);
|
||||
|
||||
const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse";
|
||||
const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly";
|
||||
const isMagicWand = activeTool === "magic-wand";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Selection type toggle */}
|
||||
{!isMagicWand && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 text-xs text-muted-foreground">Type:</span>
|
||||
<ToggleButton
|
||||
active={selectionType === "rect" && isMarquee}
|
||||
onClick={() => handleTypeChange("rect")}
|
||||
label="Rectangular"
|
||||
>
|
||||
<Square className="mr-1 h-3.5 w-3.5" />
|
||||
Rect
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={selectionType === "ellipse" && isMarquee}
|
||||
onClick={() => handleTypeChange("ellipse")}
|
||||
label="Elliptical"
|
||||
>
|
||||
<Circle className="mr-1 h-3.5 w-3.5" />
|
||||
Ellipse
|
||||
</ToggleButton>
|
||||
<ToggleButton active={isLasso} onClick={() => handleTypeChange("lasso")} label="Lasso">
|
||||
<PenTool className="mr-1 h-3.5 w-3.5" />
|
||||
Lasso
|
||||
</ToggleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMagicWand && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Wand2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Magic Wand</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Selection mode buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 text-xs text-muted-foreground">Mode:</span>
|
||||
<ToggleButton
|
||||
active={selectionMode === "new"}
|
||||
onClick={() => handleModeChange("new")}
|
||||
label="New Selection"
|
||||
>
|
||||
New
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={selectionMode === "add"}
|
||||
onClick={() => handleModeChange("add")}
|
||||
label="Add to Selection"
|
||||
>
|
||||
<Plus className="mr-0.5 h-3 w-3" />
|
||||
Add
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={selectionMode === "subtract"}
|
||||
onClick={() => handleModeChange("subtract")}
|
||||
label="Subtract from Selection"
|
||||
>
|
||||
<Minus className="mr-0.5 h-3 w-3" />
|
||||
Sub
|
||||
</ToggleButton>
|
||||
</div>
|
||||
|
||||
{/* Lasso sub-type toggle */}
|
||||
{isLasso && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-1">
|
||||
<ToggleButton
|
||||
active={activeTool === "lasso-free"}
|
||||
onClick={() => setTool("lasso-free")}
|
||||
label="Freehand Lasso"
|
||||
>
|
||||
Freehand
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={activeTool === "lasso-poly"}
|
||||
onClick={() => setTool("lasso-poly")}
|
||||
label="Polygonal Lasso"
|
||||
>
|
||||
Polygonal
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Magic Wand tolerance */}
|
||||
{isMagicWand && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="wand-tolerance" className="text-xs text-muted-foreground">
|
||||
Tolerance:
|
||||
</label>
|
||||
<input
|
||||
id="wand-tolerance"
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
defaultValue={32}
|
||||
className={cn(
|
||||
"h-6 w-14 rounded border border-border bg-card px-1.5 text-xs text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { FlipHorizontal2, FlipVertical2, Lock, Unlock } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import type { TransformToolApi } from "@/components/editor/tools/transform-tool";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TransformOptions -- X/Y/W/H/rotation inputs, aspect lock, flip buttons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function NumericInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isFinite(v)) onChange(v);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<label htmlFor={id} className="text-xs text-muted-foreground">
|
||||
{label}:
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={Math.round(value)}
|
||||
onChange={handleChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step ?? 1}
|
||||
className={cn(
|
||||
"h-6 w-16 rounded border border-border bg-card px-1.5 text-xs text-foreground",
|
||||
"focus:border-primary focus:outline-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransformOptions({ api }: { api: TransformToolApi }) {
|
||||
const { values, lockedAspect, setLockedAspect, setValues, flipHorizontal, flipVertical } = api;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<NumericInput
|
||||
id="transform-x"
|
||||
label="X"
|
||||
value={values.x}
|
||||
onChange={(v) => setValues({ x: v })}
|
||||
/>
|
||||
<NumericInput
|
||||
id="transform-y"
|
||||
label="Y"
|
||||
value={values.y}
|
||||
onChange={(v) => setValues({ y: v })}
|
||||
/>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<NumericInput
|
||||
id="transform-w"
|
||||
label="W"
|
||||
value={values.width}
|
||||
min={1}
|
||||
onChange={(v) => setValues({ width: v })}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLockedAspect(!lockedAspect)}
|
||||
title={lockedAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-label={lockedAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
aria-pressed={lockedAspect}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded transition-colors",
|
||||
lockedAspect
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{lockedAspect ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
<NumericInput
|
||||
id="transform-h"
|
||||
label="H"
|
||||
value={values.height}
|
||||
min={1}
|
||||
onChange={(v) => setValues({ height: v })}
|
||||
/>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<NumericInput
|
||||
id="transform-rotation"
|
||||
label="Rotation"
|
||||
value={values.rotation}
|
||||
min={-360}
|
||||
max={360}
|
||||
onChange={(v) => setValues({ rotation: v })}
|
||||
/>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={flipHorizontal}
|
||||
title="Flip Horizontal"
|
||||
aria-label="Flip Horizontal"
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<FlipHorizontal2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={flipVertical}
|
||||
title="Flip Vertical"
|
||||
aria-label="Flip Vertical"
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
>
|
||||
<FlipVertical2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Group, Line, Rect, Transformer } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { CropState } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aspect ratio presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ASPECT_RATIOS = [
|
||||
{ label: "Free", value: null },
|
||||
{ label: "1:1", value: 1 },
|
||||
{ label: "4:3", value: 4 / 3 },
|
||||
{ label: "3:4", value: 3 / 4 },
|
||||
{ label: "16:9", value: 16 / 9 },
|
||||
{ label: "9:16", value: 9 / 16 },
|
||||
{ label: "3:2", value: 3 / 2 },
|
||||
{ label: "2:3", value: 2 / 3 },
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useCropTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CropToolApi {
|
||||
cropRef: React.RefObject<Konva.Rect | null>;
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
cropState: CropState | null;
|
||||
aspectRatio: string;
|
||||
setAspectRatio: (label: string) => void;
|
||||
initCrop: () => void;
|
||||
applyCrop: () => void;
|
||||
cancelCrop: () => void;
|
||||
updateCropSize: (w: number, h: number) => void;
|
||||
swapDimensions: () => void;
|
||||
}
|
||||
|
||||
export function useCropTool(): CropToolApi {
|
||||
const cropRef = useRef<Konva.Rect | null>(null);
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const [aspectRatio, setAspectRatioState] = useState("Free");
|
||||
|
||||
const cropState = useEditorStore((s) => s.cropState);
|
||||
const setCropState = useEditorStore((s) => s.setCropState);
|
||||
const applyCropAction = useEditorStore((s) => s.applyCrop);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
|
||||
// Attach transformer to crop rect
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
const node = cropRef.current;
|
||||
if (tr && node && cropState) {
|
||||
tr.nodes([node]);
|
||||
tr.getLayer()?.batchDraw();
|
||||
}
|
||||
}, [cropState]);
|
||||
|
||||
const initCrop = useCallback(() => {
|
||||
const margin = 0.1;
|
||||
setCropState({
|
||||
x: canvasSize.width * margin,
|
||||
y: canvasSize.height * margin,
|
||||
width: canvasSize.width * (1 - 2 * margin),
|
||||
height: canvasSize.height * (1 - 2 * margin),
|
||||
aspectRatio: null,
|
||||
});
|
||||
}, [canvasSize, setCropState]);
|
||||
|
||||
const setAspectRatio = useCallback(
|
||||
(label: string) => {
|
||||
setAspectRatioState(label);
|
||||
const preset = ASPECT_RATIOS.find((p) => p.label === label);
|
||||
if (!preset || !preset.value || !cropState) return;
|
||||
const ratio = preset.value;
|
||||
|
||||
let w = cropState.width;
|
||||
let h = w / ratio;
|
||||
if (h > canvasSize.height) {
|
||||
h = canvasSize.height * 0.8;
|
||||
w = h * ratio;
|
||||
}
|
||||
|
||||
setCropState({
|
||||
...cropState,
|
||||
width: w,
|
||||
height: h,
|
||||
aspectRatio: label,
|
||||
});
|
||||
},
|
||||
[cropState, canvasSize, setCropState],
|
||||
);
|
||||
|
||||
const applyCrop = useCallback(() => {
|
||||
applyCropAction();
|
||||
}, [applyCropAction]);
|
||||
|
||||
const cancelCrop = useCallback(() => {
|
||||
setCropState(null);
|
||||
}, [setCropState]);
|
||||
|
||||
const updateCropSize = useCallback(
|
||||
(w: number, h: number) => {
|
||||
if (!cropState) return;
|
||||
setCropState({ ...cropState, width: w, height: h });
|
||||
},
|
||||
[cropState, setCropState],
|
||||
);
|
||||
|
||||
const swapDimensions = useCallback(() => {
|
||||
if (!cropState) return;
|
||||
setCropState({
|
||||
...cropState,
|
||||
width: cropState.height,
|
||||
height: cropState.width,
|
||||
});
|
||||
}, [cropState, setCropState]);
|
||||
|
||||
return {
|
||||
cropRef,
|
||||
transformerRef,
|
||||
cropState,
|
||||
aspectRatio,
|
||||
setAspectRatio,
|
||||
initCrop,
|
||||
applyCrop,
|
||||
cancelCrop,
|
||||
updateCropSize,
|
||||
swapDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CropOverlay -- renders darkened overlay + crop region + rule-of-thirds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CropOverlay() {
|
||||
const cropState = useEditorStore((s) => s.cropState);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const setCropState = useEditorStore((s) => s.setCropState);
|
||||
const cropRectRef = useRef<Konva.Rect | null>(null);
|
||||
const trRef = useRef<Konva.Transformer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (trRef.current && cropRectRef.current && cropState) {
|
||||
trRef.current.nodes([cropRectRef.current]);
|
||||
trRef.current.getLayer()?.batchDraw();
|
||||
}
|
||||
}, [cropState]);
|
||||
|
||||
if (!cropState) return null;
|
||||
|
||||
const { x, y, width, height } = cropState;
|
||||
const cw = canvasSize.width;
|
||||
const ch = canvasSize.height;
|
||||
const overlayFill = "rgba(0, 0, 0, 0.5)";
|
||||
|
||||
// Rule-of-thirds grid lines
|
||||
const thirdW = width / 3;
|
||||
const thirdH = height / 3;
|
||||
|
||||
const handleTransformEnd = () => {
|
||||
const node = cropRectRef.current;
|
||||
if (!node) return;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
const newW = Math.max(10, node.width() * scaleX);
|
||||
const newH = Math.max(10, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
setCropState({
|
||||
...cropState,
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: newW,
|
||||
height: newH,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
const node = cropRectRef.current;
|
||||
if (!node) return;
|
||||
setCropState({
|
||||
...cropState,
|
||||
x: Math.max(0, Math.min(node.x(), cw - width)),
|
||||
y: Math.max(0, Math.min(node.y(), ch - height)),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group listening={false}>
|
||||
{/* Darkened overlays: top, bottom, left, right */}
|
||||
<Rect x={0} y={0} width={cw} height={y} fill={overlayFill} listening={false} />
|
||||
<Rect
|
||||
x={0}
|
||||
y={y + height}
|
||||
width={cw}
|
||||
height={ch - y - height}
|
||||
fill={overlayFill}
|
||||
listening={false}
|
||||
/>
|
||||
<Rect x={0} y={y} width={x} height={height} fill={overlayFill} listening={false} />
|
||||
<Rect
|
||||
x={x + width}
|
||||
y={y}
|
||||
width={cw - x - width}
|
||||
height={height}
|
||||
fill={overlayFill}
|
||||
listening={false}
|
||||
/>
|
||||
|
||||
{/* Crop region */}
|
||||
<Rect
|
||||
ref={cropRectRef}
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
draggable
|
||||
listening
|
||||
onDragEnd={handleDragEnd}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
/>
|
||||
|
||||
{/* Rule-of-thirds grid */}
|
||||
<Line
|
||||
points={[x + thirdW, y, x + thirdW, y + height]}
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
strokeWidth={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[x + thirdW * 2, y, x + thirdW * 2, y + height]}
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
strokeWidth={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[x, y + thirdH, x + width, y + thirdH]}
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
strokeWidth={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={[x, y + thirdH * 2, x + width, y + thirdH * 2]}
|
||||
stroke="rgba(255,255,255,0.4)"
|
||||
strokeWidth={0.5}
|
||||
listening={false}
|
||||
/>
|
||||
|
||||
{/* Transformer */}
|
||||
<Transformer
|
||||
ref={trRef}
|
||||
rotateEnabled={false}
|
||||
flipEnabled={false}
|
||||
keepRatio={false}
|
||||
anchorSize={8}
|
||||
anchorStroke="#ffffff"
|
||||
anchorFill="#3b82f6"
|
||||
anchorCornerRadius={1}
|
||||
borderStroke="#ffffff"
|
||||
borderStrokeWidth={1}
|
||||
enabledAnchors={[
|
||||
"top-left",
|
||||
"top-center",
|
||||
"top-right",
|
||||
"middle-left",
|
||||
"middle-right",
|
||||
"bottom-left",
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
]}
|
||||
boundBoxFunc={(_oldBox, newBox) => ({
|
||||
...newBox,
|
||||
width: Math.max(10, newBox.width),
|
||||
height: Math.max(10, newBox.height),
|
||||
})}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Transformer } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SmartGuide } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smart guide calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findSmartGuides(
|
||||
node: Konva.Node,
|
||||
allNodes: Konva.Node[],
|
||||
canvasWidth: number,
|
||||
canvasHeight: number,
|
||||
threshold: number,
|
||||
): SmartGuide[] {
|
||||
const box = node.getClientRect({ relativeTo: node.getParent() ?? undefined });
|
||||
const guides: SmartGuide[] = [];
|
||||
|
||||
const dragEdges = {
|
||||
left: box.x,
|
||||
right: box.x + box.width,
|
||||
centerX: box.x + box.width / 2,
|
||||
top: box.y,
|
||||
bottom: box.y + box.height,
|
||||
centerY: box.y + box.height / 2,
|
||||
};
|
||||
|
||||
// Canvas edges + center
|
||||
const canvasSnaps = [
|
||||
{ pos: 0, type: "canvas" as const, orient: "vertical" as const },
|
||||
{
|
||||
pos: canvasWidth / 2,
|
||||
type: "canvas" as const,
|
||||
orient: "vertical" as const,
|
||||
},
|
||||
{ pos: canvasWidth, type: "canvas" as const, orient: "vertical" as const },
|
||||
{ pos: 0, type: "canvas" as const, orient: "horizontal" as const },
|
||||
{
|
||||
pos: canvasHeight / 2,
|
||||
type: "canvas" as const,
|
||||
orient: "horizontal" as const,
|
||||
},
|
||||
{
|
||||
pos: canvasHeight,
|
||||
type: "canvas" as const,
|
||||
orient: "horizontal" as const,
|
||||
},
|
||||
];
|
||||
|
||||
for (const snap of canvasSnaps) {
|
||||
if (snap.orient === "vertical") {
|
||||
for (const edge of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
|
||||
if (Math.abs(edge - snap.pos) < threshold) {
|
||||
guides.push({
|
||||
orientation: "vertical",
|
||||
position: snap.pos,
|
||||
type: snap.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const edge of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
|
||||
if (Math.abs(edge - snap.pos) < threshold) {
|
||||
guides.push({
|
||||
orientation: "horizontal",
|
||||
position: snap.pos,
|
||||
type: snap.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other objects
|
||||
for (const other of allNodes) {
|
||||
if (other === node) continue;
|
||||
const ob = other.getClientRect({
|
||||
relativeTo: other.getParent() ?? undefined,
|
||||
});
|
||||
const targetEdges = {
|
||||
left: ob.x,
|
||||
right: ob.x + ob.width,
|
||||
centerX: ob.x + ob.width / 2,
|
||||
top: ob.y,
|
||||
bottom: ob.y + ob.height,
|
||||
centerY: ob.y + ob.height / 2,
|
||||
};
|
||||
|
||||
for (const edgeVal of [targetEdges.left, targetEdges.centerX, targetEdges.right]) {
|
||||
for (const dragVal of [dragEdges.left, dragEdges.centerX, dragEdges.right]) {
|
||||
if (Math.abs(dragVal - edgeVal) < threshold) {
|
||||
guides.push({
|
||||
orientation: "vertical",
|
||||
position: edgeVal,
|
||||
type: dragVal === dragEdges.centerX ? "center" : "edge",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edgeVal of [targetEdges.top, targetEdges.centerY, targetEdges.bottom]) {
|
||||
for (const dragVal of [dragEdges.top, dragEdges.centerY, dragEdges.bottom]) {
|
||||
if (Math.abs(dragVal - edgeVal) < threshold) {
|
||||
guides.push({
|
||||
orientation: "horizontal",
|
||||
position: edgeVal,
|
||||
type: dragVal === dragEdges.centerY ? "center" : "edge",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return guides;
|
||||
}
|
||||
|
||||
function snapPosition(
|
||||
pos: { x: number; y: number },
|
||||
guides: SmartGuide[],
|
||||
box: { width: number; height: number },
|
||||
threshold: number,
|
||||
): { x: number; y: number } {
|
||||
let { x, y } = pos;
|
||||
|
||||
for (const g of guides) {
|
||||
if (g.orientation === "vertical") {
|
||||
if (Math.abs(x - g.position) < threshold) x = g.position;
|
||||
else if (Math.abs(x + box.width / 2 - g.position) < threshold) x = g.position - box.width / 2;
|
||||
else if (Math.abs(x + box.width - g.position) < threshold) x = g.position - box.width;
|
||||
} else {
|
||||
if (Math.abs(y - g.position) < threshold) y = g.position;
|
||||
else if (Math.abs(y + box.height / 2 - g.position) < threshold)
|
||||
y = g.position - box.height / 2;
|
||||
else if (Math.abs(y + box.height - g.position) < threshold) y = g.position - box.height;
|
||||
}
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported helpers for alignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function alignObjects(
|
||||
direction:
|
||||
| "left"
|
||||
| "center-h"
|
||||
| "right"
|
||||
| "top"
|
||||
| "center-v"
|
||||
| "bottom"
|
||||
| "distribute-h"
|
||||
| "distribute-v",
|
||||
objectIds: string[],
|
||||
objects: { id: string; attrs: Record<string, unknown> }[],
|
||||
updateObject: (id: string, attrs: Record<string, unknown>) => void,
|
||||
): void {
|
||||
const selected = objects.filter((o) => objectIds.includes(o.id));
|
||||
if (selected.length < 2 && !direction.startsWith("distribute")) return;
|
||||
if (selected.length < 3 && direction.startsWith("distribute")) return;
|
||||
|
||||
const bounds = selected.map((o) => ({
|
||||
id: o.id,
|
||||
x: (o.attrs.x as number) ?? 0,
|
||||
y: (o.attrs.y as number) ?? 0,
|
||||
w: (o.attrs.width as number) ?? 0,
|
||||
h: (o.attrs.height as number) ?? 0,
|
||||
}));
|
||||
|
||||
switch (direction) {
|
||||
case "left": {
|
||||
const minX = Math.min(...bounds.map((b) => b.x));
|
||||
for (const b of bounds) updateObject(b.id, { x: minX });
|
||||
break;
|
||||
}
|
||||
case "center-h": {
|
||||
const minX = Math.min(...bounds.map((b) => b.x));
|
||||
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
|
||||
const center = (minX + maxX) / 2;
|
||||
for (const b of bounds) updateObject(b.id, { x: center - b.w / 2 });
|
||||
break;
|
||||
}
|
||||
case "right": {
|
||||
const maxX = Math.max(...bounds.map((b) => b.x + b.w));
|
||||
for (const b of bounds) updateObject(b.id, { x: maxX - b.w });
|
||||
break;
|
||||
}
|
||||
case "top": {
|
||||
const minY = Math.min(...bounds.map((b) => b.y));
|
||||
for (const b of bounds) updateObject(b.id, { y: minY });
|
||||
break;
|
||||
}
|
||||
case "center-v": {
|
||||
const minY = Math.min(...bounds.map((b) => b.y));
|
||||
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
|
||||
const center = (minY + maxY) / 2;
|
||||
for (const b of bounds) updateObject(b.id, { y: center - b.h / 2 });
|
||||
break;
|
||||
}
|
||||
case "bottom": {
|
||||
const maxY = Math.max(...bounds.map((b) => b.y + b.h));
|
||||
for (const b of bounds) updateObject(b.id, { y: maxY - b.h });
|
||||
break;
|
||||
}
|
||||
case "distribute-h": {
|
||||
const sorted = [...bounds].sort((a, b) => a.x - b.x);
|
||||
const totalW = sorted.reduce((s, b) => s + b.w, 0);
|
||||
const minX = sorted[0].x;
|
||||
const maxX = sorted[sorted.length - 1].x + sorted[sorted.length - 1].w;
|
||||
const gap = (maxX - minX - totalW) / (sorted.length - 1);
|
||||
let cx = minX;
|
||||
for (const b of sorted) {
|
||||
updateObject(b.id, { x: cx });
|
||||
cx += b.w + gap;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "distribute-v": {
|
||||
const sorted = [...bounds].sort((a, b) => a.y - b.y);
|
||||
const totalH = sorted.reduce((s, b) => s + b.h, 0);
|
||||
const minY = sorted[0].y;
|
||||
const maxY = sorted[sorted.length - 1].y + sorted[sorted.length - 1].h;
|
||||
const gap = (maxY - minY - totalH) / (sorted.length - 1);
|
||||
let cy = minY;
|
||||
for (const b of sorted) {
|
||||
updateObject(b.id, { y: cy });
|
||||
cy += b.h + gap;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useMoveTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MoveToolApi {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
smartGuides: SmartGuide[];
|
||||
onSelect: (e: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onStageClick: (e: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onDragStart: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onDragMove: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
|
||||
nudge: (dx: number, dy: number) => void;
|
||||
}
|
||||
|
||||
export function useMoveTool(): MoveToolApi {
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const smartGuidesRef = useRef<SmartGuide[]>([]);
|
||||
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const setSelectedObjects = useEditorStore((s) => s.setSelectedObjects);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const snapToGuides = useEditorStore((s) => s.snapToGuides);
|
||||
|
||||
// Attach transformer to selected nodes
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
if (!tr) return;
|
||||
const stage = tr.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const nodes = selectedObjectIds
|
||||
.map((id) => stage.findOne(`#${id}`))
|
||||
.filter(Boolean) as Konva.Node[];
|
||||
tr.nodes(nodes);
|
||||
tr.getLayer()?.batchDraw();
|
||||
}, [selectedObjectIds]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const target = e.target;
|
||||
const id = target.id();
|
||||
if (!id) return;
|
||||
|
||||
if (e.evt.shiftKey) {
|
||||
// Toggle multi-select
|
||||
if (selectedObjectIds.includes(id)) {
|
||||
setSelectedObjects(selectedObjectIds.filter((i) => i !== id));
|
||||
} else {
|
||||
setSelectedObjects([...selectedObjectIds, id]);
|
||||
}
|
||||
} else {
|
||||
setSelectedObjects([id]);
|
||||
}
|
||||
},
|
||||
[selectedObjectIds, setSelectedObjects],
|
||||
);
|
||||
|
||||
const onStageClick = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
// Clicked on stage background - deselect
|
||||
if (e.target === e.target.getStage()) {
|
||||
setSelectedObjects([]);
|
||||
}
|
||||
},
|
||||
[setSelectedObjects],
|
||||
);
|
||||
|
||||
const onDragStart = useCallback((_e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
// No-op -- selection already handled by onClick
|
||||
}, []);
|
||||
|
||||
const onDragMove = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
if (!snapToGuides) return;
|
||||
|
||||
const stage = node.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const allNodes = stage
|
||||
.find("Rect, Ellipse, Text, Image, Line, Arrow, RegularPolygon, Star")
|
||||
.filter((n) => n.id() && n.id() !== node.id());
|
||||
|
||||
const guides = findSmartGuides(node, allNodes, canvasSize.width, canvasSize.height, 5);
|
||||
smartGuidesRef.current = guides;
|
||||
|
||||
if (guides.length > 0) {
|
||||
const box = node.getClientRect({
|
||||
relativeTo: node.getParent() ?? undefined,
|
||||
});
|
||||
const snapped = snapPosition(
|
||||
{ x: node.x(), y: node.y() },
|
||||
guides,
|
||||
{ width: box.width, height: box.height },
|
||||
5,
|
||||
);
|
||||
node.position(snapped);
|
||||
}
|
||||
},
|
||||
[canvasSize, snapToGuides],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
smartGuidesRef.current = [];
|
||||
updateObject(node.id(), {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
});
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
const onTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
|
||||
// Normalize scale into width/height
|
||||
const newWidth = Math.max(1, node.width() * scaleX);
|
||||
const newHeight = Math.max(1, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
|
||||
updateObject(node.id(), {
|
||||
x: node.x(),
|
||||
y: node.y(),
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
rotation: node.rotation(),
|
||||
});
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
const nudge = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = useEditorStore.getState().objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
updateObject(id, {
|
||||
x: ((obj.attrs.x as number) ?? 0) + dx,
|
||||
y: ((obj.attrs.y as number) ?? 0) + dy,
|
||||
});
|
||||
}
|
||||
},
|
||||
[selectedObjectIds, updateObject],
|
||||
);
|
||||
|
||||
return {
|
||||
transformerRef,
|
||||
smartGuides: smartGuidesRef.current,
|
||||
onSelect,
|
||||
onStageClick,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragEnd,
|
||||
onTransformEnd,
|
||||
nudge,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveToolTransformer -- Konva Transformer component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MoveToolTransformer({
|
||||
transformerRef,
|
||||
}: {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
}) {
|
||||
return (
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
rotateEnabled
|
||||
flipEnabled
|
||||
keepRatio={false}
|
||||
anchorSize={8}
|
||||
anchorStroke="#3b82f6"
|
||||
anchorFill="#ffffff"
|
||||
anchorCornerRadius={2}
|
||||
borderStroke="#3b82f6"
|
||||
borderStrokeWidth={1}
|
||||
padding={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Ellipse, Group, Line, Rect } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { SelectionMode, SelectionState, SelectionType } from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marching ants animation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DASH = [6, 4];
|
||||
const MARCH_SPEED = 1;
|
||||
|
||||
function useMarchingAnts(layerRef: React.RefObject<Konva.Layer | null>) {
|
||||
const dashOffsetRef = useRef(0);
|
||||
const animRef = useRef<Konva.Animation | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = layerRef.current;
|
||||
if (!layer) return;
|
||||
|
||||
// Dynamically import Konva for Animation
|
||||
import("konva").then((KonvaModule) => {
|
||||
const anim = new KonvaModule.default.Animation(() => {
|
||||
dashOffsetRef.current -= MARCH_SPEED;
|
||||
}, layer);
|
||||
animRef.current = anim;
|
||||
anim.start();
|
||||
});
|
||||
|
||||
return () => {
|
||||
animRef.current?.stop();
|
||||
};
|
||||
}, [layerRef]);
|
||||
|
||||
return dashOffsetRef;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Magic wand -- flood fill to generate selection mask
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function floodFillMask(
|
||||
imageData: ImageData,
|
||||
startX: number,
|
||||
startY: number,
|
||||
tolerance: number,
|
||||
contiguous: boolean,
|
||||
): boolean[][] {
|
||||
const { width, height, data } = imageData;
|
||||
const mask: boolean[][] = Array.from(
|
||||
{ length: height },
|
||||
() => Array(width).fill(false) as boolean[],
|
||||
);
|
||||
|
||||
const sx = Math.round(startX);
|
||||
const sy = Math.round(startY);
|
||||
if (sx < 0 || sx >= width || sy < 0 || sy >= height) return mask;
|
||||
|
||||
const idx = (sy * width + sx) * 4;
|
||||
const targetR = data[idx];
|
||||
const targetG = data[idx + 1];
|
||||
const targetB = data[idx + 2];
|
||||
|
||||
function colorDist(i: number): number {
|
||||
const dr = data[i] - targetR;
|
||||
const dg = data[i + 1] - targetG;
|
||||
const db = data[i + 2] - targetB;
|
||||
return Math.sqrt(dr * dr + dg * dg + db * db);
|
||||
}
|
||||
|
||||
if (contiguous) {
|
||||
// Scanline flood fill
|
||||
const stack: [number, number][] = [[sx, sy]];
|
||||
while (stack.length > 0) {
|
||||
const item = stack.pop();
|
||||
if (!item) break;
|
||||
const [cx, cy] = item;
|
||||
if (cx < 0 || cx >= width || cy < 0 || cy >= height) continue;
|
||||
if (mask[cy][cx]) continue;
|
||||
const ci = (cy * width + cx) * 4;
|
||||
if (colorDist(ci) > tolerance) continue;
|
||||
mask[cy][cx] = true;
|
||||
stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
|
||||
}
|
||||
} else {
|
||||
// Select all matching pixels
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ci = (y * width + x) * 4;
|
||||
if (colorDist(ci) <= tolerance) {
|
||||
mask[y][x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
function maskToBounds(
|
||||
mask: boolean[][],
|
||||
): { x: number; y: number; width: number; height: number } | null {
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
let found = false;
|
||||
|
||||
for (let y = 0; y < mask.length; y++) {
|
||||
for (let x = 0; x < mask[y].length; x++) {
|
||||
if (mask[y][x]) {
|
||||
found = true;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x);
|
||||
maxY = Math.max(maxY, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return null;
|
||||
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection mask modification utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function expandMask(mask: boolean[][], amount: number): boolean[][] {
|
||||
const h = mask.length;
|
||||
const w = mask[0]?.length ?? 0;
|
||||
const result: boolean[][] = Array.from({ length: h }, () => Array(w).fill(false) as boolean[]);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (!mask[y][x]) continue;
|
||||
for (let dy = -amount; dy <= amount; dy++) {
|
||||
for (let dx = -amount; dx <= amount; dx++) {
|
||||
const ny = y + dy;
|
||||
const nx = x + dx;
|
||||
if (ny >= 0 && ny < h && nx >= 0 && nx < w) {
|
||||
if (dx * dx + dy * dy <= amount * amount) {
|
||||
result[ny][nx] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function contractMask(mask: boolean[][], amount: number): boolean[][] {
|
||||
const inverted = mask.map((row) => row.map((v) => !v));
|
||||
const expanded = expandMask(inverted, amount);
|
||||
return expanded.map((row) => row.map((v) => !v));
|
||||
}
|
||||
|
||||
export function featherMask(mask: boolean[][], radius: number): number[][] {
|
||||
const h = mask.length;
|
||||
const w = mask[0]?.length ?? 0;
|
||||
const result: number[][] = Array.from({ length: h }, () => Array(w).fill(0) as number[]);
|
||||
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
if (mask[y][x]) {
|
||||
result[y][x] = 1;
|
||||
continue;
|
||||
}
|
||||
// Distance to nearest mask pixel within radius
|
||||
let minDist = radius + 1;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const ny = y + dy;
|
||||
const nx = x + dx;
|
||||
if (ny >= 0 && ny < h && nx >= 0 && nx < w && mask[ny][nx]) {
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
minDist = Math.min(minDist, d);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minDist <= radius) {
|
||||
result[y][x] = 1 - minDist / radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function invertMask(mask: boolean[][]): boolean[][] {
|
||||
return mask.map((row) => row.map((v) => !v));
|
||||
}
|
||||
|
||||
/** Ray-casting point-in-polygon test */
|
||||
export function pointInPolygon(x: number, y: number, points: number[]): boolean {
|
||||
let inside = false;
|
||||
const n = points.length / 2;
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const xi = points[i * 2];
|
||||
const yi = points[i * 2 + 1];
|
||||
const xj = points[j * 2];
|
||||
const yj = points[j * 2 + 1];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useSelectionTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SelectionToolApi {
|
||||
selectionType: SelectionType;
|
||||
setSelectionType: (t: SelectionType) => void;
|
||||
isDrawing: boolean;
|
||||
currentPoints: number[];
|
||||
onMouseDown: (pos: { x: number; y: number }, stage?: Konva.Stage) => void;
|
||||
onMouseMove: (pos: { x: number; y: number }) => void;
|
||||
onMouseUp: () => void;
|
||||
onDoubleClick: () => void;
|
||||
selectAll: () => void;
|
||||
deselect: () => void;
|
||||
magicWandSelect: (
|
||||
stage: Konva.Stage,
|
||||
x: number,
|
||||
y: number,
|
||||
tolerance: number,
|
||||
contiguous: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useSelectionTool(): SelectionToolApi {
|
||||
const [selectionType, setSelectionType] = useState<SelectionType>("rect");
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentPoints, setCurrentPoints] = useState<number[]>([]);
|
||||
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
|
||||
const setSelection = useEditorStore((s) => s.setSelection);
|
||||
const selectionMode = useEditorStore((s) => s.selectionMode);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const existingSelection = useEditorStore((s) => s.selection);
|
||||
|
||||
const mergeSelection = useCallback(
|
||||
(newSel: SelectionState, mode: SelectionMode) => {
|
||||
if (mode === "new" || !existingSelection) {
|
||||
setSelection(newSel);
|
||||
return;
|
||||
}
|
||||
|
||||
const eb = existingSelection.bounds;
|
||||
const nb = newSel.bounds;
|
||||
|
||||
if (mode === "add") {
|
||||
const x = Math.min(eb.x, nb.x);
|
||||
const y = Math.min(eb.y, nb.y);
|
||||
setSelection({
|
||||
...newSel,
|
||||
bounds: {
|
||||
x,
|
||||
y,
|
||||
width: Math.max(eb.x + eb.width, nb.x + nb.width) - x,
|
||||
height: Math.max(eb.y + eb.height, nb.y + nb.height) - y,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// subtract: use the new bounds minus overlap (simplified)
|
||||
setSelection(newSel);
|
||||
}
|
||||
},
|
||||
[existingSelection, setSelection],
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(pos: { x: number; y: number }, _stage?: Konva.Stage) => {
|
||||
setIsDrawing(true);
|
||||
startRef.current = pos;
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints([pos.x, pos.y]);
|
||||
} else {
|
||||
setCurrentPoints([]);
|
||||
}
|
||||
},
|
||||
[selectionType],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(pos: { x: number; y: number }) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
setCurrentPoints((prev) => [...prev, pos.x, pos.y]);
|
||||
} else {
|
||||
const s = startRef.current;
|
||||
setCurrentPoints([s.x, s.y, pos.x, pos.y]);
|
||||
}
|
||||
},
|
||||
[isDrawing, selectionType],
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (!isDrawing) return;
|
||||
setIsDrawing(false);
|
||||
|
||||
if (selectionType === "lasso") {
|
||||
if (currentPoints.length < 6) {
|
||||
setSelection(null);
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
const bounds = {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
mergeSelection({ type: "lasso", points: currentPoints, bounds }, selectionMode);
|
||||
} else {
|
||||
if (currentPoints.length < 4) {
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
const [x1, y1, x2, y2] = currentPoints;
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x2 - x1);
|
||||
const h = Math.abs(y2 - y1);
|
||||
if (w < 2 || h < 2) {
|
||||
setSelection(null);
|
||||
setCurrentPoints([]);
|
||||
return;
|
||||
}
|
||||
mergeSelection(
|
||||
{
|
||||
type: selectionType,
|
||||
points: [],
|
||||
bounds: { x, y, width: w, height: h },
|
||||
},
|
||||
selectionMode,
|
||||
);
|
||||
}
|
||||
setCurrentPoints([]);
|
||||
}, [isDrawing, currentPoints, selectionType, selectionMode, mergeSelection, setSelection]);
|
||||
|
||||
const onDoubleClick = useCallback(() => {
|
||||
// Close polygonal lasso
|
||||
if (selectionType === "lasso" && currentPoints.length >= 6) {
|
||||
setIsDrawing(false);
|
||||
const xs = currentPoints.filter((_, i) => i % 2 === 0);
|
||||
const ys = currentPoints.filter((_, i) => i % 2 === 1);
|
||||
mergeSelection(
|
||||
{
|
||||
type: "lasso",
|
||||
points: currentPoints,
|
||||
bounds: {
|
||||
x: Math.min(...xs),
|
||||
y: Math.min(...ys),
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
},
|
||||
},
|
||||
selectionMode,
|
||||
);
|
||||
setCurrentPoints([]);
|
||||
}
|
||||
}, [selectionType, currentPoints, selectionMode, mergeSelection]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelection({
|
||||
type: "rect",
|
||||
points: [],
|
||||
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
|
||||
});
|
||||
}, [canvasSize, setSelection]);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
setSelection(null);
|
||||
}, [setSelection]);
|
||||
|
||||
const magicWandSelect = useCallback(
|
||||
(stage: Konva.Stage, x: number, y: number, tolerance: number, contiguous: boolean) => {
|
||||
const canvas = stage.toCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const mask = floodFillMask(imageData, x, y, tolerance, contiguous);
|
||||
const bounds = maskToBounds(mask);
|
||||
if (!bounds) return;
|
||||
mergeSelection({ type: "rect", points: [], bounds }, selectionMode);
|
||||
},
|
||||
[selectionMode, mergeSelection],
|
||||
);
|
||||
|
||||
return {
|
||||
selectionType,
|
||||
setSelectionType,
|
||||
isDrawing,
|
||||
currentPoints,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onDoubleClick,
|
||||
selectAll,
|
||||
deselect,
|
||||
magicWandSelect,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectionOverlay -- renders selection outline with marching ants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SelectionOverlay({ layerRef }: { layerRef: React.RefObject<Konva.Layer | null> }) {
|
||||
const selection = useEditorStore((s) => s.selection);
|
||||
const dashOffset = useMarchingAnts(layerRef);
|
||||
|
||||
if (!selection) return null;
|
||||
|
||||
const { type, bounds, points } = selection;
|
||||
|
||||
if (type === "lasso" && points.length >= 6) {
|
||||
return (
|
||||
<Group>
|
||||
<Line
|
||||
points={points}
|
||||
closed
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Line
|
||||
points={points}
|
||||
closed
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "ellipse") {
|
||||
const rx = bounds.width / 2;
|
||||
const ry = bounds.height / 2;
|
||||
return (
|
||||
<Group>
|
||||
<Ellipse
|
||||
x={bounds.x + rx}
|
||||
y={bounds.y + ry}
|
||||
radiusX={rx}
|
||||
radiusY={ry}
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Ellipse
|
||||
x={bounds.x + rx}
|
||||
y={bounds.y + ry}
|
||||
radiusX={rx}
|
||||
radiusY={ry}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// Rectangular selection
|
||||
return (
|
||||
<Group>
|
||||
<Rect
|
||||
x={bounds.x}
|
||||
y={bounds.y}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
stroke="#000000"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current}
|
||||
listening={false}
|
||||
/>
|
||||
<Rect
|
||||
x={bounds.x}
|
||||
y={bounds.y}
|
||||
width={bounds.width}
|
||||
height={bounds.height}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={1}
|
||||
dash={DASH}
|
||||
dashOffset={dashOffset.current + DASH[0]}
|
||||
listening={false}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ActiveSelectionPreview -- rendered during drag to show selection shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ActiveSelectionPreview({
|
||||
type,
|
||||
points,
|
||||
}: {
|
||||
type: SelectionType;
|
||||
points: number[];
|
||||
}) {
|
||||
if (type === "lasso" && points.length >= 4) {
|
||||
return (
|
||||
<Line points={points} stroke="#3b82f6" strokeWidth={1} dash={[4, 4]} listening={false} />
|
||||
);
|
||||
}
|
||||
|
||||
if (points.length < 4) return null;
|
||||
|
||||
const [x1, y1, x2, y2] = points;
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x2 - x1);
|
||||
const h = Math.abs(y2 - y1);
|
||||
|
||||
if (type === "ellipse") {
|
||||
return (
|
||||
<Ellipse
|
||||
x={x + w / 2}
|
||||
y={y + h / 2}
|
||||
radiusX={w / 2}
|
||||
radiusY={h / 2}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={w}
|
||||
height={h}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={1}
|
||||
dash={[4, 4]}
|
||||
listening={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import type Konva from "konva";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Transformer } from "react-konva";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transform state -- position/size/rotation for the options bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformValues {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: useTransformTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransformToolApi {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
isTransforming: boolean;
|
||||
values: TransformValues;
|
||||
lockedAspect: boolean;
|
||||
setLockedAspect: (v: boolean) => void;
|
||||
activate: () => void;
|
||||
applyTransform: () => void;
|
||||
cancelTransform: () => void;
|
||||
setValues: (v: Partial<TransformValues>) => void;
|
||||
flipHorizontal: () => void;
|
||||
flipVertical: () => void;
|
||||
}
|
||||
|
||||
export function useTransformTool(): TransformToolApi {
|
||||
const transformerRef = useRef<Konva.Transformer | null>(null);
|
||||
const [isTransforming, setIsTransforming] = useState(false);
|
||||
const [lockedAspect, setLockedAspect] = useState(false);
|
||||
const [values, setValuesState] = useState<TransformValues>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rotation: 0,
|
||||
});
|
||||
const preTransformRef = useRef<TransformValues | null>(null);
|
||||
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const objects = useEditorStore((s) => s.objects);
|
||||
const updateObject = useEditorStore((s) => s.updateObject);
|
||||
const setTool = useEditorStore((s) => s.setTool);
|
||||
|
||||
// Read values from selected object(s)
|
||||
useEffect(() => {
|
||||
if (!isTransforming || selectedObjectIds.length === 0) return;
|
||||
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
|
||||
if (!obj) return;
|
||||
const v: TransformValues = {
|
||||
x: (obj.attrs.x as number) ?? 0,
|
||||
y: (obj.attrs.y as number) ?? 0,
|
||||
width: (obj.attrs.width as number) ?? 0,
|
||||
height: (obj.attrs.height as number) ?? 0,
|
||||
rotation: (obj.attrs.rotation as number) ?? 0,
|
||||
};
|
||||
setValuesState(v);
|
||||
}, [isTransforming, selectedObjectIds, objects]);
|
||||
|
||||
// Attach transformer
|
||||
useEffect(() => {
|
||||
const tr = transformerRef.current;
|
||||
if (!tr || !isTransforming) return;
|
||||
const stage = tr.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
const nodes = selectedObjectIds
|
||||
.map((id) => stage.findOne(`#${id}`))
|
||||
.filter(Boolean) as Konva.Node[];
|
||||
tr.nodes(nodes);
|
||||
tr.getLayer()?.batchDraw();
|
||||
}, [isTransforming, selectedObjectIds]);
|
||||
|
||||
const activate = useCallback(() => {
|
||||
if (selectedObjectIds.length === 0) return;
|
||||
setIsTransforming(true);
|
||||
// Store pre-transform state for cancel
|
||||
const obj = objects.find((o) => o.id === selectedObjectIds[0]);
|
||||
if (obj) {
|
||||
preTransformRef.current = {
|
||||
x: (obj.attrs.x as number) ?? 0,
|
||||
y: (obj.attrs.y as number) ?? 0,
|
||||
width: (obj.attrs.width as number) ?? 0,
|
||||
height: (obj.attrs.height as number) ?? 0,
|
||||
rotation: (obj.attrs.rotation as number) ?? 0,
|
||||
};
|
||||
}
|
||||
}, [selectedObjectIds, objects]);
|
||||
|
||||
const applyTransform = useCallback(() => {
|
||||
setIsTransforming(false);
|
||||
preTransformRef.current = null;
|
||||
setTool("move");
|
||||
}, [setTool]);
|
||||
|
||||
const cancelTransform = useCallback(() => {
|
||||
// Restore pre-transform state
|
||||
if (preTransformRef.current && selectedObjectIds.length > 0) {
|
||||
const prev = preTransformRef.current;
|
||||
for (const id of selectedObjectIds) {
|
||||
updateObject(id, {
|
||||
x: prev.x,
|
||||
y: prev.y,
|
||||
width: prev.width,
|
||||
height: prev.height,
|
||||
rotation: prev.rotation,
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsTransforming(false);
|
||||
preTransformRef.current = null;
|
||||
setTool("move");
|
||||
}, [selectedObjectIds, updateObject, setTool]);
|
||||
|
||||
const setValues = useCallback(
|
||||
(v: Partial<TransformValues>) => {
|
||||
setValuesState((prev) => {
|
||||
const next = { ...prev, ...v };
|
||||
|
||||
// If aspect is locked, derive height from width ratio or vice versa
|
||||
if (lockedAspect && prev.width > 0 && prev.height > 0) {
|
||||
const ratio = prev.width / prev.height;
|
||||
if (v.width !== undefined && v.height === undefined) {
|
||||
next.height = next.width / ratio;
|
||||
} else if (v.height !== undefined && v.width === undefined) {
|
||||
next.width = next.height * ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply to selected objects
|
||||
for (const id of selectedObjectIds) {
|
||||
updateObject(id, {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
rotation: next.rotation,
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[selectedObjectIds, updateObject, lockedAspect],
|
||||
);
|
||||
|
||||
const flipHorizontal = useCallback(() => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const currentScale = (obj.attrs.scaleX as number) ?? 1;
|
||||
updateObject(id, { scaleX: -currentScale });
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
const flipVertical = useCallback(() => {
|
||||
for (const id of selectedObjectIds) {
|
||||
const obj = objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const currentScale = (obj.attrs.scaleY as number) ?? 1;
|
||||
updateObject(id, { scaleY: -currentScale });
|
||||
}
|
||||
}, [selectedObjectIds, objects, updateObject]);
|
||||
|
||||
return {
|
||||
transformerRef,
|
||||
isTransforming,
|
||||
values,
|
||||
lockedAspect,
|
||||
setLockedAspect,
|
||||
activate,
|
||||
applyTransform,
|
||||
cancelTransform,
|
||||
setValues,
|
||||
flipHorizontal,
|
||||
flipVertical,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TransformToolTransformer -- Konva Transformer for free transform mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TransformToolTransformer({
|
||||
transformerRef,
|
||||
onTransformEnd,
|
||||
}: {
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
|
||||
}) {
|
||||
const handleTransformEnd = useCallback(
|
||||
(e: Konva.KonvaEventObject<Event>) => {
|
||||
const node = e.target;
|
||||
const scaleX = node.scaleX();
|
||||
const scaleY = node.scaleY();
|
||||
const newW = Math.max(1, node.width() * scaleX);
|
||||
const newH = Math.max(1, node.height() * scaleY);
|
||||
node.scaleX(1);
|
||||
node.scaleY(1);
|
||||
node.width(newW);
|
||||
node.height(newH);
|
||||
onTransformEnd?.(e);
|
||||
},
|
||||
[onTransformEnd],
|
||||
);
|
||||
|
||||
return (
|
||||
<Transformer
|
||||
ref={transformerRef}
|
||||
rotateEnabled
|
||||
flipEnabled
|
||||
keepRatio={false}
|
||||
rotationSnaps={[
|
||||
0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 255, 270, 285,
|
||||
300, 315, 330, 345,
|
||||
]}
|
||||
anchorSize={8}
|
||||
anchorStroke="#3b82f6"
|
||||
anchorFill="#ffffff"
|
||||
anchorCornerRadius={2}
|
||||
borderStroke="#3b82f6"
|
||||
borderStrokeWidth={1}
|
||||
borderDash={[4, 4]}
|
||||
padding={0}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
import { create } from "zustand";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import type {
|
||||
AdjustmentValues,
|
||||
AnchorPosition,
|
||||
CropState,
|
||||
EditorLayer,
|
||||
EditorState,
|
||||
GuideOrientation,
|
||||
SelectionMode,
|
||||
SelectionState,
|
||||
ToolType,
|
||||
} from "@/types/editor";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createDefaultLayer(name = "Layer 1"): EditorLayer {
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_ADJUSTMENTS: AdjustmentValues = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
luminance: 0,
|
||||
exposure: 0,
|
||||
vibrance: 0,
|
||||
warmth: 0,
|
||||
};
|
||||
|
||||
function nextLayerName(layers: EditorLayer[]): string {
|
||||
const max = layers.reduce((n, l) => {
|
||||
const m = l.name.match(/^Layer (\d+)/);
|
||||
return m ? Math.max(n, Number(m[1])) : n;
|
||||
}, 0);
|
||||
return `Layer ${max + 1}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anchor offset calculation for canvas resize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function anchorOffset(
|
||||
anchor: AnchorPosition,
|
||||
oldW: number,
|
||||
oldH: number,
|
||||
newW: number,
|
||||
newH: number,
|
||||
): { dx: number; dy: number } {
|
||||
const dw = newW - oldW;
|
||||
const dh = newH - oldH;
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
|
||||
if (anchor.includes("center") && !anchor.includes("left") && !anchor.includes("right")) {
|
||||
dx = dw / 2;
|
||||
} else if (anchor.includes("right")) {
|
||||
dx = dw;
|
||||
}
|
||||
|
||||
if (anchor === "center" || anchor === "center-left" || anchor === "center-right") {
|
||||
dy = dh / 2;
|
||||
} else if (anchor.startsWith("bottom")) {
|
||||
dy = dh;
|
||||
}
|
||||
|
||||
return { dx, dy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultLayer = createDefaultLayer();
|
||||
|
||||
export const useEditorStore = create<EditorState>()((set, get) => ({
|
||||
// Canvas
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
zoom: 1,
|
||||
panOffset: { x: 0, y: 0 },
|
||||
|
||||
// Image
|
||||
sourceImageUrl: null,
|
||||
sourceImageSize: null,
|
||||
|
||||
// Active tool
|
||||
activeTool: "move",
|
||||
previousTool: null,
|
||||
|
||||
// Brush/Eraser
|
||||
brushSize: 12,
|
||||
brushOpacity: 1,
|
||||
brushHardness: 1,
|
||||
|
||||
// Colors
|
||||
foregroundColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
recentColors: [],
|
||||
|
||||
// Layers
|
||||
layers: [defaultLayer],
|
||||
activeLayerId: defaultLayer.id,
|
||||
|
||||
// Objects
|
||||
objects: [],
|
||||
selectedObjectIds: [],
|
||||
clipboard: [],
|
||||
|
||||
// Selection
|
||||
selection: null,
|
||||
selectionMode: "new" as SelectionMode,
|
||||
|
||||
// Crop
|
||||
cropState: null,
|
||||
isCropping: false,
|
||||
|
||||
// Adjustments & Filters
|
||||
adjustments: { ...DEFAULT_ADJUSTMENTS },
|
||||
filters: [],
|
||||
|
||||
// Text
|
||||
editingTextId: null,
|
||||
|
||||
// Shape settings
|
||||
shapeFill: "#3b82f6",
|
||||
shapeStroke: "#000000",
|
||||
shapeStrokeWidth: 2,
|
||||
shapeCornerRadius: 0,
|
||||
shapePolygonSides: 6,
|
||||
shapeStarPoints: 5,
|
||||
|
||||
// Guides
|
||||
guides: [],
|
||||
showRulers: true,
|
||||
showGuides: true,
|
||||
snapToGuides: true,
|
||||
|
||||
// UI
|
||||
rightPanelTab: "layers",
|
||||
rightPanelVisible: true,
|
||||
|
||||
// Document
|
||||
isDirty: false,
|
||||
|
||||
// History
|
||||
lastAction: "",
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
setTool: (tool: ToolType) => set((s) => ({ activeTool: tool, previousTool: s.activeTool })),
|
||||
|
||||
setZoom: (zoom: number) => set({ zoom: Math.max(0.01, Math.min(64, zoom)) }),
|
||||
|
||||
setPanOffset: (offset) => set({ panOffset: offset }),
|
||||
|
||||
loadImage: (url, width, height) =>
|
||||
set({
|
||||
sourceImageUrl: url,
|
||||
sourceImageSize: { width, height },
|
||||
canvasSize: { width, height },
|
||||
zoom: 1,
|
||||
panOffset: { x: 0, y: 0 },
|
||||
isDirty: false,
|
||||
}),
|
||||
|
||||
// -- Colors --
|
||||
|
||||
setForegroundColor: (color) =>
|
||||
set((s) => {
|
||||
const recent = [color, ...s.recentColors.filter((c) => c !== color)].slice(0, 12);
|
||||
return { foregroundColor: color, recentColors: recent };
|
||||
}),
|
||||
|
||||
setBackgroundColor: (color) => set({ backgroundColor: color }),
|
||||
|
||||
swapColors: () =>
|
||||
set((s) => ({
|
||||
foregroundColor: s.backgroundColor,
|
||||
backgroundColor: s.foregroundColor,
|
||||
})),
|
||||
|
||||
resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }),
|
||||
|
||||
// -- Objects --
|
||||
|
||||
addObject: (obj) =>
|
||||
set((s) => ({
|
||||
objects: [...s.objects, { ...obj, layerId: s.activeLayerId }],
|
||||
isDirty: true,
|
||||
lastAction: "Add Object",
|
||||
})),
|
||||
|
||||
updateObject: (id, attrs) =>
|
||||
set((s) => ({
|
||||
objects: s.objects.map((o) => (o.id === id ? { ...o, attrs: { ...o.attrs, ...attrs } } : o)),
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
removeObjects: (ids) =>
|
||||
set((s) => ({
|
||||
objects: s.objects.filter((o) => !ids.includes(o.id)),
|
||||
selectedObjectIds: s.selectedObjectIds.filter((i) => !ids.includes(i)),
|
||||
isDirty: true,
|
||||
lastAction: "Delete Object",
|
||||
})),
|
||||
|
||||
setSelectedObjects: (ids) => set({ selectedObjectIds: ids }),
|
||||
|
||||
duplicateObjects: (ids) => {
|
||||
const s = get();
|
||||
const dupes = s.objects
|
||||
.filter((o) => ids.includes(o.id))
|
||||
.map((o) => ({
|
||||
...o,
|
||||
id: generateId(),
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) + 10,
|
||||
y: ((o.attrs.y as number) ?? 0) + 10,
|
||||
},
|
||||
}));
|
||||
set({
|
||||
objects: [...s.objects, ...dupes],
|
||||
selectedObjectIds: dupes.map((d) => d.id),
|
||||
isDirty: true,
|
||||
lastAction: "Duplicate",
|
||||
});
|
||||
},
|
||||
|
||||
// -- Z-ordering --
|
||||
|
||||
bringToFront: (id) =>
|
||||
set((s) => {
|
||||
const obj = s.objects.find((o) => o.id === id);
|
||||
if (!obj) return s;
|
||||
const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id);
|
||||
const others = s.objects.filter((o) => o.layerId !== obj.layerId);
|
||||
return {
|
||||
objects: [...others, ...layerObjs, obj],
|
||||
isDirty: true,
|
||||
lastAction: "Bring to Front",
|
||||
};
|
||||
}),
|
||||
|
||||
bringForward: (id) =>
|
||||
set((s) => {
|
||||
const idx = s.objects.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return s;
|
||||
const obj = s.objects[idx];
|
||||
// Find the next object in the same layer
|
||||
let nextIdx = -1;
|
||||
for (let i = idx + 1; i < s.objects.length; i++) {
|
||||
if (s.objects[i].layerId === obj.layerId) {
|
||||
nextIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextIdx === -1) return s;
|
||||
const copy = [...s.objects];
|
||||
copy.splice(idx, 1);
|
||||
copy.splice(nextIdx, 0, obj);
|
||||
return { objects: copy, isDirty: true, lastAction: "Bring Forward" };
|
||||
}),
|
||||
|
||||
sendBackward: (id) =>
|
||||
set((s) => {
|
||||
const idx = s.objects.findIndex((o) => o.id === id);
|
||||
if (idx === -1) return s;
|
||||
const obj = s.objects[idx];
|
||||
let prevIdx = -1;
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (s.objects[i].layerId === obj.layerId) {
|
||||
prevIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevIdx === -1) return s;
|
||||
const copy = [...s.objects];
|
||||
copy.splice(idx, 1);
|
||||
copy.splice(prevIdx, 0, obj);
|
||||
return { objects: copy, isDirty: true, lastAction: "Send Backward" };
|
||||
}),
|
||||
|
||||
sendToBack: (id) =>
|
||||
set((s) => {
|
||||
const obj = s.objects.find((o) => o.id === id);
|
||||
if (!obj) return s;
|
||||
const layerObjs = s.objects.filter((o) => o.layerId === obj.layerId && o.id !== id);
|
||||
const others = s.objects.filter((o) => o.layerId !== obj.layerId);
|
||||
return {
|
||||
objects: [...others, obj, ...layerObjs],
|
||||
isDirty: true,
|
||||
lastAction: "Send to Back",
|
||||
};
|
||||
}),
|
||||
|
||||
// -- Clipboard --
|
||||
|
||||
copyObjects: (ids) => {
|
||||
const s = get();
|
||||
const copied = s.objects.filter((o) => ids.includes(o.id));
|
||||
set({ clipboard: copied });
|
||||
},
|
||||
|
||||
cutObjects: (ids) => {
|
||||
const s = get();
|
||||
const copied = s.objects.filter((o) => ids.includes(o.id));
|
||||
set({
|
||||
clipboard: copied,
|
||||
objects: s.objects.filter((o) => !ids.includes(o.id)),
|
||||
selectedObjectIds: [],
|
||||
isDirty: true,
|
||||
lastAction: "Cut",
|
||||
});
|
||||
},
|
||||
|
||||
pasteObjects: () => {
|
||||
const s = get();
|
||||
if (s.clipboard.length === 0) return;
|
||||
const pasted = s.clipboard.map((o) => ({
|
||||
...o,
|
||||
id: generateId(),
|
||||
layerId: s.activeLayerId,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) + 10,
|
||||
y: ((o.attrs.y as number) ?? 0) + 10,
|
||||
},
|
||||
}));
|
||||
set({
|
||||
objects: [...s.objects, ...pasted],
|
||||
selectedObjectIds: pasted.map((p) => p.id),
|
||||
isDirty: true,
|
||||
lastAction: "Paste",
|
||||
});
|
||||
},
|
||||
|
||||
// -- Layers --
|
||||
|
||||
addLayer: () => {
|
||||
const s = get();
|
||||
const layer = createDefaultLayer(nextLayerName(s.layers));
|
||||
const idx = s.layers.findIndex((l) => l.id === s.activeLayerId);
|
||||
const copy = [...s.layers];
|
||||
copy.splice(idx + 1, 0, layer);
|
||||
set({
|
||||
layers: copy,
|
||||
activeLayerId: layer.id,
|
||||
isDirty: true,
|
||||
lastAction: "Add Layer",
|
||||
});
|
||||
},
|
||||
|
||||
removeLayer: (id) =>
|
||||
set((s) => {
|
||||
if (s.layers.length <= 1) return s;
|
||||
const idx = s.layers.findIndex((l) => l.id === id);
|
||||
const remaining = s.layers.filter((l) => l.id !== id);
|
||||
const newActive =
|
||||
s.activeLayerId === id
|
||||
? remaining[Math.min(idx, remaining.length - 1)].id
|
||||
: s.activeLayerId;
|
||||
return {
|
||||
layers: remaining,
|
||||
objects: s.objects.filter((o) => o.layerId !== id),
|
||||
activeLayerId: newActive,
|
||||
isDirty: true,
|
||||
lastAction: "Delete Layer",
|
||||
};
|
||||
}),
|
||||
|
||||
duplicateLayer: (id) => {
|
||||
const s = get();
|
||||
const src = s.layers.find((l) => l.id === id);
|
||||
if (!src) return;
|
||||
const newId = generateId();
|
||||
const dup: EditorLayer = {
|
||||
...src,
|
||||
id: newId,
|
||||
name: `${src.name} (copy)`,
|
||||
thumbnail: null,
|
||||
};
|
||||
const dupeObjs = s.objects
|
||||
.filter((o) => o.layerId === id)
|
||||
.map((o) => ({ ...o, id: generateId(), layerId: newId }));
|
||||
const idx = s.layers.findIndex((l) => l.id === id);
|
||||
const copy = [...s.layers];
|
||||
copy.splice(idx + 1, 0, dup);
|
||||
set({
|
||||
layers: copy,
|
||||
objects: [...s.objects, ...dupeObjs],
|
||||
activeLayerId: newId,
|
||||
isDirty: true,
|
||||
lastAction: "Duplicate Layer",
|
||||
});
|
||||
},
|
||||
|
||||
setActiveLayer: (id) => set({ activeLayerId: id }),
|
||||
|
||||
updateLayer: (id, updates) =>
|
||||
set((s) => ({
|
||||
layers: s.layers.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
reorderLayers: (fromIndex, toIndex) =>
|
||||
set((s) => {
|
||||
const copy = [...s.layers];
|
||||
const [moved] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, moved);
|
||||
return { layers: copy, isDirty: true, lastAction: "Reorder Layers" };
|
||||
}),
|
||||
|
||||
mergeDown: (id) =>
|
||||
set((s) => {
|
||||
const idx = s.layers.findIndex((l) => l.id === id);
|
||||
if (idx <= 0) return s;
|
||||
const below = s.layers[idx - 1];
|
||||
const merged = s.objects.map((o) => (o.layerId === id ? { ...o, layerId: below.id } : o));
|
||||
return {
|
||||
layers: s.layers.filter((l) => l.id !== id),
|
||||
objects: merged,
|
||||
activeLayerId: below.id,
|
||||
isDirty: true,
|
||||
lastAction: "Merge Down",
|
||||
};
|
||||
}),
|
||||
|
||||
flattenAll: () =>
|
||||
set((s) => {
|
||||
const first = s.layers[0];
|
||||
if (!first) return s;
|
||||
return {
|
||||
layers: [{ ...first, name: "Layer 1" }],
|
||||
objects: s.objects.map((o) => ({ ...o, layerId: first.id })),
|
||||
activeLayerId: first.id,
|
||||
isDirty: true,
|
||||
lastAction: "Flatten All",
|
||||
};
|
||||
}),
|
||||
|
||||
// -- Adjustments & Filters --
|
||||
|
||||
setAdjustment: (key, value) =>
|
||||
set((s) => ({
|
||||
adjustments: { ...s.adjustments, [key]: value },
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }),
|
||||
|
||||
toggleFilter: (type) =>
|
||||
set((s) => {
|
||||
const existing = s.filters.find((f) => f.type === type);
|
||||
if (existing) {
|
||||
return {
|
||||
filters: s.filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
|
||||
isDirty: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
filters: [...s.filters, { type, enabled: true, params: {} }],
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
setFilterParam: (type, key, value) =>
|
||||
set((s) => ({
|
||||
filters: s.filters.map((f) =>
|
||||
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
|
||||
),
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
// -- Selection --
|
||||
|
||||
setSelection: (selection: SelectionState | null) => set({ selection }),
|
||||
|
||||
setSelectionMode: (mode: SelectionMode) => set({ selectionMode: mode }),
|
||||
|
||||
invertSelection: () =>
|
||||
set((s) => {
|
||||
if (!s.selection) return s;
|
||||
// Invert: swap to full canvas bounds minus current selection
|
||||
return {
|
||||
selection: {
|
||||
...s.selection,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: s.canvasSize.width,
|
||||
height: s.canvasSize.height,
|
||||
},
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}),
|
||||
|
||||
// -- Crop --
|
||||
|
||||
setCropState: (state: CropState | null) => set({ cropState: state, isCropping: state !== null }),
|
||||
|
||||
applyCrop: () =>
|
||||
set((s) => {
|
||||
if (!s.cropState) return s;
|
||||
const { x, y, width, height } = s.cropState;
|
||||
return {
|
||||
canvasSize: { width, height },
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) - x,
|
||||
y: ((o.attrs.y as number) ?? 0) - y,
|
||||
},
|
||||
})),
|
||||
cropState: null,
|
||||
isCropping: false,
|
||||
isDirty: true,
|
||||
lastAction: "Crop",
|
||||
};
|
||||
}),
|
||||
|
||||
// -- Brush --
|
||||
|
||||
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(500, size)) }),
|
||||
setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }),
|
||||
setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }),
|
||||
|
||||
// -- Guides --
|
||||
|
||||
addGuide: (orientation: GuideOrientation, position: number) =>
|
||||
set((s) => ({
|
||||
guides: [...s.guides, { id: generateId(), orientation, position }],
|
||||
})),
|
||||
|
||||
removeGuide: (id) => set((s) => ({ guides: s.guides.filter((g) => g.id !== id) })),
|
||||
|
||||
updateGuide: (id, position) =>
|
||||
set((s) => ({
|
||||
guides: s.guides.map((g) => (g.id === id ? { ...g, position } : g)),
|
||||
})),
|
||||
|
||||
toggleRulers: () => set((s) => ({ showRulers: !s.showRulers })),
|
||||
toggleGuides: () => set((s) => ({ showGuides: !s.showGuides })),
|
||||
toggleSnapping: () => set((s) => ({ snapToGuides: !s.snapToGuides })),
|
||||
|
||||
// -- Document operations --
|
||||
|
||||
resizeCanvas: (width, height, anchor, _fill) =>
|
||||
set((s) => {
|
||||
const { dx, dy } = anchorOffset(
|
||||
anchor,
|
||||
s.canvasSize.width,
|
||||
s.canvasSize.height,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
return {
|
||||
canvasSize: { width, height },
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) + dx,
|
||||
y: ((o.attrs.y as number) ?? 0) + dy,
|
||||
},
|
||||
})),
|
||||
isDirty: true,
|
||||
lastAction: "Resize Canvas",
|
||||
};
|
||||
}),
|
||||
|
||||
resizeImage: (width, height) =>
|
||||
set((s) => {
|
||||
const sx = width / s.canvasSize.width;
|
||||
const sy = height / s.canvasSize.height;
|
||||
return {
|
||||
canvasSize: { width, height },
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) * sx,
|
||||
y: ((o.attrs.y as number) ?? 0) * sy,
|
||||
width: o.attrs.width ? (o.attrs.width as number) * sx : undefined,
|
||||
height: o.attrs.height ? (o.attrs.height as number) * sy : undefined,
|
||||
},
|
||||
})),
|
||||
isDirty: true,
|
||||
lastAction: "Resize Image",
|
||||
};
|
||||
}),
|
||||
|
||||
rotateCanvas: (degrees) =>
|
||||
set((s) => {
|
||||
const { width: w, height: h } = s.canvasSize;
|
||||
const swap = degrees === 90 || degrees === 270;
|
||||
const nw = swap ? h : w;
|
||||
const nh = swap ? w : h;
|
||||
return {
|
||||
canvasSize: { width: nw, height: nh },
|
||||
objects: s.objects.map((o) => {
|
||||
const ox = (o.attrs.x as number) ?? 0;
|
||||
const oy = (o.attrs.y as number) ?? 0;
|
||||
let nx: number;
|
||||
let ny: number;
|
||||
if (degrees === 90) {
|
||||
nx = h - oy;
|
||||
ny = ox;
|
||||
} else if (degrees === 180) {
|
||||
nx = w - ox;
|
||||
ny = h - oy;
|
||||
} else {
|
||||
nx = oy;
|
||||
ny = w - ox;
|
||||
}
|
||||
return { ...o, attrs: { ...o.attrs, x: nx, y: ny } };
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: "Rotate Canvas",
|
||||
};
|
||||
}),
|
||||
|
||||
flipCanvasHorizontal: () =>
|
||||
set((s) => ({
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: s.canvasSize.width - ((o.attrs.x as number) ?? 0),
|
||||
scaleX: -((o.attrs.scaleX as number) ?? 1),
|
||||
},
|
||||
})),
|
||||
isDirty: true,
|
||||
lastAction: "Flip Horizontal",
|
||||
})),
|
||||
|
||||
flipCanvasVertical: () =>
|
||||
set((s) => ({
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
y: s.canvasSize.height - ((o.attrs.y as number) ?? 0),
|
||||
scaleY: -((o.attrs.scaleY as number) ?? 1),
|
||||
},
|
||||
})),
|
||||
isDirty: true,
|
||||
lastAction: "Flip Vertical",
|
||||
})),
|
||||
|
||||
trimCanvas: () =>
|
||||
set((s) => {
|
||||
if (s.objects.length === 0) return s;
|
||||
let minX = Number.POSITIVE_INFINITY;
|
||||
let minY = Number.POSITIVE_INFINITY;
|
||||
let maxX = Number.NEGATIVE_INFINITY;
|
||||
let maxY = Number.NEGATIVE_INFINITY;
|
||||
for (const o of s.objects) {
|
||||
const x = (o.attrs.x as number) ?? 0;
|
||||
const y = (o.attrs.y as number) ?? 0;
|
||||
const w = (o.attrs.width as number) ?? 0;
|
||||
const h = (o.attrs.height as number) ?? 0;
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
}
|
||||
const tw = Math.max(1, maxX - minX);
|
||||
const th = Math.max(1, maxY - minY);
|
||||
return {
|
||||
canvasSize: { width: tw, height: th },
|
||||
objects: s.objects.map((o) => ({
|
||||
...o,
|
||||
attrs: {
|
||||
...o.attrs,
|
||||
x: ((o.attrs.x as number) ?? 0) - minX,
|
||||
y: ((o.attrs.y as number) ?? 0) - minY,
|
||||
},
|
||||
})),
|
||||
isDirty: true,
|
||||
lastAction: "Trim Canvas",
|
||||
};
|
||||
}),
|
||||
|
||||
// -- UI --
|
||||
|
||||
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
||||
toggleRightPanel: () => set((s) => ({ rightPanelVisible: !s.rightPanelVisible })),
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
markClean: () => set({ isDirty: false }),
|
||||
}));
|
||||
@@ -0,0 +1,279 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor Types -- shared across all editor components and the Zustand store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToolType =
|
||||
| "move"
|
||||
| "marquee-rect"
|
||||
| "marquee-ellipse"
|
||||
| "lasso-free"
|
||||
| "lasso-poly"
|
||||
| "magic-wand"
|
||||
| "crop"
|
||||
| "eyedropper"
|
||||
| "brush"
|
||||
| "eraser"
|
||||
| "pencil"
|
||||
| "fill"
|
||||
| "gradient"
|
||||
| "shape-rect"
|
||||
| "shape-ellipse"
|
||||
| "shape-line"
|
||||
| "shape-arrow"
|
||||
| "shape-polygon"
|
||||
| "shape-star"
|
||||
| "text"
|
||||
| "hand"
|
||||
| "zoom"
|
||||
| "transform";
|
||||
|
||||
export type CanvasObjectType =
|
||||
| "line"
|
||||
| "rect"
|
||||
| "ellipse"
|
||||
| "polygon"
|
||||
| "star"
|
||||
| "arrow"
|
||||
| "text"
|
||||
| "image"
|
||||
| "path";
|
||||
|
||||
export interface CanvasObject {
|
||||
id: string;
|
||||
type: CanvasObjectType;
|
||||
layerId: string;
|
||||
attrs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EditorLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
opacity: number;
|
||||
blendMode: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export type SelectionType = "rect" | "ellipse" | "lasso";
|
||||
|
||||
export interface SelectionState {
|
||||
type: SelectionType;
|
||||
points: number[];
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
}
|
||||
|
||||
export type SelectionMode = "new" | "add" | "subtract";
|
||||
|
||||
export interface CropState {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: string | null;
|
||||
}
|
||||
|
||||
export interface AdjustmentValues {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
exposure: number;
|
||||
vibrance: number;
|
||||
warmth: number;
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
export type GuideOrientation = "horizontal" | "vertical";
|
||||
|
||||
export interface Guide {
|
||||
id: string;
|
||||
orientation: GuideOrientation;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type AnchorPosition =
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "center-left"
|
||||
| "center"
|
||||
| "center-right"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
|
||||
export type ResampleMethod = "nearest" | "bilinear" | "bicubic" | "lanczos";
|
||||
|
||||
export interface SmartGuide {
|
||||
orientation: GuideOrientation;
|
||||
position: number;
|
||||
type: "edge" | "center" | "canvas";
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
// Canvas
|
||||
canvasSize: { width: number; height: number };
|
||||
zoom: number;
|
||||
panOffset: { x: number; y: number };
|
||||
|
||||
// Image
|
||||
sourceImageUrl: string | null;
|
||||
sourceImageSize: { width: number; height: number } | null;
|
||||
|
||||
// Active tool
|
||||
activeTool: ToolType;
|
||||
previousTool: ToolType | null;
|
||||
|
||||
// Brush/Eraser settings
|
||||
brushSize: number;
|
||||
brushOpacity: number;
|
||||
brushHardness: number;
|
||||
|
||||
// Colors
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
recentColors: string[];
|
||||
|
||||
// Layers
|
||||
layers: EditorLayer[];
|
||||
activeLayerId: string;
|
||||
|
||||
// Objects
|
||||
objects: CanvasObject[];
|
||||
selectedObjectIds: string[];
|
||||
clipboard: CanvasObject[];
|
||||
|
||||
// Selection
|
||||
selection: SelectionState | null;
|
||||
selectionMode: SelectionMode;
|
||||
|
||||
// Crop
|
||||
cropState: CropState | null;
|
||||
isCropping: boolean;
|
||||
|
||||
// Adjustments & Filters
|
||||
adjustments: AdjustmentValues;
|
||||
filters: FilterConfig[];
|
||||
|
||||
// Text
|
||||
editingTextId: string | null;
|
||||
|
||||
// Shape settings
|
||||
shapeFill: string;
|
||||
shapeStroke: string;
|
||||
shapeStrokeWidth: number;
|
||||
shapeCornerRadius: number;
|
||||
shapePolygonSides: number;
|
||||
shapeStarPoints: number;
|
||||
|
||||
// Guides
|
||||
guides: Guide[];
|
||||
showRulers: boolean;
|
||||
showGuides: boolean;
|
||||
snapToGuides: boolean;
|
||||
|
||||
// UI
|
||||
rightPanelTab: "layers" | "adjustments" | "history";
|
||||
rightPanelVisible: boolean;
|
||||
|
||||
// Document
|
||||
isDirty: boolean;
|
||||
|
||||
// History tracking
|
||||
lastAction: string;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Tool
|
||||
setTool: (tool: ToolType) => void;
|
||||
|
||||
// Canvas
|
||||
setZoom: (zoom: number) => void;
|
||||
setPanOffset: (offset: { x: number; y: number }) => void;
|
||||
loadImage: (url: string, width: number, height: number) => void;
|
||||
|
||||
// Colors
|
||||
setForegroundColor: (color: string) => void;
|
||||
setBackgroundColor: (color: string) => void;
|
||||
swapColors: () => void;
|
||||
resetColors: () => void;
|
||||
|
||||
// Objects
|
||||
addObject: (obj: CanvasObject) => void;
|
||||
updateObject: (id: string, attrs: Partial<CanvasObject["attrs"]>) => void;
|
||||
removeObjects: (ids: string[]) => void;
|
||||
setSelectedObjects: (ids: string[]) => void;
|
||||
duplicateObjects: (ids: string[]) => void;
|
||||
|
||||
// Z-ordering
|
||||
bringToFront: (id: string) => void;
|
||||
bringForward: (id: string) => void;
|
||||
sendBackward: (id: string) => void;
|
||||
sendToBack: (id: string) => void;
|
||||
|
||||
// Clipboard
|
||||
copyObjects: (ids: string[]) => void;
|
||||
cutObjects: (ids: string[]) => void;
|
||||
pasteObjects: () => void;
|
||||
|
||||
// Layers
|
||||
addLayer: () => void;
|
||||
removeLayer: (id: string) => void;
|
||||
duplicateLayer: (id: string) => void;
|
||||
setActiveLayer: (id: string) => void;
|
||||
updateLayer: (id: string, updates: Partial<EditorLayer>) => void;
|
||||
reorderLayers: (fromIndex: number, toIndex: number) => void;
|
||||
mergeDown: (id: string) => void;
|
||||
flattenAll: () => void;
|
||||
|
||||
// Adjustments & Filters
|
||||
setAdjustment: (key: keyof AdjustmentValues, value: number) => void;
|
||||
resetAdjustments: () => void;
|
||||
toggleFilter: (type: string) => void;
|
||||
setFilterParam: (type: string, key: string, value: number) => void;
|
||||
|
||||
// Selection
|
||||
setSelection: (selection: SelectionState | null) => void;
|
||||
setSelectionMode: (mode: SelectionMode) => void;
|
||||
invertSelection: () => void;
|
||||
|
||||
// Crop
|
||||
setCropState: (state: CropState | null) => void;
|
||||
applyCrop: () => void;
|
||||
|
||||
// Brush
|
||||
setBrushSize: (size: number) => void;
|
||||
setBrushOpacity: (opacity: number) => void;
|
||||
setBrushHardness: (hardness: number) => void;
|
||||
|
||||
// Guides
|
||||
addGuide: (orientation: GuideOrientation, position: number) => void;
|
||||
removeGuide: (id: string) => void;
|
||||
updateGuide: (id: string, position: number) => void;
|
||||
toggleRulers: () => void;
|
||||
toggleGuides: () => void;
|
||||
toggleSnapping: () => void;
|
||||
|
||||
// Document operations
|
||||
resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill: string) => void;
|
||||
resizeImage: (width: number, height: number) => void;
|
||||
rotateCanvas: (degrees: 90 | 180 | 270) => void;
|
||||
flipCanvasHorizontal: () => void;
|
||||
flipCanvasVertical: () => void;
|
||||
trimCanvas: () => void;
|
||||
|
||||
// UI
|
||||
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
|
||||
toggleRightPanel: () => void;
|
||||
markDirty: () => void;
|
||||
markClean: () => void;
|
||||
}
|
||||
Generated
+99
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user