mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-a39efeb4' into feat/image-editor
# Conflicts: # apps/web/src/components/editor/editor-right-panel.tsx
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user