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

# Conflicts:
#	apps/web/src/components/editor/editor-right-panel.tsx
This commit is contained in:
SnapOtter
2026-05-06 23:29:25 +08:00
4 changed files with 2051 additions and 0 deletions
@@ -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>
);
}