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:
SnapOtter
2026-05-06 23:28:29 +08:00
parent c9b24ada78
commit 2ae97a6c88
19 changed files with 4398 additions and 1 deletions
@@ -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>
);
}