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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user