mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add history panel, keyboard shortcuts, export dialog, and navigator minimap
Implement Agent 7 features for the image editor: - History panel with undo/redo list, action icons, and state jumping - 51+ keyboard shortcuts via react-hotkeys-hook (tools, modifiers, clipboard) - Export dialog with PNG/JPEG/WebP format, quality, resize, clipboard copy - Navigator minimap with viewport rectangle, drag-to-pan, zoom slider - Autosave/recovery system with localStorage and recovery banner - Project save/load (.snapotter JSON format) - Unsaved changes warning via beforeunload - Paste from system clipboard support
This commit is contained in:
@@ -12,25 +12,31 @@
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@sentry/react": "^10.49.0",
|
||||
"@snapotter/shared": "workspace:*",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"clsx": "^2.1.0",
|
||||
"fflate": "^0.8.2",
|
||||
"jszip": "^3.10.1",
|
||||
"konva": "^10",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"posthog-js": "^1.370.0",
|
||||
"qr-code-styling": "^1.9.2",
|
||||
"react": "^19.0.0",
|
||||
"react-colorful": "^5",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hotkeys-hook": "^5",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-konva": "^19",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"use-image": "^1",
|
||||
"zundo": "^2",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// apps/web/src/components/editor/common/custom-cursor.tsx
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ToolType } from "@/types/editor";
|
||||
|
||||
const TOOL_CURSORS: Record<ToolType, string> = {
|
||||
move: "default",
|
||||
"marquee-rect": "crosshair",
|
||||
"marquee-ellipse": "crosshair",
|
||||
"lasso-free": "crosshair",
|
||||
"lasso-poly": "crosshair",
|
||||
"magic-wand": "crosshair",
|
||||
crop: "crosshair",
|
||||
eyedropper: "crosshair",
|
||||
brush: "none",
|
||||
eraser: "none",
|
||||
pencil: "none",
|
||||
"clone-stamp": "none",
|
||||
dodge: "none",
|
||||
burn: "none",
|
||||
sponge: "none",
|
||||
"blur-brush": "none",
|
||||
"sharpen-brush": "none",
|
||||
smudge: "none",
|
||||
fill: "crosshair",
|
||||
gradient: "crosshair",
|
||||
"shape-rect": "crosshair",
|
||||
"shape-ellipse": "crosshair",
|
||||
"shape-line": "crosshair",
|
||||
"shape-arrow": "crosshair",
|
||||
"shape-polygon": "crosshair",
|
||||
"shape-star": "crosshair",
|
||||
text: "text",
|
||||
hand: "grab",
|
||||
zoom: "zoom-in",
|
||||
transform: "default",
|
||||
};
|
||||
|
||||
const BRUSH_CURSOR_TOOLS = new Set<ToolType>([
|
||||
"brush",
|
||||
"eraser",
|
||||
"pencil",
|
||||
"clone-stamp",
|
||||
"dodge",
|
||||
"burn",
|
||||
"sponge",
|
||||
"blur-brush",
|
||||
"sharpen-brush",
|
||||
"smudge",
|
||||
]);
|
||||
|
||||
export function useEditorCursor(): string {
|
||||
const activeTool = useEditorStore((s) => s.activeTool);
|
||||
const isSpaceHeld = useEditorStore((s) => s.isSpaceHeld);
|
||||
if (isSpaceHeld) return "grab";
|
||||
return TOOL_CURSORS[activeTool] || "default";
|
||||
}
|
||||
|
||||
interface BrushCursorOverlayProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorOverlayProps) {
|
||||
const activeTool = useEditorStore((s) => s.activeTool);
|
||||
const brushSize = useEditorStore((s) => s.brushSize);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const cursorPosition = useEditorStore((s) => s.cursorPosition);
|
||||
|
||||
if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null;
|
||||
|
||||
const displaySize = brushSize * zoom;
|
||||
const isEraser = activeTool === "eraser";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-50"
|
||||
style={{
|
||||
left: cursorPosition.x - displaySize / 2,
|
||||
top: cursorPosition.y - displaySize / 2,
|
||||
width: displaySize,
|
||||
height: displaySize,
|
||||
borderRadius: "50%",
|
||||
border: isEraser ? "2px dashed currentColor" : "1.5px solid currentColor",
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
// apps/web/src/components/editor/common/export-dialog.tsx
|
||||
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Download,
|
||||
FileDown,
|
||||
FileUp,
|
||||
Lock,
|
||||
Save,
|
||||
Unlock,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type {
|
||||
AdjustmentValues,
|
||||
CanvasObject,
|
||||
EditorLayer,
|
||||
FilterConfig,
|
||||
Guide,
|
||||
} from "@/types/editor";
|
||||
|
||||
type ExportFormat = "png" | "jpeg" | "webp";
|
||||
|
||||
interface ExportSettings {
|
||||
format: ExportFormat;
|
||||
quality: number;
|
||||
width: number;
|
||||
height: number;
|
||||
lockAspect: boolean;
|
||||
transparent: boolean;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: { value: ExportFormat; label: string; supportsTransparency: boolean }[] = [
|
||||
{ value: "png", label: "PNG", supportsTransparency: true },
|
||||
{ value: "jpeg", label: "JPEG", supportsTransparency: false },
|
||||
{ value: "webp", label: "WebP", supportsTransparency: true },
|
||||
];
|
||||
|
||||
function getMimeType(format: ExportFormat): string {
|
||||
switch (format) {
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "jpeg":
|
||||
return "image/jpeg";
|
||||
case "webp":
|
||||
return "image/webp";
|
||||
}
|
||||
}
|
||||
|
||||
export function ExportDialog({ onClose }: { onClose: () => void }) {
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const markClean = useEditorStore((s) => s.markClean);
|
||||
|
||||
const [settings, setSettings] = useState<ExportSettings>({
|
||||
format: "png",
|
||||
quality: 92,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
lockAspect: true,
|
||||
transparent: true,
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [copyStatus, setCopyStatus] = useState<"idle" | "copied">("idle");
|
||||
|
||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const generatePreview = useCallback(() => {
|
||||
// Create a thumbnail canvas for preview
|
||||
const canvas = document.createElement("canvas");
|
||||
const maxPreview = 200;
|
||||
const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height);
|
||||
canvas.width = Math.round(canvasSize.width * scale);
|
||||
canvas.height = Math.round(canvasSize.height * scale);
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// Try to draw from the Konva stage if available
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (stageCanvas) {
|
||||
ctx.drawImage(stageCanvas, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const url = canvas.toDataURL(getMimeType(settings.format), settings.quality / 100);
|
||||
setPreviewUrl(url);
|
||||
}, [canvasSize, settings.format, settings.quality, settings.transparent]);
|
||||
|
||||
// Generate preview thumbnail on format/transparency change
|
||||
useEffect(() => {
|
||||
generatePreview();
|
||||
}, [generatePreview]);
|
||||
|
||||
// Handle width change with aspect lock
|
||||
const handleWidthChange = useCallback(
|
||||
(w: number) => {
|
||||
const newWidth = Math.max(1, w);
|
||||
if (settings.lockAspect) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
width: newWidth,
|
||||
height: Math.round(newWidth / aspectRatio),
|
||||
}));
|
||||
} else {
|
||||
setSettings((prev) => ({ ...prev, width: newWidth }));
|
||||
}
|
||||
},
|
||||
[settings.lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
// Handle height change with aspect lock
|
||||
const handleHeightChange = useCallback(
|
||||
(h: number) => {
|
||||
const newHeight = Math.max(1, h);
|
||||
if (settings.lockAspect) {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
height: newHeight,
|
||||
width: Math.round(newHeight * aspectRatio),
|
||||
}));
|
||||
} else {
|
||||
setSettings((prev) => ({ ...prev, height: newHeight }));
|
||||
}
|
||||
},
|
||||
[settings.lockAspect, aspectRatio],
|
||||
);
|
||||
|
||||
// Export as file download
|
||||
const handleExport = useCallback(() => {
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
|
||||
// Create export canvas at the requested dimensions
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = settings.width;
|
||||
exportCanvas.height = settings.height;
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
||||
|
||||
exportCanvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `export.${settings.format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
markClean();
|
||||
},
|
||||
getMimeType(settings.format),
|
||||
settings.format === "png" ? undefined : settings.quality / 100,
|
||||
);
|
||||
}, [settings, markClean]);
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
|
||||
const exportCanvas = document.createElement("canvas");
|
||||
exportCanvas.width = settings.width;
|
||||
exportCanvas.height = settings.height;
|
||||
const ctx = exportCanvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (!settings.transparent || settings.format === "jpeg") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||||
}
|
||||
|
||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
||||
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
exportCanvas.toBlob(resolve, "image/png"),
|
||||
);
|
||||
if (!blob) return;
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
setCopyStatus("copied");
|
||||
setTimeout(() => setCopyStatus("idle"), 2000);
|
||||
} catch {
|
||||
// Clipboard API may not be available in all contexts
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Project save (.snapotter file)
|
||||
const handleSaveProject = useCallback(() => {
|
||||
const state = useEditorStore.getState();
|
||||
const projectData = {
|
||||
version: 1,
|
||||
canvasSize: state.canvasSize,
|
||||
layers: state.layers,
|
||||
objects: state.objects,
|
||||
adjustments: state.adjustments,
|
||||
filters: state.filters,
|
||||
guides: state.guides,
|
||||
sourceImageUrl: state.sourceImageUrl,
|
||||
sourceImageSize: state.sourceImageSize,
|
||||
foregroundColor: state.foregroundColor,
|
||||
backgroundColor: state.backgroundColor,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(projectData, null, 2);
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "project.snapotter";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
markClean();
|
||||
}, [markClean]);
|
||||
|
||||
// Project load (.snapotter file)
|
||||
const handleLoadProject = useCallback(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".snapotter,.json";
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(reader.result as string);
|
||||
if (!data.version || !data.canvasSize) return;
|
||||
|
||||
const store = useEditorStore.getState();
|
||||
const setState = useEditorStore.setState;
|
||||
|
||||
setState({
|
||||
canvasSize: data.canvasSize,
|
||||
layers: data.layers || store.layers,
|
||||
objects: data.objects || [],
|
||||
adjustments: data.adjustments || store.adjustments,
|
||||
filters: data.filters || store.filters,
|
||||
guides: data.guides || [],
|
||||
sourceImageUrl: data.sourceImageUrl || null,
|
||||
sourceImageSize: data.sourceImageSize || null,
|
||||
foregroundColor: data.foregroundColor || "#000000",
|
||||
backgroundColor: data.backgroundColor || "#ffffff",
|
||||
isDirty: false,
|
||||
lastAction: "Load Project",
|
||||
_historyVersion: store._historyVersion + 1,
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch {
|
||||
// Invalid project file
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
}, [onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const supportsQuality = settings.format === "jpeg" || settings.format === "webp";
|
||||
const supportsTransparency = settings.format !== "jpeg";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: modal backdrop click-to-dismiss uses Escape as keyboard equivalent
|
||||
<div
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="bg-card border border-border rounded-lg shadow-xl w-full max-w-md mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">Export Image</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Preview */}
|
||||
{previewUrl && (
|
||||
<div className="flex justify-center p-2 bg-muted/30 rounded border border-border">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Export preview"
|
||||
className="max-h-[120px] object-contain rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format */}
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground mb-1.5">Format</span>
|
||||
<div className="flex gap-1">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
format: opt.value,
|
||||
transparent: opt.supportsTransparency ? prev.transparent : false,
|
||||
}))
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs font-medium rounded transition-colors",
|
||||
settings.format === opt.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality */}
|
||||
{supportsQuality && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Quality</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{settings.quality}%
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={settings.quality}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, quality: Number.parseInt(e.target.value, 10) }))
|
||||
}
|
||||
className={cn(
|
||||
"w-full h-1.5 appearance-none rounded-full bg-muted",
|
||||
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3",
|
||||
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dimensions */}
|
||||
<div>
|
||||
<span className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Dimensions
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.width}
|
||||
onChange={(e) => handleWidthChange(Number.parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Width</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettings((prev) => ({ ...prev, lockAspect: !prev.lockAspect }))}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors mt-[-12px]",
|
||||
settings.lockAspect
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-label={settings.lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{settings.lockAspect ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.height}
|
||||
onChange={(e) => handleHeightChange(Number.parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full px-2 py-1 text-xs bg-muted rounded border border-border text-foreground outline-none focus:border-primary"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Height</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
width: canvasSize.width,
|
||||
height: canvasSize.height,
|
||||
}))
|
||||
}
|
||||
className="mt-1 text-[10px] text-primary hover:underline"
|
||||
>
|
||||
Reset to original size ({canvasSize.width} x {canvasSize.height})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Transparent background */}
|
||||
{supportsTransparency && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.transparent}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, transparent: e.target.checked }))
|
||||
}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="text-xs text-foreground">Transparent background</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||
{/* Primary export actions */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Download size={14} />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyToClipboard}
|
||||
className="flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium bg-muted text-foreground rounded hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
{copyStatus === "copied" ? <Check size={14} /> : <ClipboardCopy size={14} />}
|
||||
{copyStatus === "copied" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Project save/load */}
|
||||
<div className="flex gap-2 pt-1 border-t border-border mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveProject}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileDown size={12} />
|
||||
Save Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadProject}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileUp size={12} />
|
||||
Load Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Autosave utilities (Feature 44) ----
|
||||
|
||||
const AUTOSAVE_KEY = "snapotter-editor-autosave";
|
||||
const AUTOSAVE_INTERVAL_MS = 60_000;
|
||||
|
||||
interface AutosaveState {
|
||||
canvasSize: { width: number; height: number };
|
||||
layers: EditorLayer[];
|
||||
objects: CanvasObject[];
|
||||
adjustments: AdjustmentValues;
|
||||
filters: FilterConfig[];
|
||||
guides: Guide[];
|
||||
sourceImageUrl: string | null;
|
||||
sourceImageSize: { width: number; height: number } | null;
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
|
||||
interface AutosaveData {
|
||||
version: 1;
|
||||
timestamp: number;
|
||||
state: AutosaveState;
|
||||
}
|
||||
|
||||
export function saveEditorState(): void {
|
||||
try {
|
||||
const s = useEditorStore.getState();
|
||||
const data: AutosaveData = {
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
state: {
|
||||
canvasSize: s.canvasSize,
|
||||
layers: s.layers,
|
||||
objects: s.objects,
|
||||
adjustments: s.adjustments,
|
||||
filters: s.filters,
|
||||
guides: s.guides,
|
||||
sourceImageUrl: s.sourceImageUrl,
|
||||
sourceImageSize: s.sourceImageSize,
|
||||
foregroundColor: s.foregroundColor,
|
||||
backgroundColor: s.backgroundColor,
|
||||
},
|
||||
};
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
useEditorStore.setState({ lastAutoSave: Date.now() });
|
||||
} catch {
|
||||
// localStorage might be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAutosaveState(): AutosaveData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTOSAVE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as AutosaveData;
|
||||
if (data.version !== 1 || !data.state?.canvasSize) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAutosave(): void {
|
||||
try {
|
||||
localStorage.removeItem(AUTOSAVE_KEY);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreAutosave(data: AutosaveData): void {
|
||||
const store = useEditorStore.getState();
|
||||
useEditorStore.setState({
|
||||
...data.state,
|
||||
isDirty: true,
|
||||
lastAction: "Restore Autosave",
|
||||
_historyVersion: store._historyVersion + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to run autosave on an interval. Call this in EditorPage.
|
||||
* Returns recovery state if found on mount.
|
||||
*/
|
||||
export function useAutosave(): {
|
||||
recoveryData: AutosaveData | null;
|
||||
dismissRecovery: () => void;
|
||||
restoreRecovery: () => void;
|
||||
} {
|
||||
const [recoveryData, setRecoveryData] = useState<AutosaveData | null>(null);
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
|
||||
// Check for recovery on mount
|
||||
useEffect(() => {
|
||||
const data = loadAutosaveState();
|
||||
if (data) {
|
||||
setRecoveryData(data);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Autosave interval
|
||||
useEffect(() => {
|
||||
if (!sourceImageUrl) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (isDirty) {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => saveEditorState());
|
||||
} else {
|
||||
saveEditorState();
|
||||
}
|
||||
}
|
||||
}, AUTOSAVE_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isDirty, sourceImageUrl]);
|
||||
|
||||
const dismissRecovery = useCallback(() => {
|
||||
clearAutosave();
|
||||
setRecoveryData(null);
|
||||
}, []);
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
if (recoveryData) {
|
||||
restoreAutosave(recoveryData);
|
||||
clearAutosave();
|
||||
setRecoveryData(null);
|
||||
}
|
||||
}, [recoveryData]);
|
||||
|
||||
return { recoveryData, dismissRecovery, restoreRecovery: handleRestore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery banner component for display at the top of the editor.
|
||||
*/
|
||||
export function AutosaveRecoveryBanner({
|
||||
data,
|
||||
onRestore,
|
||||
onDiscard,
|
||||
}: {
|
||||
data: AutosaveData;
|
||||
onRestore: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
const timeStr = new Date(data.timestamp).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
|
||||
<Save size={14} className="text-yellow-600 shrink-0" />
|
||||
<span className="text-foreground">Recovered unsaved work from {timeStr}.</span>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestore}
|
||||
className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscard}
|
||||
className="px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// apps/web/src/components/editor/common/icon-button.tsx
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
"data-tool"?: string;
|
||||
"data-tool-active"?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
active,
|
||||
disabled,
|
||||
size = 18,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
className,
|
||||
...dataProps
|
||||
}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
className={cn(
|
||||
"relative flex items-center justify-center w-8 h-8 rounded-md transition-colors",
|
||||
"hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
active && "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
!active && "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...dataProps}
|
||||
>
|
||||
<Icon size={size} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// apps/web/src/components/editor/common/new-document-dialog.tsx
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Custom", width: 1920, height: 1080 },
|
||||
{ label: "1920x1080 (HD)", width: 1920, height: 1080 },
|
||||
{ label: "3840x2160 (4K)", width: 3840, height: 2160 },
|
||||
{ label: "1080x1080 (Instagram)", width: 1080, height: 1080 },
|
||||
{ label: "1200x628 (Facebook)", width: 1200, height: 628 },
|
||||
{ label: "800x600", width: 800, height: 600 },
|
||||
{ label: "1280x720", width: 1280, height: 720 },
|
||||
];
|
||||
|
||||
const BACKGROUNDS = ["White", "Black", "Transparent"] as const;
|
||||
|
||||
interface NewDocumentDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) {
|
||||
const [width, setWidth] = useState(1920);
|
||||
const [height, setHeight] = useState(1080);
|
||||
const [preset, setPreset] = useState("1920x1080 (HD)");
|
||||
const [background, setBackground] = useState<(typeof BACKGROUNDS)[number]>("White");
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handlePresetChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selected = PRESETS.find((p) => p.label === e.target.value);
|
||||
if (selected) {
|
||||
setPreset(selected.label);
|
||||
if (selected.label !== "Custom") {
|
||||
setWidth(selected.width);
|
||||
setHeight(selected.height);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
if (background === "White") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
} else if (background === "Black") {
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
}
|
||||
const url = canvas.toDataURL("image/png");
|
||||
loadImage(url, width, height);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-card border border-border rounded-lg shadow-lg p-6 w-96">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">New Document</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="new-doc-preset" className="text-xs text-muted-foreground">
|
||||
Preset
|
||||
</label>
|
||||
<select
|
||||
id="new-doc-preset"
|
||||
value={preset}
|
||||
onChange={handlePresetChange}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.label} value={p.label}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="new-doc-width" className="text-xs text-muted-foreground">
|
||||
Width (px)
|
||||
</label>
|
||||
<input
|
||||
id="new-doc-width"
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => {
|
||||
setWidth(Number(e.target.value));
|
||||
setPreset("Custom");
|
||||
}}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="new-doc-height" className="text-xs text-muted-foreground">
|
||||
Height (px)
|
||||
</label>
|
||||
<input
|
||||
id="new-doc-height"
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => {
|
||||
setHeight(Number(e.target.value));
|
||||
setPreset("Custom");
|
||||
}}
|
||||
className="w-full mt-1 px-2 py-1.5 bg-muted border border-border rounded text-sm text-foreground"
|
||||
min={1}
|
||||
max={10000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Background</span>
|
||||
<div className="flex gap-2 mt-1">
|
||||
{BACKGROUNDS.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
type="button"
|
||||
onClick={() => setBackground(bg)}
|
||||
className={cn(
|
||||
"flex-1 py-1.5 text-xs rounded border transition-colors",
|
||||
background === bg
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted text-muted-foreground border-border hover:bg-muted/80",
|
||||
)}
|
||||
>
|
||||
{bg}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// apps/web/src/components/editor/common/welcome-screen.tsx
|
||||
|
||||
import { FilePlus, ImagePlus } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { NewDocumentDialog } from "./new-document-dialog";
|
||||
|
||||
const ACCEPTED_TYPES = ".png,.jpg,.jpeg,.webp,.gif,.bmp,.tiff,.svg";
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const [showNewDoc, setShowNewDoc] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
loadImage(url, img.naturalWidth, img.naturalHeight);
|
||||
};
|
||||
img.src = url;
|
||||
},
|
||||
[loadImage],
|
||||
);
|
||||
|
||||
const handleOpenFile = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ACCEPTED_TYPES;
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => setIsDragOver(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
aria-label="Image drop zone"
|
||||
className="absolute inset-0 flex items-center justify-center z-10"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center gap-6 p-12 bg-card border-2 border-dashed rounded-xl max-w-md transition-colors ${
|
||||
isDragOver ? "border-primary bg-primary/5" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-1">Image Editor</h2>
|
||||
<p className="text-sm text-muted-foreground">Drop an image here to get started</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenFile}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ImagePlus size={20} />
|
||||
<span className="text-sm font-medium">Open Image</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewDoc(true)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<FilePlus size={20} />
|
||||
<span className="text-sm font-medium">New Document</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">Or paste from clipboard (Ctrl+V)</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NewDocumentDialog open={showNewDoc} onClose={() => setShowNewDoc(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// apps/web/src/components/editor/editor-canvas.tsx
|
||||
|
||||
import type Konva from "konva";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { Layer, Shape, Stage } from "react-konva";
|
||||
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
||||
|
||||
const CHECKERBOARD_SIZE = 20;
|
||||
const CHECKERBOARD_CSS = `
|
||||
repeating-conic-gradient(
|
||||
rgba(128, 128, 128, 0.15) 0% 25%,
|
||||
transparent 0% 50%
|
||||
)
|
||||
`;
|
||||
|
||||
export function EditorCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { stageRef, handleWheel, fitToScreen } = useCanvasZoom();
|
||||
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const setCursorPosition = useEditorStore((s) => s.setCursorPosition);
|
||||
const gridVisible = useEditorStore((s) => s.gridVisible);
|
||||
|
||||
const cursor = useEditorCursor();
|
||||
|
||||
const [stageWidth, setStageWidth] = React.useState(800);
|
||||
const [stageHeight, setStageHeight] = React.useState(600);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const { width, height } = entries[0].contentRect;
|
||||
setStageWidth(width);
|
||||
setStageHeight(height);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceImageUrl && stageWidth > 0 && stageHeight > 0) {
|
||||
fitToScreen(stageWidth, stageHeight, canvasSize.width, canvasSize.height);
|
||||
}
|
||||
}, [sourceImageUrl, stageWidth, stageHeight, canvasSize.width, canvasSize.height, fitToScreen]);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
const x = Math.round((pointer.x - panOffset.x) / zoom);
|
||||
const y = Math.round((pointer.y - panOffset.y) / zoom);
|
||||
setCursorPosition({ x, y });
|
||||
},
|
||||
[zoom, panOffset, setCursorPosition],
|
||||
);
|
||||
|
||||
const checkerboardSize = CHECKERBOARD_SIZE * zoom;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 overflow-hidden"
|
||||
style={{
|
||||
cursor,
|
||||
background: sourceImageUrl ? CHECKERBOARD_CSS : undefined,
|
||||
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
|
||||
}}
|
||||
data-testid="editor-canvas"
|
||||
>
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
scaleX={zoom}
|
||||
scaleY={zoom}
|
||||
x={panOffset.x}
|
||||
y={panOffset.y}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<Layer>{/* Canvas objects are rendered here by tool components */}</Layer>
|
||||
{/* Grid overlay layer (Feature 49) - non-interactive */}
|
||||
{(gridVisible || zoom >= 8) && (
|
||||
<Layer listening={false}>
|
||||
<GridOverlay
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
zoom={zoom}
|
||||
showGrid={gridVisible}
|
||||
showPixelGrid={zoom >= 8}
|
||||
/>
|
||||
</Layer>
|
||||
)}
|
||||
</Stage>
|
||||
<BrushCursorOverlay containerRef={containerRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridOverlay({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
zoom,
|
||||
showGrid,
|
||||
showPixelGrid,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
zoom: number;
|
||||
showGrid: boolean;
|
||||
showPixelGrid: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Shape
|
||||
sceneFunc={(ctx, shape) => {
|
||||
ctx.beginPath();
|
||||
|
||||
if (showGrid) {
|
||||
const spacing = 50;
|
||||
ctx.strokeStyle = "rgba(128, 128, 128, 0.15)";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
for (let x = spacing; x < canvasWidth; x += spacing) {
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvasHeight);
|
||||
}
|
||||
for (let y = spacing; y < canvasHeight; y += spacing) {
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvasWidth, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
if (showPixelGrid) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "rgba(128, 128, 128, 0.1)";
|
||||
ctx.lineWidth = 1 / zoom;
|
||||
for (let x = 1; x < canvasWidth; x++) {
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvasHeight);
|
||||
}
|
||||
for (let y = 1; y < canvasHeight; y++) {
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvasWidth, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.fillStrokeShape(shape);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// apps/web/src/components/editor/editor-options-bar.tsx
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
export function EditorOptionsBar() {
|
||||
const activeTool = useEditorStore((s) => s.activeTool);
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground capitalize">
|
||||
{activeTool.replace(/-/g, " ").replace(/^shape /, "")}
|
||||
</span>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{/* Tool-specific option components are rendered here by each agent */}
|
||||
<div id="editor-options-content" className="flex items-center gap-2 flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// apps/web/src/components/editor/editor-right-panel.tsx
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const TABS = [
|
||||
{ id: "layers" as const, label: "Layers" },
|
||||
{ id: "adjustments" as const, label: "Adjustments" },
|
||||
{ id: "history" as const, label: "History" },
|
||||
];
|
||||
|
||||
export function EditorRightPanel() {
|
||||
const visible = useEditorStore((s) => s.rightPanelVisible);
|
||||
const activeTab = useEditorStore((s) => s.rightPanelTab);
|
||||
const setTab = useEditorStore((s) => s.setRightPanelTab);
|
||||
const togglePanel = useEditorStore((s) => s.toggleRightPanel);
|
||||
|
||||
if (!visible) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className="flex items-center justify-center w-6 bg-card border-l border-border"
|
||||
aria-label="Expand panel"
|
||||
>
|
||||
<ChevronRight size={14} className="text-muted-foreground rotate-180" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[280px] bg-card border-l border-border">
|
||||
<div className="flex items-center border-b border-border">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setTab(tab.id)}
|
||||
className={cn(
|
||||
"flex-1 py-2 text-xs font-medium text-center transition-colors",
|
||||
activeTab === tab.id
|
||||
? "text-foreground border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
data-testid={`tab-${tab.id}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePanel}
|
||||
className="px-1.5 py-2 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Collapse panel"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{/* Tab content rendered by agents: layers-panel, adjustments-panel, history-panel */}
|
||||
<div id={`editor-panel-${activeTab}`} />
|
||||
</div>
|
||||
{/* Color panel always visible at bottom (Agent 5) */}
|
||||
<div id="editor-color-panel" className="border-t border-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// apps/web/src/components/editor/editor-status-bar.tsx
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
export function EditorStatusBar() {
|
||||
const cursorPosition = useEditorStore((s) => s.cursorPosition);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const setZoom = useEditorStore((s) => s.setZoom);
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
|
||||
const zoomPercent = Math.round(zoom * 100);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between h-7 px-3 bg-card border-t border-border text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-3" data-testid="status-cursor">
|
||||
{sourceImageUrl && (
|
||||
<>
|
||||
<span data-testid="status-cursor-x">X: {cursorPosition.x}</span>
|
||||
<span data-testid="status-cursor-y">Y: {cursorPosition.y}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div data-testid="status-dimensions">
|
||||
{sourceImageUrl && `${canvasSize.width} x ${canvasSize.height} px`}
|
||||
</div>
|
||||
<div className="flex items-center gap-1" data-testid="status-zoom">
|
||||
<input
|
||||
type="number"
|
||||
value={zoomPercent}
|
||||
onChange={(e) => {
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (!Number.isNaN(val) && val > 0) setZoom(val / 100);
|
||||
}}
|
||||
className="w-14 bg-transparent text-right text-xs border-none outline-none"
|
||||
min={1}
|
||||
max={6400}
|
||||
/>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// apps/web/src/components/editor/editor-toolbar.tsx
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Crop,
|
||||
Eraser,
|
||||
Hand,
|
||||
MousePointer2,
|
||||
Move,
|
||||
PaintBucket,
|
||||
Paintbrush,
|
||||
Pen,
|
||||
Pencil,
|
||||
Pipette,
|
||||
ScanLine,
|
||||
Square,
|
||||
Stamp,
|
||||
Sun,
|
||||
Type,
|
||||
Wand2,
|
||||
ZoomIn,
|
||||
} from "lucide-react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ToolType } from "@/types/editor";
|
||||
import { IconButton } from "./common/icon-button";
|
||||
|
||||
interface ToolGroup {
|
||||
tools: {
|
||||
tool: ToolType;
|
||||
icon: typeof MousePointer2;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const TOOL_GROUPS: ToolGroup[] = [
|
||||
{
|
||||
// Group 1: Move + Free Transform
|
||||
tools: [
|
||||
{ tool: "move", icon: MousePointer2, label: "Move", shortcut: "V" },
|
||||
{
|
||||
tool: "transform",
|
||||
icon: Move,
|
||||
label: "Free Transform",
|
||||
shortcut: "Ctrl+T",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 2: Selection tools
|
||||
tools: [
|
||||
{
|
||||
tool: "marquee-rect",
|
||||
icon: Square,
|
||||
label: "Marquee",
|
||||
shortcut: "M",
|
||||
},
|
||||
{ tool: "lasso-free", icon: Pen, label: "Lasso", shortcut: "L" },
|
||||
{
|
||||
tool: "magic-wand",
|
||||
icon: Wand2,
|
||||
label: "Magic Wand",
|
||||
shortcut: "W",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 3: Crop
|
||||
tools: [{ tool: "crop", icon: Crop, label: "Crop", shortcut: "C" }],
|
||||
},
|
||||
{
|
||||
// Group 4: Eyedropper
|
||||
tools: [
|
||||
{
|
||||
tool: "eyedropper",
|
||||
icon: Pipette,
|
||||
label: "Eyedropper",
|
||||
shortcut: "I",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 5: Brush, Eraser, Pencil
|
||||
tools: [
|
||||
{ tool: "brush", icon: Paintbrush, label: "Brush", shortcut: "B" },
|
||||
{ tool: "eraser", icon: Eraser, label: "Eraser", shortcut: "E" },
|
||||
{ tool: "pencil", icon: Pencil, label: "Pencil", shortcut: "N" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 6: Clone Stamp
|
||||
tools: [
|
||||
{
|
||||
tool: "clone-stamp",
|
||||
icon: Stamp,
|
||||
label: "Clone Stamp",
|
||||
shortcut: "S",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 7: Dodge, Burn, Sponge
|
||||
tools: [
|
||||
{ tool: "dodge", icon: Sun, label: "Dodge", shortcut: "O" },
|
||||
{ tool: "burn", icon: Sun, label: "Burn", shortcut: "Shift+O" },
|
||||
{ tool: "sponge", icon: Sun, label: "Sponge", shortcut: "Shift+O" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 8: Blur brush, Sharpen brush, Smudge
|
||||
tools: [
|
||||
{ tool: "blur-brush", icon: ScanLine, label: "Blur Brush", shortcut: "" },
|
||||
{
|
||||
tool: "sharpen-brush",
|
||||
icon: ScanLine,
|
||||
label: "Sharpen Brush",
|
||||
shortcut: "",
|
||||
},
|
||||
{ tool: "smudge", icon: ScanLine, label: "Smudge", shortcut: "" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 9: Paint Bucket, Gradient
|
||||
tools: [
|
||||
{
|
||||
tool: "fill",
|
||||
icon: PaintBucket,
|
||||
label: "Paint Bucket",
|
||||
shortcut: "G",
|
||||
},
|
||||
{
|
||||
tool: "gradient",
|
||||
icon: ArrowUpRight,
|
||||
label: "Gradient",
|
||||
shortcut: "Shift+G",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Group 10: Shapes
|
||||
tools: [{ tool: "shape-rect", icon: Square, label: "Shape", shortcut: "U" }],
|
||||
},
|
||||
{
|
||||
// Group 11: Text
|
||||
tools: [{ tool: "text", icon: Type, label: "Text", shortcut: "T" }],
|
||||
},
|
||||
{
|
||||
// Group 12: Hand, Zoom
|
||||
tools: [
|
||||
{ tool: "hand", icon: Hand, label: "Hand", shortcut: "H" },
|
||||
{ tool: "zoom", icon: ZoomIn, label: "Zoom", shortcut: "Z" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function EditorToolbar() {
|
||||
const activeTool = useEditorStore((s) => s.activeTool);
|
||||
const setTool = useEditorStore((s) => s.setTool);
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center w-12 bg-card border-r border-border py-2 gap-0.5 overflow-y-auto">
|
||||
{TOOL_GROUPS.map((group, gi) => (
|
||||
<div key={group.tools[0].tool}>
|
||||
{gi > 0 && <div className="w-6 h-px bg-border mx-auto my-1" />}
|
||||
{group.tools.map((t) => (
|
||||
<IconButton
|
||||
key={t.tool}
|
||||
icon={t.icon}
|
||||
label={t.label}
|
||||
shortcut={t.shortcut}
|
||||
active={activeTool === t.tool}
|
||||
disabled={!sourceImageUrl && t.tool !== "hand" && t.tool !== "zoom"}
|
||||
onClick={() => setTool(t.tool)}
|
||||
data-testid={`tool-${t.tool}`}
|
||||
data-tool={t.tool}
|
||||
data-tool-active={String(activeTool === t.tool)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// apps/web/src/components/editor/panels/history-panel.tsx
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Brush,
|
||||
Copy,
|
||||
Crop,
|
||||
Eraser,
|
||||
Layers,
|
||||
MousePointer2,
|
||||
Move,
|
||||
Paintbrush,
|
||||
Pencil,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
Sliders,
|
||||
Square,
|
||||
Trash2,
|
||||
Type,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
// Map action labels to icons for the history list
|
||||
const ACTION_ICON_MAP: Record<string, React.ComponentType<{ size?: number }>> = {
|
||||
"Brush Stroke": Brush,
|
||||
"Add Line": Pencil,
|
||||
"Eraser Stroke": Eraser,
|
||||
"Add Rect": Square,
|
||||
"Add Ellipse": Square,
|
||||
"Add Text": Type,
|
||||
"Add Arrow": ArrowUp,
|
||||
"Add Polygon": Square,
|
||||
"Add Star": Square,
|
||||
"Add Image": Square,
|
||||
Move: Move,
|
||||
Transform: Move,
|
||||
"Text Edit": Type,
|
||||
"Add Layer": Layers,
|
||||
"Delete Layer": Trash2,
|
||||
"Duplicate Layer": Copy,
|
||||
"Reorder Layers": ArrowDown,
|
||||
"Merge Down": Layers,
|
||||
"Flatten All": Layers,
|
||||
Crop: Crop,
|
||||
Delete: Trash2,
|
||||
Paste: Copy,
|
||||
"Paste in Place": Copy,
|
||||
Fill: Paintbrush,
|
||||
"Resize Canvas": Square,
|
||||
"Resize Image": Square,
|
||||
"Rotate Canvas 90": RotateCcw,
|
||||
"Rotate Canvas 180": RotateCcw,
|
||||
"Rotate Canvas 270": RotateCcw,
|
||||
"Flip Horizontal": ArrowUp,
|
||||
"Flip Vertical": ArrowDown,
|
||||
"Trim Canvas": Scissors,
|
||||
"Load Image": Square,
|
||||
"Bring to Front": ArrowUp,
|
||||
"Bring Forward": ArrowUp,
|
||||
"Send Backward": ArrowDown,
|
||||
"Send to Back": ArrowDown,
|
||||
};
|
||||
|
||||
function getActionIcon(label: string): React.ComponentType<{ size?: number }> {
|
||||
if (ACTION_ICON_MAP[label]) return ACTION_ICON_MAP[label];
|
||||
if (label.startsWith("Add ")) return Square;
|
||||
if (label.includes("Layer")) return Layers;
|
||||
if (label.includes("Adjust") || label.includes("Filter")) return Sliders;
|
||||
return MousePointer2;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function HistoryPanel() {
|
||||
const lastAction = useEditorStore((s) => s.lastAction);
|
||||
const temporalStore = useEditorStore.temporal.getState();
|
||||
const pastStates = temporalStore.pastStates;
|
||||
const futureStates = temporalStore.futureStates;
|
||||
|
||||
// Force re-render when history changes by subscribing to history version
|
||||
useEditorStore((s) => s._historyVersion);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
useEditorStore.temporal.getState().undo();
|
||||
}, []);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
useEditorStore.temporal.getState().redo();
|
||||
}, []);
|
||||
|
||||
// Build the history list from past states
|
||||
const entries = useMemo((): HistoryEntry[] => {
|
||||
const temporal = useEditorStore.temporal.getState();
|
||||
const past = temporal.pastStates as Array<{ lastAction?: string }>;
|
||||
const future = temporal.futureStates as Array<{ lastAction?: string }>;
|
||||
|
||||
const result: HistoryEntry[] = [];
|
||||
|
||||
// Future states (dimmed, above current in reverse order)
|
||||
for (let i = future.length - 1; i >= 0; i--) {
|
||||
result.push({
|
||||
index: -(i + 1),
|
||||
label: (future[i] as { lastAction?: string })?.lastAction || "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
// Current state (highlighted)
|
||||
result.push({
|
||||
index: 0,
|
||||
label: lastAction,
|
||||
});
|
||||
|
||||
// Past states (newest first, below current)
|
||||
for (let i = past.length - 1; i >= 0; i--) {
|
||||
result.push({
|
||||
index: past.length - i,
|
||||
label: (past[i] as { lastAction?: string })?.lastAction || "Unknown",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
// pastStates and futureStates are intentionally not reactive deps;
|
||||
// we read them inside via getState(). lastAction triggers recalculation.
|
||||
}, [lastAction]);
|
||||
|
||||
const jumpToState = useCallback((entry: HistoryEntry) => {
|
||||
const temporal = useEditorStore.temporal.getState();
|
||||
if (entry.index < 0) {
|
||||
// Future state: redo N times
|
||||
const steps = Math.abs(entry.index);
|
||||
for (let i = 0; i < steps; i++) {
|
||||
temporal.redo();
|
||||
}
|
||||
} else if (entry.index > 0) {
|
||||
// Past state: undo N times
|
||||
for (let i = 0; i < entry.index; i++) {
|
||||
temporal.undo();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Undo/Redo toolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={pastStates.length === 0}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
pastStates.length > 0
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Undo"
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={redo}
|
||||
disabled={futureStates.length === 0}
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors",
|
||||
futureStates.length > 0
|
||||
? "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
: "text-muted-foreground/30 cursor-not-allowed",
|
||||
)}
|
||||
aria-label="Redo"
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</button>
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">{pastStates.length} / 50</span>
|
||||
</div>
|
||||
|
||||
{/* History list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{entries.map((entry) => {
|
||||
const isCurrent = entry.index === 0;
|
||||
const isFuture = entry.index < 0;
|
||||
const Icon = getActionIcon(entry.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`history-${entry.index}`}
|
||||
type="button"
|
||||
onClick={() => jumpToState(entry)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-left text-xs transition-colors",
|
||||
isCurrent && "bg-primary/10 text-foreground font-medium",
|
||||
isFuture && "text-muted-foreground/40",
|
||||
!isCurrent &&
|
||||
!isFuture &&
|
||||
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon size={12} />
|
||||
<span className="truncate">{entry.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{entries.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-xs text-muted-foreground">No history yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// apps/web/src/components/editor/panels/navigator-panel.tsx
|
||||
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const THUMBNAIL_MAX_HEIGHT = 80;
|
||||
const THROTTLE_MS = 500;
|
||||
const MIN_ZOOM = 0.01;
|
||||
const MAX_ZOOM = 64;
|
||||
|
||||
export function NavigatorPanel() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const setPanOffset = useEditorStore((s) => s.setPanOffset);
|
||||
const setZoom = useEditorStore((s) => s.setZoom);
|
||||
|
||||
// Track history version to know when to update the thumbnail
|
||||
const historyVersion = useEditorStore((s) => s._historyVersion);
|
||||
|
||||
const [thumbnailDims, setThumbnailDims] = useState({ width: 0, height: 0 });
|
||||
const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
|
||||
|
||||
// Load the source image for the thumbnail
|
||||
useEffect(() => {
|
||||
if (!sourceImageUrl) {
|
||||
setImageEl(null);
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => setImageEl(img);
|
||||
img.src = sourceImageUrl;
|
||||
}, [sourceImageUrl]);
|
||||
|
||||
// Calculate thumbnail dimensions
|
||||
useEffect(() => {
|
||||
if (!canvasSize.width || !canvasSize.height) return;
|
||||
const aspect = canvasSize.width / canvasSize.height;
|
||||
const height = THUMBNAIL_MAX_HEIGHT;
|
||||
const width = Math.round(height * aspect);
|
||||
setThumbnailDims({ width, height });
|
||||
}, [canvasSize.width, canvasSize.height]);
|
||||
|
||||
// Draw the thumbnail (throttled)
|
||||
const drawThumbnail = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !imageEl) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
canvas.width = thumbnailDims.width;
|
||||
canvas.height = thumbnailDims.height;
|
||||
|
||||
// Draw checkerboard background for transparency
|
||||
const checkSize = 4;
|
||||
for (let y = 0; y < canvas.height; y += checkSize) {
|
||||
for (let x = 0; x < canvas.width; x += checkSize) {
|
||||
ctx.fillStyle =
|
||||
(Math.floor(x / checkSize) + Math.floor(y / checkSize)) % 2 === 0 ? "#e0e0e0" : "#ffffff";
|
||||
ctx.fillRect(x, y, checkSize, checkSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw image scaled to thumbnail
|
||||
ctx.drawImage(imageEl, 0, 0, thumbnailDims.width, thumbnailDims.height);
|
||||
}, [imageEl, thumbnailDims.width, thumbnailDims.height]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: historyVersion triggers thumbnail redraw on canvas changes
|
||||
useEffect(() => {
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
throttleTimerRef.current = setTimeout(() => {
|
||||
drawThumbnail();
|
||||
throttleTimerRef.current = null;
|
||||
}, THROTTLE_MS);
|
||||
return () => {
|
||||
if (throttleTimerRef.current) {
|
||||
clearTimeout(throttleTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [drawThumbnail, historyVersion]);
|
||||
|
||||
// Calculate the viewport rectangle on the thumbnail
|
||||
// The viewport rectangle represents what is currently visible in the editor canvas
|
||||
const getViewportRect = useCallback(() => {
|
||||
if (thumbnailDims.width === 0 || canvasSize.width === 0) {
|
||||
return { x: 0, y: 0, width: thumbnailDims.width, height: thumbnailDims.height };
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return { x: 0, y: 0, width: thumbnailDims.width, height: thumbnailDims.height };
|
||||
}
|
||||
|
||||
// Scale from canvas coordinates to thumbnail coordinates
|
||||
const scaleX = thumbnailDims.width / canvasSize.width;
|
||||
const scaleY = thumbnailDims.height / canvasSize.height;
|
||||
|
||||
// The visible area in canvas coordinates
|
||||
// panOffset is the stage position, zoom is the stage scale
|
||||
// Visible canvas area: from (-panOffset/zoom) to ((-panOffset + viewportSize)/zoom)
|
||||
const editorContainer = container.closest("[data-testid='editor-canvas']");
|
||||
const viewportWidth = editorContainer?.clientWidth || 800;
|
||||
const viewportHeight = editorContainer?.clientHeight || 600;
|
||||
|
||||
const visibleX = -panOffset.x / zoom;
|
||||
const visibleY = -panOffset.y / zoom;
|
||||
const visibleWidth = viewportWidth / zoom;
|
||||
const visibleHeight = viewportHeight / zoom;
|
||||
|
||||
return {
|
||||
x: visibleX * scaleX,
|
||||
y: visibleY * scaleY,
|
||||
width: visibleWidth * scaleX,
|
||||
height: visibleHeight * scaleY,
|
||||
};
|
||||
}, [thumbnailDims, canvasSize, zoom, panOffset]);
|
||||
|
||||
const viewportRect = getViewportRect();
|
||||
|
||||
// Handle click on minimap to jump to position
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
if (isDraggingRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const clickY = e.clientY - rect.top;
|
||||
|
||||
// Convert thumbnail coords to canvas coords
|
||||
const scaleX = canvasSize.width / thumbnailDims.width;
|
||||
const scaleY = canvasSize.height / thumbnailDims.height;
|
||||
const canvasX = clickX * scaleX;
|
||||
const canvasY = clickY * scaleY;
|
||||
|
||||
// Center the viewport on this position
|
||||
const editorContainer = containerRef.current?.closest("[data-testid='editor-canvas']");
|
||||
const viewportWidth = editorContainer?.clientWidth || 800;
|
||||
const viewportHeight = editorContainer?.clientHeight || 600;
|
||||
|
||||
setPanOffset({
|
||||
x: -(canvasX * zoom) + viewportWidth / 2,
|
||||
y: -(canvasY * zoom) + viewportHeight / 2,
|
||||
});
|
||||
},
|
||||
[canvasSize, thumbnailDims, zoom, setPanOffset],
|
||||
);
|
||||
|
||||
// Handle drag on viewport rectangle to pan
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDraggingRef.current = true;
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startPan = { ...panOffset };
|
||||
|
||||
const scaleX = canvasSize.width / thumbnailDims.width;
|
||||
const scaleY = canvasSize.height / thumbnailDims.height;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const dx = moveEvent.clientX - startX;
|
||||
const dy = moveEvent.clientY - startY;
|
||||
|
||||
setPanOffset({
|
||||
x: startPan.x - dx * scaleX * zoom,
|
||||
y: startPan.y - dy * scaleY * zoom,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[panOffset, canvasSize, thumbnailDims, zoom, setPanOffset],
|
||||
);
|
||||
|
||||
const zoomPercent = Math.round(zoom * 100);
|
||||
|
||||
const handleZoomSlider = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = Number.parseFloat(e.target.value);
|
||||
setZoom(val);
|
||||
},
|
||||
[setZoom],
|
||||
);
|
||||
|
||||
if (!sourceImageUrl) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground border-b border-border">
|
||||
No image loaded
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border-b border-border" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="relative mx-auto my-2 cursor-crosshair border-0 bg-transparent p-0"
|
||||
style={{ width: thumbnailDims.width, height: thumbnailDims.height }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={thumbnailDims.width}
|
||||
height={thumbnailDims.height}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: viewport drag handle is mouse-only */}
|
||||
<div
|
||||
className="absolute border-2 border-red-500/70 pointer-events-auto cursor-move"
|
||||
style={{
|
||||
left: Math.max(0, viewportRect.x),
|
||||
top: Math.max(0, viewportRect.y),
|
||||
width: Math.min(viewportRect.width, thumbnailDims.width - Math.max(0, viewportRect.x)),
|
||||
height: Math.min(
|
||||
viewportRect.height,
|
||||
thumbnailDims.height - Math.max(0, viewportRect.y),
|
||||
),
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Zoom slider */}
|
||||
<div className="flex items-center gap-1.5 px-2 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom(Math.max(MIN_ZOOM, zoom / 1.2))}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={handleZoomSlider}
|
||||
className={cn(
|
||||
"flex-1 h-1 appearance-none rounded-full bg-muted",
|
||||
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2.5 [&::-webkit-slider-thumb]:h-2.5",
|
||||
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-foreground [&::-webkit-slider-thumb]:cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoom(Math.min(MAX_ZOOM, zoom * 1.2))}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground w-9 text-right tabular-nums">
|
||||
{zoomPercent}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// apps/web/src/hooks/use-canvas-zoom.ts
|
||||
|
||||
import Konva from "konva";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const ZOOM_SENSITIVITY = 1.1;
|
||||
const ZOOM_ANIMATION_DURATION = 0.15;
|
||||
|
||||
export function useCanvasZoom() {
|
||||
const stageRef = useRef<Konva.Stage>(null);
|
||||
const setZoom = useEditorStore((s) => s.setZoom);
|
||||
const setPanOffset = useEditorStore((s) => s.setPanOffset);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const panOffset = useEditorStore((s) => s.panOffset);
|
||||
const tweenRef = useRef<Konva.Tween | null>(null);
|
||||
|
||||
const animateZoom = useCallback(
|
||||
(targetZoom: number, targetPos: { x: number; y: number }) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) {
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
return;
|
||||
}
|
||||
if (tweenRef.current) {
|
||||
tweenRef.current.destroy();
|
||||
}
|
||||
tweenRef.current = new Konva.Tween({
|
||||
node: stage,
|
||||
scaleX: targetZoom,
|
||||
scaleY: targetZoom,
|
||||
x: targetPos.x,
|
||||
y: targetPos.y,
|
||||
duration: ZOOM_ANIMATION_DURATION,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
onFinish: () => {
|
||||
setZoom(targetZoom);
|
||||
setPanOffset(targetPos);
|
||||
tweenRef.current = null;
|
||||
},
|
||||
});
|
||||
tweenRef.current.play();
|
||||
},
|
||||
[setZoom, setPanOffset],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: Konva.KonvaEventObject<WheelEvent>) => {
|
||||
e.evt.preventDefault();
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const isZoom = e.evt.ctrlKey || e.evt.metaKey;
|
||||
|
||||
if (isZoom) {
|
||||
const pointer = stage.getPointerPosition();
|
||||
if (!pointer) return;
|
||||
|
||||
const oldZoom = zoom;
|
||||
const direction = e.evt.deltaY < 0 ? 1 : -1;
|
||||
const newZoom = direction > 0 ? oldZoom * ZOOM_SENSITIVITY : oldZoom / ZOOM_SENSITIVITY;
|
||||
const clampedZoom = Math.max(0.01, Math.min(64, newZoom));
|
||||
|
||||
const mousePointTo = {
|
||||
x: (pointer.x - panOffset.x) / oldZoom,
|
||||
y: (pointer.y - panOffset.y) / oldZoom,
|
||||
};
|
||||
|
||||
const newPos = {
|
||||
x: pointer.x - mousePointTo.x * clampedZoom,
|
||||
y: pointer.y - mousePointTo.y * clampedZoom,
|
||||
};
|
||||
|
||||
animateZoom(clampedZoom, newPos);
|
||||
} else {
|
||||
const dx = e.evt.shiftKey ? -e.evt.deltaY : -e.evt.deltaX;
|
||||
const dy = e.evt.shiftKey ? 0 : -e.evt.deltaY;
|
||||
setPanOffset({ x: panOffset.x + dx, y: panOffset.y + dy });
|
||||
}
|
||||
},
|
||||
[zoom, panOffset, setPanOffset, animateZoom],
|
||||
);
|
||||
|
||||
const fitToScreen = useCallback(
|
||||
(viewportWidth: number, viewportHeight: number, imageWidth: number, imageHeight: number) => {
|
||||
const scaleX = viewportWidth / imageWidth;
|
||||
const scaleY = viewportHeight / imageHeight;
|
||||
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
|
||||
const offsetX = (viewportWidth - imageWidth * fitZoom) / 2;
|
||||
const offsetY = (viewportHeight - imageHeight * fitZoom) / 2;
|
||||
animateZoom(fitZoom, { x: offsetX, y: offsetY });
|
||||
},
|
||||
[animateZoom],
|
||||
);
|
||||
|
||||
const zoomTo = useCallback(
|
||||
(targetZoom: number, viewportWidth: number, viewportHeight: number) => {
|
||||
const canvasSize = useEditorStore.getState().canvasSize;
|
||||
const offsetX = (viewportWidth - canvasSize.width * targetZoom) / 2;
|
||||
const offsetY = (viewportHeight - canvasSize.height * targetZoom) / 2;
|
||||
animateZoom(targetZoom, { x: offsetX, y: offsetY });
|
||||
},
|
||||
[animateZoom],
|
||||
);
|
||||
|
||||
return { stageRef, handleWheel, fitToScreen, zoomTo };
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
// apps/web/src/hooks/use-editor-shortcuts.ts
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import type { ToolType } from "@/types/editor";
|
||||
|
||||
/**
|
||||
* Checks whether the currently focused element is a text input
|
||||
* (input, textarea, select, or contentEditable) so tool‑shortcut
|
||||
* single‑letter keys can be suppressed while the user is typing.
|
||||
*/
|
||||
function isInputFocused(): boolean {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
if ((el as HTMLElement).isContentEditable) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Brush size step depends on current size for natural feel
|
||||
function getBrushSizeStep(current: number): number {
|
||||
if (current < 10) return 1;
|
||||
if (current < 50) return 2;
|
||||
if (current < 100) return 5;
|
||||
return 10;
|
||||
}
|
||||
|
||||
// Marquee subtypes cycle
|
||||
const MARQUEE_CYCLE: ToolType[] = ["marquee-rect", "marquee-ellipse"];
|
||||
// Lasso subtypes cycle
|
||||
const LASSO_CYCLE: ToolType[] = ["lasso-free", "lasso-poly"];
|
||||
// Shape subtypes cycle
|
||||
const SHAPE_CYCLE: ToolType[] = [
|
||||
"shape-rect",
|
||||
"shape-ellipse",
|
||||
"shape-line",
|
||||
"shape-arrow",
|
||||
"shape-polygon",
|
||||
"shape-star",
|
||||
];
|
||||
// Fill/gradient cycle
|
||||
const FILL_CYCLE: ToolType[] = ["fill", "gradient"];
|
||||
|
||||
function cycleSubtool(current: ToolType, cycle: ToolType[]): ToolType {
|
||||
const idx = cycle.indexOf(current);
|
||||
if (idx === -1) return cycle[0];
|
||||
return cycle[(idx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all editor keyboard shortcuts.
|
||||
* Must be called once inside the editor page component.
|
||||
*
|
||||
* @param callbacks Optional callbacks for save/export dialogs
|
||||
*/
|
||||
export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?: () => void }) {
|
||||
const previousToolRef = useRef<ToolType | null>(null);
|
||||
const isSpaceHeldRef = useRef(false);
|
||||
|
||||
// ---- Tool shortcuts (single key, disabled when input focused) ----
|
||||
|
||||
// V - Move tool
|
||||
useHotkeys(
|
||||
"v",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("move");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// M - Marquee selection (cycles rect/ellipse)
|
||||
useHotkeys(
|
||||
"m",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const current = useEditorStore.getState().activeTool;
|
||||
if (MARQUEE_CYCLE.includes(current)) {
|
||||
useEditorStore.getState().setTool(cycleSubtool(current, MARQUEE_CYCLE));
|
||||
} else {
|
||||
useEditorStore.getState().setTool("marquee-rect");
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+M - Cycle marquee subtypes
|
||||
useHotkeys(
|
||||
"shift+m",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const current = useEditorStore.getState().activeTool;
|
||||
useEditorStore.getState().setTool(cycleSubtool(current, MARQUEE_CYCLE));
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// L - Lasso tool (cycles freehand/polygonal)
|
||||
useHotkeys(
|
||||
"l",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const current = useEditorStore.getState().activeTool;
|
||||
if (LASSO_CYCLE.includes(current)) {
|
||||
useEditorStore.getState().setTool(cycleSubtool(current, LASSO_CYCLE));
|
||||
} else {
|
||||
useEditorStore.getState().setTool("lasso-free");
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+L - Cycle lasso subtypes
|
||||
useHotkeys(
|
||||
"shift+l",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore
|
||||
.getState()
|
||||
.setTool(cycleSubtool(useEditorStore.getState().activeTool, LASSO_CYCLE));
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// W - Magic wand
|
||||
useHotkeys(
|
||||
"w",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("magic-wand");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// C - Crop tool
|
||||
useHotkeys(
|
||||
"c",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("crop");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// I - Eyedropper
|
||||
useHotkeys(
|
||||
"i",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("eyedropper");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// B - Brush tool
|
||||
useHotkeys(
|
||||
"b",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("brush");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// E - Eraser tool
|
||||
useHotkeys(
|
||||
"e",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("eraser");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// S - Clone stamp
|
||||
useHotkeys(
|
||||
"s",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("clone-stamp");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// O - Dodge tool
|
||||
useHotkeys(
|
||||
"o",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("dodge");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// G - Fill/Gradient (cycles)
|
||||
useHotkeys(
|
||||
"g",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const current = useEditorStore.getState().activeTool;
|
||||
if (FILL_CYCLE.includes(current)) {
|
||||
useEditorStore.getState().setTool(cycleSubtool(current, FILL_CYCLE));
|
||||
} else {
|
||||
useEditorStore.getState().setTool("fill");
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+G - Cycle fill/gradient subtypes
|
||||
useHotkeys(
|
||||
"shift+g",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore
|
||||
.getState()
|
||||
.setTool(cycleSubtool(useEditorStore.getState().activeTool, FILL_CYCLE));
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// U - Shape tool (cycles shapes)
|
||||
useHotkeys(
|
||||
"u",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const current = useEditorStore.getState().activeTool;
|
||||
if (SHAPE_CYCLE.includes(current)) {
|
||||
useEditorStore.getState().setTool(cycleSubtool(current, SHAPE_CYCLE));
|
||||
} else {
|
||||
useEditorStore.getState().setTool("shape-rect");
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+U - Cycle shape subtypes
|
||||
useHotkeys(
|
||||
"shift+u",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore
|
||||
.getState()
|
||||
.setTool(cycleSubtool(useEditorStore.getState().activeTool, SHAPE_CYCLE));
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// T - Text tool
|
||||
useHotkeys(
|
||||
"t",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("text");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// H - Hand tool
|
||||
useHotkeys(
|
||||
"h",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("hand");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Z - Zoom tool
|
||||
useHotkeys(
|
||||
"z",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().setTool("zoom");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// ---- Color shortcuts ----
|
||||
|
||||
// X - Swap foreground/background
|
||||
useHotkeys(
|
||||
"x",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().swapColors();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// D - Reset colors to black/white
|
||||
useHotkeys(
|
||||
"d",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
useEditorStore.getState().resetColors();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// [ - Decrease brush size
|
||||
useHotkeys(
|
||||
"[",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const state = useEditorStore.getState();
|
||||
const step = getBrushSizeStep(state.brushSize);
|
||||
state.setBrushSize(state.brushSize - step);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// ] - Increase brush size
|
||||
useHotkeys(
|
||||
"]",
|
||||
() => {
|
||||
if (isInputFocused()) return;
|
||||
const state = useEditorStore.getState();
|
||||
const step = getBrushSizeStep(state.brushSize);
|
||||
state.setBrushSize(state.brushSize + step);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// ---- Modifier shortcuts (always active, override browser defaults) ----
|
||||
|
||||
// Ctrl+Z / Cmd+Z - Undo
|
||||
useHotkeys(
|
||||
"mod+z",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().undo();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+Z / Cmd+Shift+Z - Redo
|
||||
useHotkeys(
|
||||
"mod+shift+z",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().redo();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+S / Cmd+S - Save project
|
||||
useHotkeys(
|
||||
"mod+s",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
callbacks?.onSave?.();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+S / Cmd+Shift+S - Export image
|
||||
useHotkeys(
|
||||
"mod+shift+s",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
callbacks?.onExport?.();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+A / Cmd+A - Select all
|
||||
useHotkeys(
|
||||
"mod+a",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
const allIds = state.objects.map((o) => o.id);
|
||||
state.setSelectedObjects(allIds);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+D / Cmd+D - Deselect
|
||||
useHotkeys(
|
||||
"mod+d",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().setSelectedObjects([]);
|
||||
useEditorStore.getState().setSelection(null);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+C / Cmd+C - Copy
|
||||
useHotkeys(
|
||||
"mod+c",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().copyObjects();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+X / Cmd+X - Cut
|
||||
useHotkeys(
|
||||
"mod+x",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().cutObjects();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+V / Cmd+V - Paste
|
||||
useHotkeys(
|
||||
"mod+v",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
// Only paste internal clipboard; system paste is handled by paste event listener
|
||||
const state = useEditorStore.getState();
|
||||
if (state.clipboard && state.clipboard.length > 0) {
|
||||
e.preventDefault();
|
||||
state.pasteObjects();
|
||||
}
|
||||
},
|
||||
{ preventDefault: false },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged
|
||||
useHotkeys(
|
||||
"mod+shift+c",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
// Export visible layers to clipboard as PNG
|
||||
const stageCanvas = document.querySelector(
|
||||
"[data-testid='editor-canvas'] canvas",
|
||||
) as HTMLCanvasElement | null;
|
||||
if (!stageCanvas) return;
|
||||
stageCanvas.toBlob(async (blob) => {
|
||||
if (!blob) return;
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
}, "image/png");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+V / Cmd+Shift+V - Paste in place
|
||||
useHotkeys(
|
||||
"mod+shift+v",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().pasteInPlace();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+T / Cmd+T - Free transform
|
||||
useHotkeys(
|
||||
"mod+t",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().setTool("transform");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+J / Cmd+J - Duplicate layer
|
||||
useHotkeys(
|
||||
"mod+j",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
state.duplicateLayer(state.activeLayerId);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+N / Cmd+Shift+N - New layer
|
||||
useHotkeys(
|
||||
"mod+shift+n",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().addLayer();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Delete / Backspace - Delete selected objects
|
||||
useHotkeys(
|
||||
"delete,backspace",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length > 0) {
|
||||
state.removeObjects(state.selectedObjectIds);
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+0 / Cmd+0 - Fit to screen
|
||||
useHotkeys(
|
||||
"mod+0",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const editorCanvas = document.querySelector("[data-testid='editor-canvas']");
|
||||
if (!editorCanvas) return;
|
||||
const { width: vw, height: vh } = editorCanvas.getBoundingClientRect();
|
||||
const { canvasSize } = useEditorStore.getState();
|
||||
const scaleX = vw / canvasSize.width;
|
||||
const scaleY = vh / canvasSize.height;
|
||||
const fitZoom = Math.min(scaleX, scaleY) * 0.9;
|
||||
const offsetX = (vw - canvasSize.width * fitZoom) / 2;
|
||||
const offsetY = (vh - canvasSize.height * fitZoom) / 2;
|
||||
useEditorStore.getState().setZoom(fitZoom);
|
||||
useEditorStore.getState().setPanOffset({ x: offsetX, y: offsetY });
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+1 / Cmd+1 - Zoom to 100%
|
||||
useHotkeys(
|
||||
"mod+1",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const editorCanvas = document.querySelector("[data-testid='editor-canvas']");
|
||||
if (!editorCanvas) return;
|
||||
const { width: vw, height: vh } = editorCanvas.getBoundingClientRect();
|
||||
const { canvasSize } = useEditorStore.getState();
|
||||
const offsetX = (vw - canvasSize.width) / 2;
|
||||
const offsetY = (vh - canvasSize.height) / 2;
|
||||
useEditorStore.getState().setZoom(1);
|
||||
useEditorStore.getState().setPanOffset({ x: offsetX, y: offsetY });
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl++ / Cmd++ - Zoom in
|
||||
useHotkeys(
|
||||
"mod+=,mod+plus",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
state.setZoom(state.zoom * 1.25);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+- / Cmd+- - Zoom out
|
||||
useHotkeys(
|
||||
"mod+-,mod+minus",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
state.setZoom(state.zoom / 1.25);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Tab - Toggle right panel
|
||||
useHotkeys(
|
||||
"tab",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().toggleRightPanel();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Arrow keys - Nudge selected 1px
|
||||
useHotkeys(
|
||||
"left",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(-1, 0);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"right",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(1, 0);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"up",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(0, -1);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"down",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(0, 1);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+Arrow - Nudge 10px
|
||||
useHotkeys(
|
||||
"shift+left",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(-10, 0);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"shift+right",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(10, 0);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"shift+up",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(0, -10);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"shift+down",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
nudgeSelected(0, 10);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Enter - Apply current operation (crop, transform)
|
||||
useHotkeys(
|
||||
"enter",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.isCropping && state.cropState) {
|
||||
state.applyCrop();
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Escape - Cancel current operation
|
||||
useHotkeys(
|
||||
"escape",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.isCropping) {
|
||||
state.setCropState(null);
|
||||
}
|
||||
state.setSelectedObjects([]);
|
||||
state.setSelection(null);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+R / Cmd+R - Toggle rulers (override browser refresh)
|
||||
useHotkeys(
|
||||
"mod+r",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().toggleRulers();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+; / Cmd+; - Toggle guides
|
||||
useHotkeys(
|
||||
"mod+;",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().toggleGuides();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+' / Cmd+' - Toggle grid
|
||||
useHotkeys(
|
||||
"mod+'",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().toggleGrid();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+I / Cmd+Shift+I - Inverse selection
|
||||
useHotkeys(
|
||||
"mod+shift+i",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().invertSelection();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+E / Cmd+E - Merge down
|
||||
useHotkeys(
|
||||
"mod+e",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
state.mergeDown(state.activeLayerId);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+E / Cmd+Shift+E - Flatten all
|
||||
useHotkeys(
|
||||
"mod+shift+e",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
useEditorStore.getState().flattenAll();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+] / Cmd+Shift+] - Bring to front
|
||||
useHotkeys(
|
||||
"mod+shift+]",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length === 1) {
|
||||
state.bringToFront(state.selectedObjectIds[0]);
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+Shift+[ / Cmd+Shift+[ - Send to back
|
||||
useHotkeys(
|
||||
"mod+shift+[",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length === 1) {
|
||||
state.sendToBack(state.selectedObjectIds[0]);
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+] / Cmd+] - Bring forward
|
||||
useHotkeys(
|
||||
"mod+]",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length === 1) {
|
||||
state.bringForward(state.selectedObjectIds[0]);
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Ctrl+[ / Cmd+[ - Send backward
|
||||
useHotkeys(
|
||||
"mod+[",
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const state = useEditorStore.getState();
|
||||
if (state.selectedObjectIds.length === 1) {
|
||||
state.sendBackward(state.selectedObjectIds[0]);
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Shift+Backspace - Fill dialog (trigger callback or use fill tool)
|
||||
useHotkeys(
|
||||
"shift+backspace",
|
||||
(e) => {
|
||||
if (isInputFocused()) return;
|
||||
e.preventDefault();
|
||||
// Fill dialog would be handled by Agent 1's fill-dialog component
|
||||
// For now, switch to fill tool as a fallback
|
||||
useEditorStore.getState().setTool("fill");
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// ---- Space key: temporary hand tool ----
|
||||
|
||||
const handleSpaceDown = useCallback((e: KeyboardEvent) => {
|
||||
if (isInputFocused()) return;
|
||||
if (e.code !== "Space") return;
|
||||
if (e.repeat) return;
|
||||
e.preventDefault();
|
||||
|
||||
isSpaceHeldRef.current = true;
|
||||
const state = useEditorStore.getState();
|
||||
if (state.activeTool !== "hand") {
|
||||
previousToolRef.current = state.activeTool;
|
||||
useEditorStore.setState({ isSpaceHeld: true });
|
||||
state.setTool("hand");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSpaceUp = useCallback((e: KeyboardEvent) => {
|
||||
if (e.code !== "Space") return;
|
||||
e.preventDefault();
|
||||
|
||||
if (isSpaceHeldRef.current) {
|
||||
isSpaceHeldRef.current = false;
|
||||
useEditorStore.setState({ isSpaceHeld: false });
|
||||
if (previousToolRef.current) {
|
||||
useEditorStore.getState().setTool(previousToolRef.current);
|
||||
previousToolRef.current = null;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleSpaceDown);
|
||||
window.addEventListener("keyup", handleSpaceUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleSpaceDown);
|
||||
window.removeEventListener("keyup", handleSpaceUp);
|
||||
};
|
||||
}, [handleSpaceDown, handleSpaceUp]);
|
||||
}
|
||||
|
||||
/** Nudge all selected objects by (dx, dy) pixels. */
|
||||
function nudgeSelected(dx: number, dy: number): void {
|
||||
const state = useEditorStore.getState();
|
||||
for (const id of state.selectedObjectIds) {
|
||||
const obj = state.objects.find((o) => o.id === id);
|
||||
if (!obj) continue;
|
||||
const attrs = obj.attrs;
|
||||
if ("x" in attrs && "y" in attrs) {
|
||||
state.updateObject(id, {
|
||||
x: (attrs as { x: number }).x + dx,
|
||||
y: (attrs as { y: number }).y + dy,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// apps/web/src/pages/editor-page.tsx
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
|
||||
import { EditorCanvas } from "@/components/editor/editor-canvas";
|
||||
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
|
||||
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
|
||||
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
||||
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
export function EditorPage() {
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
const loadImage = useEditorStore((s) => s.loadImage);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
const blob = item.getAsFile();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
|
||||
img.src = url;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadImage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, [handlePaste]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const url = params.get("url");
|
||||
if (url) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
|
||||
img.src = url;
|
||||
}
|
||||
}, [loadImage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<EditorOptionsBar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<EditorToolbar />
|
||||
<div className="relative flex-1 overflow-hidden bg-muted/30">
|
||||
<EditorCanvas />
|
||||
{!sourceImageUrl && <WelcomeScreen />}
|
||||
</div>
|
||||
<EditorRightPanel />
|
||||
</div>
|
||||
<EditorStatusBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
// apps/web/src/stores/editor-store.ts
|
||||
|
||||
import { temporal } from "zundo";
|
||||
import { create } from "zustand";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import type {
|
||||
AdjustmentValues,
|
||||
CanvasObject,
|
||||
EditorLayer,
|
||||
EditorState,
|
||||
FilterConfig,
|
||||
ToolType,
|
||||
} from "@/types/editor";
|
||||
|
||||
const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 };
|
||||
const DEFAULT_LAYER_ID = "layer-1";
|
||||
const MAX_RECENT_COLORS = 12;
|
||||
const MIN_ZOOM = 0.01;
|
||||
const MAX_ZOOM = 64;
|
||||
const MAX_BRUSH_SIZE = 500;
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
const DEFAULT_ADJUSTMENTS: AdjustmentValues = {
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
luminance: 0,
|
||||
exposure: 0,
|
||||
vibrance: 0,
|
||||
warmth: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: FilterConfig[] = [
|
||||
{ type: "blur", enabled: false, params: { radius: 0 } },
|
||||
{ type: "sharpen", enabled: false, params: { amount: 0 } },
|
||||
{ type: "noise", enabled: false, params: { amount: 0 } },
|
||||
{ type: "pixelate", enabled: false, params: { size: 1 } },
|
||||
{ type: "emboss", enabled: false, params: { strength: 0 } },
|
||||
{ type: "grayscale", enabled: false, params: {} },
|
||||
{ type: "sepia", enabled: false, params: {} },
|
||||
{ type: "invert", enabled: false, params: {} },
|
||||
{ type: "posterize", enabled: false, params: { levels: 8 } },
|
||||
{ type: "solarize", enabled: false, params: {} },
|
||||
{ type: "threshold", enabled: false, params: { level: 0.5 } },
|
||||
{ type: "kaleidoscope", enabled: false, params: { power: 2, angle: 0 } },
|
||||
{ type: "motionBlur", enabled: false, params: { angle: 0, distance: 10 } },
|
||||
{
|
||||
type: "radialBlur",
|
||||
enabled: false,
|
||||
params: { amount: 10, centerX: 0.5, centerY: 0.5 },
|
||||
},
|
||||
{
|
||||
type: "surfaceBlur",
|
||||
enabled: false,
|
||||
params: { radius: 5, threshold: 25 },
|
||||
},
|
||||
{
|
||||
type: "vignette",
|
||||
enabled: false,
|
||||
params: { amount: 50, midpoint: 50, roundness: 0, feather: 50 },
|
||||
},
|
||||
{
|
||||
type: "grain",
|
||||
enabled: false,
|
||||
params: { amount: 25, size: 25, roughness: 50 },
|
||||
},
|
||||
];
|
||||
|
||||
function createDefaultLayer(id: string, name: string): EditorLayer {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
visible: true,
|
||||
locked: false,
|
||||
opacity: 1,
|
||||
blendMode: "source-over",
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
let layerCounter = 1;
|
||||
|
||||
export const useEditorStore = create<EditorState>()(
|
||||
temporal(
|
||||
(set, get) => ({
|
||||
// --- Canvas ---
|
||||
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||
zoom: 1,
|
||||
panOffset: { x: 0, y: 0 },
|
||||
cursorPosition: { x: 0, y: 0 },
|
||||
|
||||
// --- Image ---
|
||||
sourceImageUrl: null,
|
||||
sourceImageSize: null,
|
||||
|
||||
// --- Tool ---
|
||||
activeTool: "move" as ToolType,
|
||||
previousTool: null,
|
||||
|
||||
// --- Brush ---
|
||||
brushSize: 10,
|
||||
brushOpacity: 1,
|
||||
brushHardness: 1,
|
||||
|
||||
// --- Colors ---
|
||||
foregroundColor: "#000000",
|
||||
backgroundColor: "#ffffff",
|
||||
recentColors: [],
|
||||
|
||||
// --- Layers ---
|
||||
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
|
||||
activeLayerId: DEFAULT_LAYER_ID,
|
||||
|
||||
// --- Objects ---
|
||||
objects: [],
|
||||
selectedObjectIds: [],
|
||||
|
||||
// --- Selection ---
|
||||
selection: null,
|
||||
|
||||
// --- Crop ---
|
||||
cropState: null,
|
||||
isCropping: false,
|
||||
|
||||
// --- Adjustments ---
|
||||
adjustments: { ...DEFAULT_ADJUSTMENTS },
|
||||
filters: DEFAULT_FILTERS.map((f) => ({
|
||||
...f,
|
||||
params: { ...f.params },
|
||||
})),
|
||||
|
||||
// --- Text ---
|
||||
editingTextId: null,
|
||||
|
||||
// --- Shape settings ---
|
||||
shapeFill: "#3b82f6",
|
||||
shapeStroke: "#000000",
|
||||
shapeStrokeWidth: 2,
|
||||
shapeCornerRadius: 0,
|
||||
shapePolygonSides: 6,
|
||||
shapeStarPoints: 5,
|
||||
|
||||
// --- Clone stamp ---
|
||||
cloneSource: null,
|
||||
|
||||
// --- Dodge/Burn/Sponge ---
|
||||
dodgeBurnRange: "midtones",
|
||||
dodgeBurnExposure: 50,
|
||||
spongeMode: "saturate",
|
||||
spongeFlow: 50,
|
||||
|
||||
// --- UI ---
|
||||
rightPanelTab: "layers",
|
||||
rightPanelVisible: true,
|
||||
isSpaceHeld: false,
|
||||
|
||||
// --- Document ---
|
||||
isDirty: false,
|
||||
lastAutoSave: null,
|
||||
|
||||
// --- Clipboard ---
|
||||
clipboard: null,
|
||||
|
||||
// --- Guides ---
|
||||
guides: [],
|
||||
snappingEnabled: true,
|
||||
rulersVisible: false,
|
||||
guidesVisible: true,
|
||||
gridVisible: false,
|
||||
|
||||
// --- Loading ---
|
||||
loadingState: null,
|
||||
|
||||
// --- History ---
|
||||
lastAction: "Initial State",
|
||||
_historyVersion: 0,
|
||||
|
||||
// ===== ACTIONS =====
|
||||
|
||||
setTool: (tool) => {
|
||||
const { activeTool } = get();
|
||||
set({
|
||||
activeTool: tool,
|
||||
previousTool: activeTool,
|
||||
isCropping: tool === "crop",
|
||||
});
|
||||
},
|
||||
|
||||
setCursorPosition: (pos) => set({ cursorPosition: pos }),
|
||||
|
||||
setZoom: (zoom) => set({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)) }),
|
||||
|
||||
setPanOffset: (offset) => set({ panOffset: offset }),
|
||||
|
||||
loadImage: (url, width, height) => {
|
||||
set({
|
||||
sourceImageUrl: url,
|
||||
sourceImageSize: { width, height },
|
||||
canvasSize: { width, height },
|
||||
zoom: 1,
|
||||
panOffset: { x: 0, y: 0 },
|
||||
lastAction: "Load Image",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
resizeCanvas: (width, height, _anchor) => {
|
||||
set({
|
||||
canvasSize: { width, height },
|
||||
isDirty: true,
|
||||
lastAction: "Resize Canvas",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
resizeImage: (width, height) => {
|
||||
set({
|
||||
canvasSize: { width, height },
|
||||
sourceImageSize: { width, height },
|
||||
isDirty: true,
|
||||
lastAction: "Resize Image",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
rotateCanvas: (degrees) => {
|
||||
const { canvasSize, objects } = get();
|
||||
const newSize =
|
||||
degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width };
|
||||
set({
|
||||
canvasSize: newSize,
|
||||
sourceImageSize: newSize,
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs && "y" in attrs) {
|
||||
if (degrees === 90) {
|
||||
const newX = canvasSize.height - (attrs as { y: number }).y;
|
||||
const newY = (attrs as { x: number }).x;
|
||||
(attrs as { x: number }).x = newX;
|
||||
(attrs as { y: number }).y = newY;
|
||||
} else if (degrees === 270) {
|
||||
const newX = (attrs as { y: number }).y;
|
||||
const newY = canvasSize.width - (attrs as { x: number }).x;
|
||||
(attrs as { x: number }).x = newX;
|
||||
(attrs as { y: number }).y = newY;
|
||||
} else {
|
||||
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x;
|
||||
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y;
|
||||
}
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: `Rotate Canvas ${degrees}`,
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
flipCanvasHorizontal: () => {
|
||||
const { canvasSize, objects } = get();
|
||||
set({
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: "Flip Horizontal",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
flipCanvasVertical: () => {
|
||||
const { canvasSize, objects } = get();
|
||||
set({
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
isDirty: true,
|
||||
lastAction: "Flip Vertical",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
trimCanvas: () => {
|
||||
set({
|
||||
isDirty: true,
|
||||
lastAction: "Trim Canvas",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Colors
|
||||
setForegroundColor: (color) => {
|
||||
const { recentColors } = get();
|
||||
const updated = [color, ...recentColors.filter((c) => c !== color)].slice(
|
||||
0,
|
||||
MAX_RECENT_COLORS,
|
||||
);
|
||||
set({ foregroundColor: color, recentColors: updated });
|
||||
},
|
||||
|
||||
setBackgroundColor: (color) => set({ backgroundColor: color }),
|
||||
|
||||
swapColors: () => {
|
||||
const { foregroundColor, backgroundColor } = get();
|
||||
set({
|
||||
foregroundColor: backgroundColor,
|
||||
backgroundColor: foregroundColor,
|
||||
});
|
||||
},
|
||||
|
||||
resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }),
|
||||
|
||||
// Objects
|
||||
addObject: (obj) => {
|
||||
set({
|
||||
objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }],
|
||||
isDirty: true,
|
||||
lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`,
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
updateObject: (id, attrs) => {
|
||||
set({
|
||||
objects: get().objects.map((obj) =>
|
||||
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
|
||||
),
|
||||
isDirty: true,
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
removeObjects: (ids) => {
|
||||
const idSet = new Set(ids);
|
||||
set({
|
||||
objects: get().objects.filter((obj) => !idSet.has(obj.id)),
|
||||
selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)),
|
||||
isDirty: true,
|
||||
lastAction: "Delete",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedObjects: (ids) => set({ selectedObjectIds: ids }),
|
||||
|
||||
bringToFront: (objectId) => {
|
||||
const { objects } = get();
|
||||
const obj = objects.find((o) => o.id === objectId);
|
||||
if (!obj) return;
|
||||
const layerObjects = objects.filter((o) => o.layerId === obj.layerId);
|
||||
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId);
|
||||
const reordered = [...layerObjects.filter((o) => o.id !== objectId), obj];
|
||||
set({
|
||||
objects: [...otherObjects, ...reordered],
|
||||
lastAction: "Bring to Front",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
bringForward: (objectId) => {
|
||||
const { objects } = get();
|
||||
const idx = objects.findIndex((o) => o.id === objectId);
|
||||
if (idx === -1 || idx === objects.length - 1) return;
|
||||
const newObjects = [...objects];
|
||||
[newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]];
|
||||
set({
|
||||
objects: newObjects,
|
||||
lastAction: "Bring Forward",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
sendBackward: (objectId) => {
|
||||
const { objects } = get();
|
||||
const idx = objects.findIndex((o) => o.id === objectId);
|
||||
if (idx <= 0) return;
|
||||
const newObjects = [...objects];
|
||||
[newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]];
|
||||
set({
|
||||
objects: newObjects,
|
||||
lastAction: "Send Backward",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
sendToBack: (objectId) => {
|
||||
const { objects } = get();
|
||||
const obj = objects.find((o) => o.id === objectId);
|
||||
if (!obj) return;
|
||||
const layerObjects = objects.filter((o) => o.layerId === obj.layerId);
|
||||
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId);
|
||||
const reordered = [obj, ...layerObjects.filter((o) => o.id !== objectId)];
|
||||
set({
|
||||
objects: [...reordered, ...otherObjects],
|
||||
lastAction: "Send to Back",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Layers
|
||||
addLayer: () => {
|
||||
layerCounter++;
|
||||
const id = generateId();
|
||||
const name = `Layer ${layerCounter}`;
|
||||
const newLayer = createDefaultLayer(id, name);
|
||||
const { layers, activeLayerId } = get();
|
||||
const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
|
||||
const newLayers = [...layers];
|
||||
newLayers.splice(activeIndex + 1, 0, newLayer);
|
||||
set({
|
||||
layers: newLayers,
|
||||
activeLayerId: id,
|
||||
isDirty: true,
|
||||
lastAction: "Add Layer",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
removeLayer: (id) => {
|
||||
const { layers, objects, activeLayerId } = get();
|
||||
if (layers.length <= 1) return;
|
||||
const idx = layers.findIndex((l) => l.id === id);
|
||||
const newLayers = layers.filter((l) => l.id !== id);
|
||||
const newActiveId =
|
||||
activeLayerId === id ? newLayers[Math.min(idx, newLayers.length - 1)].id : activeLayerId;
|
||||
set({
|
||||
layers: newLayers,
|
||||
objects: objects.filter((o) => o.layerId !== id),
|
||||
activeLayerId: newActiveId,
|
||||
isDirty: true,
|
||||
lastAction: "Delete Layer",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
duplicateLayer: (id) => {
|
||||
const { layers, objects } = get();
|
||||
const source = layers.find((l) => l.id === id);
|
||||
if (!source) return;
|
||||
const newId = generateId();
|
||||
layerCounter++;
|
||||
const copy: EditorLayer = {
|
||||
...source,
|
||||
id: newId,
|
||||
name: `${source.name} (copy)`,
|
||||
thumbnail: null,
|
||||
};
|
||||
const sourceObjects = objects
|
||||
.filter((o) => o.layerId === id)
|
||||
.map((o) => ({ ...o, id: generateId(), layerId: newId }) as CanvasObject);
|
||||
const idx = layers.findIndex((l) => l.id === id);
|
||||
const newLayers = [...layers];
|
||||
newLayers.splice(idx + 1, 0, copy);
|
||||
set({
|
||||
layers: newLayers,
|
||||
objects: [...objects, ...sourceObjects],
|
||||
activeLayerId: newId,
|
||||
isDirty: true,
|
||||
lastAction: "Duplicate Layer",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
setActiveLayer: (id) => set({ activeLayerId: id }),
|
||||
|
||||
updateLayer: (id, updates) => {
|
||||
set({
|
||||
layers: get().layers.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||
isDirty: true,
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
reorderLayers: (fromIndex, toIndex) => {
|
||||
const newLayers = [...get().layers];
|
||||
const [moved] = newLayers.splice(fromIndex, 1);
|
||||
newLayers.splice(toIndex, 0, moved);
|
||||
set({
|
||||
layers: newLayers,
|
||||
isDirty: true,
|
||||
lastAction: "Reorder Layers",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
mergeDown: (id) => {
|
||||
const { layers, objects } = get();
|
||||
const idx = layers.findIndex((l) => l.id === id);
|
||||
if (idx <= 0) return;
|
||||
const belowLayer = layers[idx - 1];
|
||||
const mergedObjects = objects.map((o) =>
|
||||
o.layerId === id ? ({ ...o, layerId: belowLayer.id } as CanvasObject) : o,
|
||||
);
|
||||
set({
|
||||
layers: layers.filter((l) => l.id !== id),
|
||||
objects: mergedObjects,
|
||||
activeLayerId: belowLayer.id,
|
||||
isDirty: true,
|
||||
lastAction: "Merge Down",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
flattenAll: () => {
|
||||
const { layers, objects } = get();
|
||||
const bottomLayer = layers[0];
|
||||
set({
|
||||
layers: [{ ...bottomLayer, name: "Flattened" }],
|
||||
objects: objects.map((o) => ({ ...o, layerId: bottomLayer.id }) as CanvasObject),
|
||||
activeLayerId: bottomLayer.id,
|
||||
isDirty: true,
|
||||
lastAction: "Flatten All",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Adjustments
|
||||
setAdjustment: (key, value) => {
|
||||
const clamps: Record<string, [number, number]> = {
|
||||
brightness: [-100, 100],
|
||||
contrast: [-100, 100],
|
||||
hue: [0, 359],
|
||||
saturation: [-100, 100],
|
||||
luminance: [-100, 100],
|
||||
exposure: [-100, 100],
|
||||
vibrance: [-100, 100],
|
||||
warmth: [-100, 100],
|
||||
};
|
||||
const [min, max] = clamps[key] || [-100, 100];
|
||||
set({
|
||||
adjustments: {
|
||||
...get().adjustments,
|
||||
[key]: Math.max(min, Math.min(max, value)),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }),
|
||||
|
||||
toggleFilter: (type) => {
|
||||
set({
|
||||
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
|
||||
});
|
||||
},
|
||||
|
||||
setFilterParam: (type, key, value) => {
|
||||
set({
|
||||
filters: get().filters.map((f) =>
|
||||
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
// Selection
|
||||
setSelection: (selection) => set({ selection }),
|
||||
|
||||
invertSelection: () => {
|
||||
const { selection } = get();
|
||||
if (!selection) return;
|
||||
if (selection.mask) {
|
||||
const inverted = new Uint8Array(selection.mask.length);
|
||||
for (let i = 0; i < selection.mask.length; i++) {
|
||||
inverted[i] = 255 - selection.mask[i];
|
||||
}
|
||||
set({ selection: { ...selection, mask: inverted } });
|
||||
}
|
||||
},
|
||||
|
||||
// Crop
|
||||
setCropState: (state) => set({ cropState: state, isCropping: state !== null }),
|
||||
|
||||
applyCrop: () => {
|
||||
const { cropState, objects } = get();
|
||||
if (!cropState) return;
|
||||
set({
|
||||
canvasSize: { width: cropState.width, height: cropState.height },
|
||||
objects: objects.map((obj) => {
|
||||
const attrs = { ...obj.attrs };
|
||||
if ("x" in attrs) {
|
||||
(attrs as { x: number }).x -= cropState.x;
|
||||
}
|
||||
if ("y" in attrs) {
|
||||
(attrs as { y: number }).y -= cropState.y;
|
||||
}
|
||||
return { ...obj, attrs } as CanvasObject;
|
||||
}),
|
||||
cropState: null,
|
||||
isCropping: false,
|
||||
isDirty: true,
|
||||
lastAction: "Crop",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Brush
|
||||
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }),
|
||||
setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }),
|
||||
setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }),
|
||||
|
||||
// Clipboard
|
||||
copyObjects: () => {
|
||||
const { objects, selectedObjectIds } = get();
|
||||
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
|
||||
set({ clipboard: selected });
|
||||
},
|
||||
|
||||
cutObjects: () => {
|
||||
const { objects, selectedObjectIds } = get();
|
||||
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
|
||||
set({ clipboard: selected });
|
||||
get().removeObjects(selectedObjectIds);
|
||||
},
|
||||
|
||||
pasteObjects: () => {
|
||||
const { clipboard, activeLayerId } = get();
|
||||
if (!clipboard || clipboard.length === 0) return;
|
||||
const pasted = clipboard.map(
|
||||
(obj) =>
|
||||
({
|
||||
...obj,
|
||||
id: generateId(),
|
||||
layerId: activeLayerId,
|
||||
attrs: {
|
||||
...obj.attrs,
|
||||
...("x" in obj.attrs ? { x: (obj.attrs as { x: number }).x + 10 } : {}),
|
||||
...("y" in obj.attrs ? { y: (obj.attrs as { y: number }).y + 10 } : {}),
|
||||
},
|
||||
}) as CanvasObject,
|
||||
);
|
||||
set({
|
||||
objects: [...get().objects, ...pasted],
|
||||
selectedObjectIds: pasted.map((o) => o.id),
|
||||
isDirty: true,
|
||||
lastAction: "Paste",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
pasteInPlace: () => {
|
||||
const { clipboard, activeLayerId } = get();
|
||||
if (!clipboard || clipboard.length === 0) return;
|
||||
const pasted = clipboard.map(
|
||||
(obj) =>
|
||||
({
|
||||
...obj,
|
||||
id: generateId(),
|
||||
layerId: activeLayerId,
|
||||
}) as CanvasObject,
|
||||
);
|
||||
set({
|
||||
objects: [...get().objects, ...pasted],
|
||||
selectedObjectIds: pasted.map((o) => o.id),
|
||||
isDirty: true,
|
||||
lastAction: "Paste in Place",
|
||||
_historyVersion: get()._historyVersion + 1,
|
||||
});
|
||||
},
|
||||
|
||||
// Guides
|
||||
addGuide: (orientation, position) => {
|
||||
set({
|
||||
guides: [...get().guides, { id: generateId(), orientation, position }],
|
||||
});
|
||||
},
|
||||
|
||||
removeGuide: (id) => {
|
||||
set({ guides: get().guides.filter((g) => g.id !== id) });
|
||||
},
|
||||
|
||||
updateGuide: (id, position) => {
|
||||
set({
|
||||
guides: get().guides.map((g) => (g.id === id ? { ...g, position } : g)),
|
||||
});
|
||||
},
|
||||
|
||||
toggleSnapping: () => set({ snappingEnabled: !get().snappingEnabled }),
|
||||
toggleRulers: () => set({ rulersVisible: !get().rulersVisible }),
|
||||
toggleGuides: () => set({ guidesVisible: !get().guidesVisible }),
|
||||
toggleGrid: () => set({ gridVisible: !get().gridVisible }),
|
||||
|
||||
// Document
|
||||
markDirty: () => set({ isDirty: true }),
|
||||
markClean: () => set({ isDirty: false }),
|
||||
setLoadingState: (state) => set({ loadingState: state }),
|
||||
|
||||
// Right panel
|
||||
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
||||
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
|
||||
}),
|
||||
{
|
||||
partialize: (state) => ({
|
||||
layers: state.layers,
|
||||
objects: state.objects,
|
||||
canvasSize: state.canvasSize,
|
||||
adjustments: state.adjustments,
|
||||
filters: state.filters,
|
||||
guides: state.guides,
|
||||
sourceImageUrl: state.sourceImageUrl,
|
||||
sourceImageSize: state.sourceImageSize,
|
||||
_historyVersion: state._historyVersion,
|
||||
}),
|
||||
limit: MAX_HISTORY,
|
||||
equality: (a, b) =>
|
||||
(a as { _historyVersion: number })._historyVersion ===
|
||||
(b as { _historyVersion: number })._historyVersion,
|
||||
handleSet: (handleSet) => {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (state) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => handleSet(state), 500);
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,430 @@
|
||||
// apps/web/src/types/editor.ts
|
||||
|
||||
export type ToolType =
|
||||
| "move"
|
||||
| "marquee-rect"
|
||||
| "marquee-ellipse"
|
||||
| "lasso-free"
|
||||
| "lasso-poly"
|
||||
| "magic-wand"
|
||||
| "crop"
|
||||
| "eyedropper"
|
||||
| "brush"
|
||||
| "eraser"
|
||||
| "pencil"
|
||||
| "clone-stamp"
|
||||
| "dodge"
|
||||
| "burn"
|
||||
| "sponge"
|
||||
| "blur-brush"
|
||||
| "sharpen-brush"
|
||||
| "smudge"
|
||||
| "fill"
|
||||
| "gradient"
|
||||
| "shape-rect"
|
||||
| "shape-ellipse"
|
||||
| "shape-line"
|
||||
| "shape-arrow"
|
||||
| "shape-polygon"
|
||||
| "shape-star"
|
||||
| "text"
|
||||
| "hand"
|
||||
| "zoom"
|
||||
| "transform";
|
||||
|
||||
export interface LineAttrs {
|
||||
points: number[];
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
tension: number;
|
||||
lineCap: "butt" | "round" | "square";
|
||||
lineJoin: "bevel" | "round" | "miter";
|
||||
opacity: number;
|
||||
globalCompositeOperation: string;
|
||||
shadowBlur?: number;
|
||||
shadowColor?: string;
|
||||
shadowOffsetX?: number;
|
||||
shadowOffsetY?: number;
|
||||
}
|
||||
|
||||
export interface RectAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
cornerRadius: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface EllipseAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface TextAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
text: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontStyle: string;
|
||||
fontVariant: string;
|
||||
textDecoration: string;
|
||||
align: "left" | "center" | "right";
|
||||
fill: string;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
wrap?: "word" | "char" | "none";
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface ImageAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
src: string;
|
||||
}
|
||||
|
||||
export interface ArrowAttrs {
|
||||
points: number[];
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
pointerLength: number;
|
||||
pointerWidth: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface PolygonAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
sides: number;
|
||||
radius: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface StarAttrs {
|
||||
x: number;
|
||||
y: number;
|
||||
numPoints: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
rotation: number;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export type CanvasObject =
|
||||
| { id: string; type: "line"; layerId: string; attrs: LineAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "rect"; layerId: string; attrs: RectAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "ellipse"; layerId: string; attrs: EllipseAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "text"; layerId: string; attrs: TextAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "image"; layerId: string; attrs: ImageAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "arrow"; layerId: string; attrs: ArrowAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "polygon"; layerId: string; attrs: PolygonAttrs; effects?: ObjectEffects }
|
||||
| { id: string; type: "star"; layerId: string; attrs: StarAttrs; effects?: ObjectEffects };
|
||||
|
||||
export interface ObjectEffects {
|
||||
dropShadow?: {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
blur: number;
|
||||
spread: number;
|
||||
};
|
||||
innerShadow?: {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
angle: number;
|
||||
distance: number;
|
||||
blur: number;
|
||||
};
|
||||
outerGlow?: {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
blur: number;
|
||||
spread: number;
|
||||
};
|
||||
stroke?: {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
width: number;
|
||||
position: "inside" | "center" | "outside";
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditorLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
opacity: number;
|
||||
blendMode: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
type: "rect" | "ellipse" | "lasso" | "wand";
|
||||
points: number[];
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
mask?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CropState {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: string | null;
|
||||
}
|
||||
|
||||
export interface AdjustmentValues {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
luminance: number;
|
||||
exposure: number;
|
||||
vibrance: number;
|
||||
warmth: number;
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
params: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface Guide {
|
||||
id: string;
|
||||
orientation: "horizontal" | "vertical";
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type AnchorPosition =
|
||||
| "top-left"
|
||||
| "top-center"
|
||||
| "top-right"
|
||||
| "center-left"
|
||||
| "center"
|
||||
| "center-right"
|
||||
| "bottom-left"
|
||||
| "bottom-center"
|
||||
| "bottom-right";
|
||||
|
||||
export interface LoadingState {
|
||||
operation: string;
|
||||
progress: number | null;
|
||||
cancellable: boolean;
|
||||
}
|
||||
|
||||
export interface CloneSource {
|
||||
x: number;
|
||||
y: number;
|
||||
aligned: boolean;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
// Canvas
|
||||
canvasSize: { width: number; height: number };
|
||||
zoom: number;
|
||||
panOffset: { x: number; y: number };
|
||||
cursorPosition: { x: number; y: number };
|
||||
|
||||
// Image
|
||||
sourceImageUrl: string | null;
|
||||
sourceImageSize: { width: number; height: number } | null;
|
||||
|
||||
// Active tool
|
||||
activeTool: ToolType;
|
||||
previousTool: ToolType | null;
|
||||
|
||||
// Brush/Eraser settings
|
||||
brushSize: number;
|
||||
brushOpacity: number;
|
||||
brushHardness: number;
|
||||
|
||||
// Colors
|
||||
foregroundColor: string;
|
||||
backgroundColor: string;
|
||||
recentColors: string[];
|
||||
|
||||
// Layers
|
||||
layers: EditorLayer[];
|
||||
activeLayerId: string;
|
||||
|
||||
// Objects
|
||||
objects: CanvasObject[];
|
||||
selectedObjectIds: string[];
|
||||
|
||||
// Selection
|
||||
selection: SelectionState | null;
|
||||
|
||||
// Crop
|
||||
cropState: CropState | null;
|
||||
isCropping: boolean;
|
||||
|
||||
// Adjustments & Filters
|
||||
adjustments: AdjustmentValues;
|
||||
filters: FilterConfig[];
|
||||
|
||||
// Text
|
||||
editingTextId: string | null;
|
||||
|
||||
// Shape settings
|
||||
shapeFill: string;
|
||||
shapeStroke: string;
|
||||
shapeStrokeWidth: number;
|
||||
shapeCornerRadius: number;
|
||||
shapePolygonSides: number;
|
||||
shapeStarPoints: number;
|
||||
|
||||
// Clone stamp
|
||||
cloneSource: CloneSource | null;
|
||||
|
||||
// Dodge/Burn/Sponge
|
||||
dodgeBurnRange: "shadows" | "midtones" | "highlights";
|
||||
dodgeBurnExposure: number;
|
||||
spongeMode: "saturate" | "desaturate";
|
||||
spongeFlow: number;
|
||||
|
||||
// UI
|
||||
rightPanelTab: "layers" | "adjustments" | "history";
|
||||
rightPanelVisible: boolean;
|
||||
isSpaceHeld: boolean;
|
||||
|
||||
// Document state
|
||||
isDirty: boolean;
|
||||
lastAutoSave: number | null;
|
||||
|
||||
// Clipboard
|
||||
clipboard: CanvasObject[] | null;
|
||||
|
||||
// Guides
|
||||
guides: Guide[];
|
||||
snappingEnabled: boolean;
|
||||
rulersVisible: boolean;
|
||||
guidesVisible: boolean;
|
||||
gridVisible: boolean;
|
||||
|
||||
// Loading
|
||||
loadingState: LoadingState | null;
|
||||
|
||||
// History tracking
|
||||
lastAction: string;
|
||||
_historyVersion: number;
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// Tool
|
||||
setTool: (tool: ToolType) => void;
|
||||
setCursorPosition: (pos: { x: number; y: number }) => void;
|
||||
|
||||
// Canvas
|
||||
setZoom: (zoom: number) => void;
|
||||
setPanOffset: (offset: { x: number; y: number }) => void;
|
||||
loadImage: (url: string, width: number, height: number) => void;
|
||||
resizeCanvas: (width: number, height: number, anchor: AnchorPosition) => void;
|
||||
resizeImage: (width: number, height: number) => void;
|
||||
rotateCanvas: (degrees: 90 | 180 | 270) => void;
|
||||
flipCanvasHorizontal: () => void;
|
||||
flipCanvasVertical: () => void;
|
||||
trimCanvas: () => void;
|
||||
|
||||
// Colors
|
||||
setForegroundColor: (color: string) => void;
|
||||
setBackgroundColor: (color: string) => void;
|
||||
swapColors: () => void;
|
||||
resetColors: () => void;
|
||||
|
||||
// Objects
|
||||
addObject: (obj: CanvasObject) => void;
|
||||
updateObject: (id: string, attrs: Partial<CanvasObject["attrs"]>) => void;
|
||||
removeObjects: (ids: string[]) => void;
|
||||
setSelectedObjects: (ids: string[]) => void;
|
||||
bringToFront: (objectId: string) => void;
|
||||
bringForward: (objectId: string) => void;
|
||||
sendBackward: (objectId: string) => void;
|
||||
sendToBack: (objectId: string) => void;
|
||||
|
||||
// Layers
|
||||
addLayer: () => void;
|
||||
removeLayer: (id: string) => void;
|
||||
duplicateLayer: (id: string) => void;
|
||||
setActiveLayer: (id: string) => void;
|
||||
updateLayer: (id: string, updates: Partial<EditorLayer>) => void;
|
||||
reorderLayers: (fromIndex: number, toIndex: number) => void;
|
||||
mergeDown: (id: string) => void;
|
||||
flattenAll: () => void;
|
||||
|
||||
// Adjustments & Filters
|
||||
setAdjustment: (key: keyof AdjustmentValues, value: number) => void;
|
||||
resetAdjustments: () => void;
|
||||
toggleFilter: (type: string) => void;
|
||||
setFilterParam: (type: string, key: string, value: number) => void;
|
||||
|
||||
// Selection
|
||||
setSelection: (selection: SelectionState | null) => void;
|
||||
invertSelection: () => void;
|
||||
|
||||
// Crop
|
||||
setCropState: (state: CropState | null) => void;
|
||||
applyCrop: () => void;
|
||||
|
||||
// Brush
|
||||
setBrushSize: (size: number) => void;
|
||||
setBrushOpacity: (opacity: number) => void;
|
||||
setBrushHardness: (hardness: number) => void;
|
||||
|
||||
// Clipboard
|
||||
copyObjects: () => void;
|
||||
cutObjects: () => void;
|
||||
pasteObjects: () => void;
|
||||
pasteInPlace: () => void;
|
||||
|
||||
// Guides
|
||||
addGuide: (orientation: "horizontal" | "vertical", position: number) => void;
|
||||
removeGuide: (id: string) => void;
|
||||
updateGuide: (id: string, position: number) => void;
|
||||
toggleSnapping: () => void;
|
||||
toggleRulers: () => void;
|
||||
toggleGuides: () => void;
|
||||
toggleGrid: () => void;
|
||||
|
||||
// Document state
|
||||
markDirty: () => void;
|
||||
markClean: () => void;
|
||||
setLoadingState: (state: LoadingState | null) => void;
|
||||
|
||||
// Right panel
|
||||
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
|
||||
toggleRightPanel: () => void;
|
||||
}
|
||||
Generated
+125
@@ -278,6 +278,9 @@ importers:
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
konva:
|
||||
specifier: ^10
|
||||
version: 10.3.0
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
@@ -293,12 +296,21 @@ importers:
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4
|
||||
react-colorful:
|
||||
specifier: ^5
|
||||
version: 5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-hotkeys-hook:
|
||||
specifier: ^5
|
||||
version: 5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-image-crop:
|
||||
specifier: ^11.0.10
|
||||
version: 11.0.10(react@19.2.4)
|
||||
react-konva:
|
||||
specifier: ^19
|
||||
version: 19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-router-dom:
|
||||
specifier: ^7.1.0
|
||||
version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -308,6 +320,12 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.1
|
||||
use-image:
|
||||
specifier: ^1
|
||||
version: 1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
zundo:
|
||||
specifier: ^2
|
||||
version: 2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4))
|
||||
zustand:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
|
||||
@@ -3400,6 +3418,16 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react-reconciler@0.28.9':
|
||||
resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
|
||||
'@types/react-reconciler@0.33.0':
|
||||
resolution: {integrity: sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
@@ -4852,6 +4880,11 @@ packages:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
its-fine@2.0.0:
|
||||
resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==}
|
||||
peerDependencies:
|
||||
react: ^19.0.0
|
||||
|
||||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
@@ -4944,6 +4977,9 @@ packages:
|
||||
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
konva@10.3.0:
|
||||
resolution: {integrity: sha512-gt19K2gzY4lHbnkvsku7eSmB+A9PTS2jG4F9coBMsdjM1UKfJNxJbDbXVpeCW1wjEGRwBD3nBamcHnqJhAeKlg==}
|
||||
|
||||
lazystream@1.0.1:
|
||||
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
|
||||
engines: {node: '>= 0.6.3'}
|
||||
@@ -5884,11 +5920,23 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-colorful@5.6.1:
|
||||
resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.4
|
||||
|
||||
react-hotkeys-hook@5.3.2:
|
||||
resolution: {integrity: sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-image-crop@11.0.10:
|
||||
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
|
||||
peerDependencies:
|
||||
@@ -5897,6 +5945,19 @@ packages:
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-konva@19.2.3:
|
||||
resolution: {integrity: sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==}
|
||||
peerDependencies:
|
||||
konva: ^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0
|
||||
react: ^19.2.0
|
||||
react-dom: ^19.2.0
|
||||
|
||||
react-reconciler@0.33.0:
|
||||
resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
react-refresh@0.17.0:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -6608,6 +6669,12 @@ packages:
|
||||
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
use-image@1.1.4:
|
||||
resolution: {integrity: sha512-P+swhszzHHgEb2X2yQ+vQNPCq/8Ks3hyfdXAVN133pvnvK7UK++bUaZUa5E+A3S02Mw8xOCBr9O6CLhk2fjrWA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
utif@2.0.1:
|
||||
resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==}
|
||||
|
||||
@@ -6899,6 +6966,11 @@ packages:
|
||||
zod@4.3.6:
|
||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||
|
||||
zundo@2.3.0:
|
||||
resolution: {integrity: sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==}
|
||||
peerDependencies:
|
||||
zustand: ^4.3.0 || ^5.0.0
|
||||
|
||||
zustand@5.0.12:
|
||||
resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -9771,6 +9843,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-reconciler@0.28.9(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-reconciler@0.33.0(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react@19.2.14':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
@@ -11265,6 +11345,13 @@ snapshots:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/react-reconciler': 0.28.9(@types/react@19.2.14)
|
||||
react: 19.2.4
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
jackspeak@3.4.3:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@@ -11377,6 +11464,8 @@ snapshots:
|
||||
|
||||
kind-of@6.0.3: {}
|
||||
|
||||
konva@10.3.0: {}
|
||||
|
||||
lazystream@1.0.1:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
@@ -12329,17 +12418,44 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-colorful@5.6.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-hotkeys-hook@5.3.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-image-crop@11.0.10(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-konva@19.2.3(@types/react@19.2.14)(konva@10.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/react-reconciler': 0.33.0(@types/react@19.2.14)
|
||||
its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4)
|
||||
konva: 10.3.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-reconciler: 0.33.0(react@19.2.4)
|
||||
scheduler: 0.27.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
react-reconciler@0.33.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
@@ -13159,6 +13275,11 @@ snapshots:
|
||||
|
||||
url-join@5.0.0: {}
|
||||
|
||||
use-image@1.1.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
utif@2.0.1:
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
@@ -13507,6 +13628,10 @@ snapshots:
|
||||
|
||||
zod@4.3.6: {}
|
||||
|
||||
zundo@2.3.0(zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)):
|
||||
dependencies:
|
||||
zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
Reference in New Issue
Block a user