mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-a82b6128' into feat/image-editor
# Conflicts: # apps/web/package.json # apps/web/src/stores/editor-store.ts # apps/web/src/types/editor.ts # pnpm-lock.yaml
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user