Merge branch 'worktree-agent-a82b6128' into feat/image-editor

# Conflicts:
#	apps/web/package.json
#	apps/web/src/stores/editor-store.ts
#	apps/web/src/types/editor.ts
#	pnpm-lock.yaml
This commit is contained in:
SnapOtter
2026-05-06 23:30:42 +08:00
14 changed files with 3363 additions and 0 deletions
@@ -0,0 +1,170 @@
import { X } from "lucide-react";
import { useCallback, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { AnchorPosition } from "@/types/editor";
// ---------------------------------------------------------------------------
// CanvasResizeDialog -- modal with W/H, 9-point anchor grid, fill color
// ---------------------------------------------------------------------------
const ANCHOR_POSITIONS: AnchorPosition[] = [
"top-left",
"top-center",
"top-right",
"center-left",
"center",
"center-right",
"bottom-left",
"bottom-center",
"bottom-right",
];
export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeCanvas = useEditorStore((s) => s.resizeCanvas);
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [anchor, setAnchor] = useState<AnchorPosition>("center");
const [fill, setFill] = useState("#ffffff");
const handleApply = useCallback(() => {
resizeCanvas(width, height, anchor, fill);
onClose();
}, [width, height, anchor, fill, resizeCanvas, onClose]);
if (!open) return null;
const inputCn = cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">Canvas Size</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-4 py-4">
{/* Current size info */}
<p className="text-xs text-muted-foreground">
Current: {canvasSize.width} x {canvasSize.height} px
</p>
{/* Width / Height */}
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="canvas-w" className="mb-1 block text-xs text-muted-foreground">
Width (px)
</label>
<input
id="canvas-w"
type="number"
min={1}
max={16384}
value={width}
onChange={(e) => setWidth(Math.max(1, Number(e.target.value) || 1))}
className={inputCn}
/>
</div>
<div>
<label htmlFor="canvas-h" className="mb-1 block text-xs text-muted-foreground">
Height (px)
</label>
<input
id="canvas-h"
type="number"
min={1}
max={16384}
value={height}
onChange={(e) => setHeight(Math.max(1, Number(e.target.value) || 1))}
className={inputCn}
/>
</div>
</div>
{/* Anchor grid */}
<div>
<p className="mb-2 text-xs text-muted-foreground">Anchor:</p>
<div className="inline-grid grid-cols-3 gap-1 rounded border border-border p-1.5">
{ANCHOR_POSITIONS.map((pos) => (
<button
key={pos}
type="button"
onClick={() => setAnchor(pos)}
title={pos.replace("-", " ")}
aria-label={`Anchor ${pos.replace("-", " ")}`}
aria-pressed={anchor === pos}
className={cn(
"h-5 w-5 rounded-sm transition-colors",
anchor === pos ? "bg-primary" : "bg-muted hover:bg-muted-foreground/20",
)}
/>
))}
</div>
</div>
{/* Background fill */}
<div className="flex items-center gap-2">
<label htmlFor="canvas-fill" className="text-xs text-muted-foreground">
Background:
</label>
<input
id="canvas-fill"
type="color"
value={fill}
onChange={(e) => setFill(e.target.value)}
className="h-7 w-7 cursor-pointer rounded border border-border"
/>
<input
type="text"
value={fill}
onChange={(e) => setFill(e.target.value)}
className={cn(
"h-7 w-20 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none",
)}
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
type="button"
onClick={onClose}
className={cn(
"h-8 rounded border border-border px-3 text-sm",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className={cn(
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
"hover:bg-primary/90 transition-colors",
)}
>
Apply
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,272 @@
import {
ArrowDown,
ArrowUp,
ClipboardPaste,
Copy,
CopyPlus,
ImageIcon,
Maximize,
MousePointer,
Scissors,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// Context menu state
// ---------------------------------------------------------------------------
interface MenuPosition {
x: number;
y: number;
}
interface MenuItem {
label: string;
icon: React.ComponentType<{ className?: string }>;
shortcut?: string;
action: () => void;
disabled?: boolean;
dividerAfter?: boolean;
}
// ---------------------------------------------------------------------------
// Hook: useContextMenu
// ---------------------------------------------------------------------------
export function useContextMenu() {
const [position, setPosition] = useState<MenuPosition | null>(null);
const [menuType, setMenuType] = useState<"object" | "canvas">("canvas");
const handleContextMenu = useCallback((e: React.MouseEvent, hasSelectedObject: boolean) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setMenuType(hasSelectedObject ? "object" : "canvas");
}, []);
const close = useCallback(() => {
setPosition(null);
}, []);
return { position, menuType, handleContextMenu, close };
}
// ---------------------------------------------------------------------------
// ContextMenu component
// ---------------------------------------------------------------------------
export function ContextMenu({
position,
menuType,
onClose,
}: {
position: MenuPosition;
menuType: "object" | "canvas";
onClose: () => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const copyObjects = useEditorStore((s) => s.copyObjects);
const cutObjects = useEditorStore((s) => s.cutObjects);
const pasteObjects = useEditorStore((s) => s.pasteObjects);
const removeObjects = useEditorStore((s) => s.removeObjects);
const duplicateObjects = useEditorStore((s) => s.duplicateObjects);
const bringToFront = useEditorStore((s) => s.bringToFront);
const bringForward = useEditorStore((s) => s.bringForward);
const sendBackward = useEditorStore((s) => s.sendBackward);
const sendToBack = useEditorStore((s) => s.sendToBack);
const clipboard = useEditorStore((s) => s.clipboard);
const setSelection = useEditorStore((s) => s.setSelection);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Close on click outside or Escape
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose]);
const objectItems: MenuItem[] = [
{
label: "Cut",
icon: Scissors,
shortcut: "Ctrl+X",
action: () => {
cutObjects(selectedObjectIds);
onClose();
},
},
{
label: "Copy",
icon: Copy,
shortcut: "Ctrl+C",
action: () => {
copyObjects(selectedObjectIds);
onClose();
},
},
{
label: "Paste",
icon: ClipboardPaste,
shortcut: "Ctrl+V",
action: () => {
pasteObjects();
onClose();
},
disabled: clipboard.length === 0,
},
{
label: "Duplicate",
icon: CopyPlus,
shortcut: "Ctrl+D",
action: () => {
duplicateObjects(selectedObjectIds);
onClose();
},
dividerAfter: true,
},
{
label: "Bring to Front",
icon: ArrowUp,
action: () => {
for (const id of selectedObjectIds) bringToFront(id);
onClose();
},
},
{
label: "Bring Forward",
icon: ArrowUp,
action: () => {
for (const id of selectedObjectIds) bringForward(id);
onClose();
},
},
{
label: "Send Backward",
icon: ArrowDown,
action: () => {
for (const id of selectedObjectIds) sendBackward(id);
onClose();
},
},
{
label: "Send to Back",
icon: ArrowDown,
action: () => {
for (const id of selectedObjectIds) sendToBack(id);
onClose();
},
dividerAfter: true,
},
{
label: "Delete",
icon: Trash2,
shortcut: "Del",
action: () => {
removeObjects(selectedObjectIds);
onClose();
},
},
];
const canvasItems: MenuItem[] = [
{
label: "Paste",
icon: ClipboardPaste,
shortcut: "Ctrl+V",
action: () => {
pasteObjects();
onClose();
},
disabled: clipboard.length === 0,
},
{
label: "Select All",
icon: MousePointer,
shortcut: "Ctrl+A",
action: () => {
setSelection({
type: "rect",
points: [],
bounds: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
},
});
onClose();
},
dividerAfter: true,
},
{
label: "Canvas Size...",
icon: Maximize,
action: () => {
// Trigger canvas resize dialog (handled by parent)
onClose();
},
},
{
label: "Image Size...",
icon: ImageIcon,
action: () => {
// Trigger image resize dialog (handled by parent)
onClose();
},
},
];
const items = menuType === "object" ? objectItems : canvasItems;
// Adjust position to stay within viewport
const adjustedX = Math.min(position.x, window.innerWidth - 220);
const adjustedY = Math.min(position.y, window.innerHeight - items.length * 36);
return (
<div
ref={menuRef}
className={cn(
"fixed z-50 min-w-[200px] rounded-lg border border-border bg-card py-1 shadow-lg",
"animate-in fade-in-0 zoom-in-95",
)}
style={{ left: adjustedX, top: adjustedY }}
>
{items.map((item) => (
<div key={item.label}>
<button
type="button"
onClick={item.action}
disabled={item.disabled}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm",
"text-foreground hover:bg-muted transition-colors",
"disabled:cursor-not-allowed disabled:opacity-40",
)}
>
<item.icon className="h-4 w-4 text-muted-foreground" />
<span className="flex-1">{item.label}</span>
{item.shortcut && (
<span className="text-xs text-muted-foreground">{item.shortcut}</span>
)}
</button>
{item.dividerAfter && <div className="my-1 h-px bg-border" />}
</div>
))}
</div>
);
}
@@ -0,0 +1,110 @@
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { Group, Line } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// GuideLines -- draggable guide lines rendered as Konva.Line
// ---------------------------------------------------------------------------
const GUIDE_COLOR = "#22d3ee"; // cyan-400
const GUIDE_WIDTH = 1;
export function GuideLines() {
const guides = useEditorStore((s) => s.guides);
const showGuides = useEditorStore((s) => s.showGuides);
const canvasSize = useEditorStore((s) => s.canvasSize);
const updateGuide = useEditorStore((s) => s.updateGuide);
const removeGuide = useEditorStore((s) => s.removeGuide);
if (!showGuides || guides.length === 0) return null;
return (
<Group>
{guides.map((guide) => (
<DraggableGuide
key={guide.id}
id={guide.id}
orientation={guide.orientation}
position={guide.position}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
onPositionChange={(pos) => updateGuide(guide.id, pos)}
onRemove={() => removeGuide(guide.id)}
/>
))}
</Group>
);
}
// ---------------------------------------------------------------------------
// DraggableGuide -- individual guide line
// ---------------------------------------------------------------------------
function DraggableGuide({
id,
orientation,
position,
canvasWidth,
canvasHeight,
onPositionChange,
onRemove,
}: {
id: string;
orientation: "horizontal" | "vertical";
position: number;
canvasWidth: number;
canvasHeight: number;
onPositionChange: (pos: number) => void;
onRemove: () => void;
}) {
const lineRef = useRef<Konva.Line>(null);
const isHorizontal = orientation === "horizontal";
const points = isHorizontal
? [0, position, canvasWidth, position]
: [position, 0, position, canvasHeight];
const handleDragEnd = useCallback(
(e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
if (isHorizontal) {
const newY = node.y() + position;
node.y(0); // reset drag offset
onPositionChange(newY);
} else {
const newX = node.x() + position;
node.x(0);
onPositionChange(newX);
}
},
[isHorizontal, position, onPositionChange],
);
const handleDblClick = useCallback(() => {
onRemove();
}, [onRemove]);
return (
<Line
ref={lineRef}
id={`guide-${id}`}
points={points}
stroke={GUIDE_COLOR}
strokeWidth={GUIDE_WIDTH}
dash={[8, 4]}
draggable
dragBoundFunc={(pos) => {
// Constrain drag to the guide's axis
if (isHorizontal) {
return { x: 0, y: pos.y };
}
return { x: pos.x, y: 0 };
}}
onDragEnd={handleDragEnd}
onDblClick={handleDblClick}
hitStrokeWidth={8}
/>
);
}
@@ -0,0 +1,198 @@
import { Lock, Unlock, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { ResampleMethod } from "@/types/editor";
// ---------------------------------------------------------------------------
// ImageResizeDialog -- modal with W/H, aspect lock, resampling method
// ---------------------------------------------------------------------------
const RESAMPLE_METHODS: { value: ResampleMethod; label: string }[] = [
{ value: "nearest", label: "Nearest Neighbor (fast)" },
{ value: "bilinear", label: "Bilinear" },
{ value: "bicubic", label: "Bicubic (smooth)" },
{ value: "lanczos", label: "Lanczos (sharp)" },
];
export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
const resizeImage = useEditorStore((s) => s.resizeImage);
const [width, setWidth] = useState(canvasSize.width);
const [height, setHeight] = useState(canvasSize.height);
const [lockAspect, setLockAspect] = useState(true);
const [resample, setResample] = useState<ResampleMethod>("bicubic");
const aspectRatio = canvasSize.width / canvasSize.height;
// Sync when dialog opens
useEffect(() => {
if (open) {
setWidth(canvasSize.width);
setHeight(canvasSize.height);
}
}, [open, canvasSize]);
const handleWidthChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const w = Math.max(1, Number(e.target.value) || 1);
setWidth(w);
if (lockAspect) {
setHeight(Math.round(w / aspectRatio));
}
},
[lockAspect, aspectRatio],
);
const handleHeightChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const h = Math.max(1, Number(e.target.value) || 1);
setHeight(h);
if (lockAspect) {
setWidth(Math.round(h * aspectRatio));
}
},
[lockAspect, aspectRatio],
);
const handleApply = useCallback(() => {
resizeImage(width, height);
onClose();
}, [width, height, resizeImage, onClose]);
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
const pctHeight =
canvasSize.height > 0 ? ((height / canvasSize.height) * 100).toFixed(1) : "100.0";
if (!open) return null;
const inputCn = cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-[380px] rounded-lg border border-border bg-card shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">Image Size</h2>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Body */}
<div className="space-y-4 px-4 py-4">
{/* Current size info */}
<p className="text-xs text-muted-foreground">
Original: {canvasSize.width} x {canvasSize.height} px
</p>
{/* Width / Lock / Height */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="img-w" className="mb-1 block text-xs text-muted-foreground">
Width (px)
</label>
<input
id="img-w"
type="number"
min={1}
max={16384}
value={width}
onChange={handleWidthChange}
className={inputCn}
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctWidth}%</p>
</div>
<button
type="button"
onClick={() => setLockAspect(!lockAspect)}
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-label={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
aria-pressed={lockAspect}
className={cn(
"mb-4 flex h-8 w-8 items-center justify-center rounded transition-colors",
lockAspect
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{lockAspect ? <Lock className="h-4 w-4" /> : <Unlock className="h-4 w-4" />}
</button>
<div className="flex-1">
<label htmlFor="img-h" className="mb-1 block text-xs text-muted-foreground">
Height (px)
</label>
<input
id="img-h"
type="number"
min={1}
max={16384}
value={height}
onChange={handleHeightChange}
className={inputCn}
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">{pctHeight}%</p>
</div>
</div>
{/* Resampling method */}
<div>
<label htmlFor="resample" className="mb-1 block text-xs text-muted-foreground">
Resampling:
</label>
<select
id="resample"
value={resample}
onChange={(e) => setResample(e.target.value as ResampleMethod)}
className={cn(
"h-8 w-full rounded border border-border bg-card px-2 text-sm text-foreground",
"focus:border-primary focus:outline-none",
)}
>
{RESAMPLE_METHODS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
type="button"
onClick={onClose}
className={cn(
"h-8 rounded border border-border px-3 text-sm",
"text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className={cn(
"h-8 rounded bg-primary px-3 text-sm text-primary-foreground",
"hover:bg-primary/90 transition-colors",
)}
>
Apply
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,253 @@
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
// ---------------------------------------------------------------------------
// Ruler configuration
// ---------------------------------------------------------------------------
const RULER_SIZE = 20; // px
const TICK_COLOR = "var(--color-muted-foreground)";
const BG_COLOR = "var(--color-card)";
const TEXT_COLOR = "var(--color-muted-foreground)";
// ---------------------------------------------------------------------------
// Helper: pick tick spacing based on zoom level
// ---------------------------------------------------------------------------
function getTickInterval(zoom: number): { major: number; minor: number } {
// Adjust tick spacing so labels don't overlap at any zoom
if (zoom >= 4) return { major: 25, minor: 5 };
if (zoom >= 2) return { major: 50, minor: 10 };
if (zoom >= 1) return { major: 100, minor: 10 };
if (zoom >= 0.5) return { major: 200, minor: 50 };
if (zoom >= 0.25) return { major: 500, minor: 100 };
return { major: 1000, minor: 200 };
}
// ---------------------------------------------------------------------------
// HorizontalRuler
// ---------------------------------------------------------------------------
export function HorizontalRuler() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
const showRulers = useEditorStore((s) => s.showRulers);
const addGuide = useEditorStore((s) => s.addGuide);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = RULER_SIZE;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
// Background
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
const startPx = -panOffset.x / zoom;
const endPx = (w - panOffset.x) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.font = "9px sans-serif";
ctx.textBaseline = "top";
for (let t = startTick; t <= endPx + minor; t += minor) {
const screenX = t * zoom + panOffset.x;
if (screenX < 0 || screenX > w) continue;
const isMajor = t % major === 0;
const tickHeight = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(screenX, h);
ctx.lineTo(screenX, h - tickHeight);
ctx.lineWidth = isMajor ? 0.8 : 0.4;
ctx.stroke();
if (isMajor) {
ctx.fillText(String(Math.round(t)), screenX + 2, 2);
}
}
// Bottom border
ctx.strokeStyle = TICK_COLOR;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h - 0.5);
ctx.lineTo(w, h - 0.5);
ctx.stroke();
// canvasSize referenced to trigger redraw when canvas dimensions change
void canvasSize;
}, [zoom, panOffset, canvasSize]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [draw]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Drag from ruler to create a horizontal guide
const startY = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientY - startY) > 10) {
const canvasY = (me.clientY - startY) / zoom;
addGuide("horizontal", canvasY);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[zoom, addGuide],
);
if (!showRulers) return null;
return (
<canvas
ref={canvasRef}
className={cn("block w-full cursor-col-resize select-none")}
style={{ height: RULER_SIZE }}
onMouseDown={handleMouseDown}
/>
);
}
// ---------------------------------------------------------------------------
// VerticalRuler
// ---------------------------------------------------------------------------
export function VerticalRuler() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const canvasSize = useEditorStore((s) => s.canvasSize);
const showRulers = useEditorStore((s) => s.showRulers);
const addGuide = useEditorStore((s) => s.addGuide);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = RULER_SIZE;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = BG_COLOR;
ctx.fillRect(0, 0, w, h);
const { major, minor } = getTickInterval(zoom);
const startPx = -panOffset.y / zoom;
const endPx = (h - panOffset.y) / zoom;
const startTick = Math.floor(startPx / minor) * minor;
ctx.strokeStyle = TICK_COLOR;
ctx.fillStyle = TEXT_COLOR;
ctx.font = "9px sans-serif";
for (let t = startTick; t <= endPx + minor; t += minor) {
const screenY = t * zoom + panOffset.y;
if (screenY < 0 || screenY > h) continue;
const isMajor = t % major === 0;
const tickWidth = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(w, screenY);
ctx.lineTo(w - tickWidth, screenY);
ctx.lineWidth = isMajor ? 0.8 : 0.4;
ctx.stroke();
if (isMajor) {
ctx.save();
ctx.translate(3, screenY + 2);
ctx.rotate(-Math.PI / 2);
ctx.textBaseline = "top";
ctx.fillText(String(Math.round(t)), 0, 0);
ctx.restore();
}
}
// Right border
ctx.strokeStyle = TICK_COLOR;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(w - 0.5, 0);
ctx.lineTo(w - 0.5, h);
ctx.stroke();
void canvasSize;
}, [zoom, panOffset, canvasSize]);
useEffect(() => {
draw();
}, [draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [draw]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const startX = e.clientX;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - startX) > 10) {
const canvasX = (me.clientX - startX) / zoom;
addGuide("vertical", canvasX);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[zoom, addGuide],
);
if (!showRulers) return null;
return (
<canvas
ref={canvasRef}
className={cn("block h-full cursor-row-resize select-none")}
style={{ width: RULER_SIZE }}
onMouseDown={handleMouseDown}
/>
);
}
export { RULER_SIZE };
@@ -0,0 +1,208 @@
import { Group, Line } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
import type { SmartGuide } from "@/types/editor";
// ---------------------------------------------------------------------------
// Smart guide calculation utilities (exported for testing)
// ---------------------------------------------------------------------------
const SNAP_THRESHOLD = 5;
interface ObjectBounds {
id: string;
x: number;
y: number;
width: number;
height: number;
}
export function findAlignmentGuides(
dragging: ObjectBounds,
others: ObjectBounds[],
canvasWidth: number,
canvasHeight: number,
threshold = SNAP_THRESHOLD,
): SmartGuide[] {
const guides: SmartGuide[] = [];
const dragEdges = {
left: dragging.x,
right: dragging.x + dragging.width,
centerX: dragging.x + dragging.width / 2,
top: dragging.y,
bottom: dragging.y + dragging.height,
centerY: dragging.y + dragging.height / 2,
};
// Canvas alignment
const canvasTargets = [
{ pos: 0, orient: "vertical" as const, type: "canvas" as const },
{
pos: canvasWidth / 2,
orient: "vertical" as const,
type: "canvas" as const,
},
{ pos: canvasWidth, orient: "vertical" as const, type: "canvas" as const },
{ pos: 0, orient: "horizontal" as const, type: "canvas" as const },
{
pos: canvasHeight / 2,
orient: "horizontal" as const,
type: "canvas" as const,
},
{
pos: canvasHeight,
orient: "horizontal" as const,
type: "canvas" as const,
},
];
for (const ct of canvasTargets) {
const edges =
ct.orient === "vertical"
? [dragEdges.left, dragEdges.centerX, dragEdges.right]
: [dragEdges.top, dragEdges.centerY, dragEdges.bottom];
for (const edge of edges) {
if (Math.abs(edge - ct.pos) < threshold) {
guides.push({
orientation: ct.orient,
position: ct.pos,
type: ct.type,
});
}
}
}
// Object-to-object alignment
for (const other of others) {
if (other.id === dragging.id) continue;
const otherEdges = {
left: other.x,
right: other.x + other.width,
centerX: other.x + other.width / 2,
top: other.y,
bottom: other.y + other.height,
centerY: other.y + other.height / 2,
};
// Vertical guides (x-axis alignment)
const vPairs: [number, number, "edge" | "center"][] = [
[dragEdges.left, otherEdges.left, "edge"],
[dragEdges.left, otherEdges.right, "edge"],
[dragEdges.right, otherEdges.left, "edge"],
[dragEdges.right, otherEdges.right, "edge"],
[dragEdges.centerX, otherEdges.centerX, "center"],
];
for (const [dragVal, otherVal, type] of vPairs) {
if (Math.abs(dragVal - otherVal) < threshold) {
guides.push({ orientation: "vertical", position: otherVal, type });
}
}
// Horizontal guides (y-axis alignment)
const hPairs: [number, number, "edge" | "center"][] = [
[dragEdges.top, otherEdges.top, "edge"],
[dragEdges.top, otherEdges.bottom, "edge"],
[dragEdges.bottom, otherEdges.top, "edge"],
[dragEdges.bottom, otherEdges.bottom, "edge"],
[dragEdges.centerY, otherEdges.centerY, "center"],
];
for (const [dragVal, otherVal, type] of hPairs) {
if (Math.abs(dragVal - otherVal) < threshold) {
guides.push({ orientation: "horizontal", position: otherVal, type });
}
}
}
return guides;
}
export function snapToGuides(
pos: { x: number; y: number },
size: { width: number; height: number },
guides: SmartGuide[],
threshold = SNAP_THRESHOLD,
): { x: number; y: number } {
let { x, y } = pos;
for (const g of guides) {
if (g.orientation === "vertical") {
// Snap left edge, center, or right edge
if (Math.abs(x - g.position) < threshold) {
x = g.position;
} else if (Math.abs(x + size.width / 2 - g.position) < threshold) {
x = g.position - size.width / 2;
} else if (Math.abs(x + size.width - g.position) < threshold) {
x = g.position - size.width;
}
} else {
if (Math.abs(y - g.position) < threshold) {
y = g.position;
} else if (Math.abs(y + size.height / 2 - g.position) < threshold) {
y = g.position - size.height / 2;
} else if (Math.abs(y + size.height - g.position) < threshold) {
y = g.position - size.height;
}
}
}
return { x, y };
}
// ---------------------------------------------------------------------------
// SmartGuidesOverlay -- renders temporary guide lines during drag
// ---------------------------------------------------------------------------
const GUIDE_COLORS = {
edge: "#f43f5e",
center: "#8b5cf6",
canvas: "#22c55e",
};
export function SmartGuidesOverlay({ guides }: { guides: SmartGuide[] }) {
const canvasSize = useEditorStore((s) => s.canvasSize);
if (guides.length === 0) return null;
// Deduplicate guides by position + orientation
const seen = new Set<string>();
const unique = guides.filter((g) => {
const key = `${g.orientation}-${g.position}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return (
<Group listening={false}>
{unique.map((g) => {
const color = GUIDE_COLORS[g.type];
if (g.orientation === "vertical") {
return (
<Line
key={`sg-${g.orientation}-${g.position}`}
points={[g.position, 0, g.position, canvasSize.height]}
stroke={color}
strokeWidth={0.5}
dash={[4, 4]}
listening={false}
/>
);
}
return (
<Line
key={`sg-${g.orientation}-${g.position}`}
points={[0, g.position, canvasSize.width, g.position]}
stroke={color}
strokeWidth={0.5}
dash={[4, 4]}
listening={false}
/>
);
})}
</Group>
);
}