fix: resolve 41 bugs and wire 21 unimplemented features in image editor

Canvas rendering:
- Fix Konva filter application order (filters before cache)
- Implement 6 missing filters (motionBlur, radialBlur, surfaceBlur, vignette, grain, sharpen)
- Implement exposure, vibrance, warmth adjustments as custom Konva filters
- Apply layer blend modes via globalCompositeOperation
- Apply object effects (drop shadow, outer glow, stroke) to all shapes
- Mount SmartGuidesOverlay during move tool drag
- Clip pixel grid to visible viewport (200-line cap for performance)

Store logic:
- resizeImage now scales all objects proportionally (points, radii, fontSize)
- rotate/flip/trim handle line/arrow points arrays and center-based objects
- applyCrop creates cropped source image via offscreen canvas
- invertSelection creates mask from bounds when no mask exists
- cutObjects uses single atomic set() to prevent race conditions
- sendToBack respects layer ordering in multi-layer documents
- Add batchNudge() and commitHistory() for undoable nudge operations
- Add updateLayerThumbnail() method

Tool hooks:
- Fix clone stamp/dodge/burn perf (toDataURL only on mouseUp, not every move)
- Fix magic wand zoom/pixelRatio with explicit stage.toCanvas() viewport
- Fix eyedropper sampling with unzoomed canvas export
- Fix selection tool stale closure via isDrawingRef
- Implement polygonal lasso (click-to-place vertices, double-click to close)
- Implement selection subtract mode (geometric and mask-based)
- Implement gradient live preview during drag
- Fix transform/move tool to persist changes and handle ellipse/polygon/star

UI wiring:
- Mount rulers and guidelines in editor page
- Wire histogram with live canvas imageData
- Wire autosave recovery with blob-to-dataURL conversion
- Wire fill dialog to Shift+Backspace shortcut
- Wire eyedropper and transform options to options bar
- Fix history panel undo/redo button reactive state via useSyncExternalStore
- Fix layer row name click to select layer (timer-based click/dblclick)
- Fix zoom animation coordinate drift with progressive store sync
- Fix copy merged to use Konva stage composite export

Tests:
- 49 new unit tests (store fixes + konva filters)
- 8 new E2E test files with 39 test cases
This commit is contained in:
SnapOtter
2026-05-08 16:43:27 +08:00
parent 3a2b1ee105
commit dd73a8a50a
30 changed files with 3404 additions and 203 deletions
@@ -3,6 +3,7 @@
import { Wand2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SliderRow } from "@/components/editor/common/slider-row";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { HistogramPanel } from "@/components/editor/panels/histogram-panel";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -1035,6 +1036,36 @@ export function AdjustmentsPanel() {
const adjustments = useEditorStore((s) => s.adjustments);
const filters = useEditorStore((s) => s.filters);
const resetAdjustments = useEditorStore((s) => s.resetAdjustments);
const canvasSize = useEditorStore((s) => s.canvasSize);
// Capture imageData from the Konva stage for the histogram
const [histogramData, setHistogramData] = useState<ImageData | null>(null);
useEffect(() => {
function captureImageData() {
const stage = editorStageRefHolder.current;
if (!stage) return;
try {
const canvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const ctx = canvas.getContext("2d");
if (!ctx) return;
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
setHistogramData(data);
} catch {
// Stage may not be ready yet
}
}
// Capture on mount and when adjustments/filters change
const timer = setTimeout(captureImageData, 100);
return () => clearTimeout(timer);
}, [adjustments, filters, canvasSize]);
const hasChanges = useMemo(() => {
const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0);
@@ -1079,7 +1110,7 @@ export function AdjustmentsPanel() {
return (
<div className="flex flex-col gap-2 text-sm">
{/* Histogram */}
<HistogramPanel />
<HistogramPanel imageData={histogramData} />
{/* Auto Adjustments */}
<SectionHeader title="Auto" />
@@ -21,7 +21,7 @@ import {
Type,
Undo2,
} from "lucide-react";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
@@ -85,6 +85,18 @@ export function HistoryPanel() {
// Force re-render when history changes by subscribing to history version
useEditorStore((s) => s._historyVersion);
// Subscribe reactively to temporal state for undo/redo button disabled states
const pastLength = useSyncExternalStore(
(cb) => useEditorStore.temporal.subscribe(cb),
() => useEditorStore.temporal.getState().pastStates.length,
);
const futureLength = useSyncExternalStore(
(cb) => useEditorStore.temporal.subscribe(cb),
() => useEditorStore.temporal.getState().futureStates.length,
);
const canUndo = pastLength > 0;
const canRedo = futureLength > 0;
const undo = useCallback(() => {
useEditorStore.temporal.getState().undo();
}, []);
@@ -151,10 +163,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={undo}
disabled={useEditorStore.temporal.getState().pastStates.length === 0}
disabled={!canUndo}
className={cn(
"p-1 rounded transition-colors",
useEditorStore.temporal.getState().pastStates.length > 0
canUndo
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -166,10 +178,10 @@ export function HistoryPanel() {
<button
type="button"
onClick={redo}
disabled={useEditorStore.temporal.getState().futureStates.length === 0}
disabled={!canRedo}
className={cn(
"p-1 rounded transition-colors",
useEditorStore.temporal.getState().futureStates.length > 0
canRedo
? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed",
)}
@@ -178,9 +190,7 @@ export function HistoryPanel() {
>
<Redo2 size={14} />
</button>
<span className="ml-auto text-[10px] text-muted-foreground">
{useEditorStore.temporal.getState().pastStates.length} / 50
</span>
<span className="ml-auto text-[10px] text-muted-foreground">{pastLength} / 50</span>
</div>
{/* History list */}
@@ -369,9 +369,31 @@ function LayerRow({
setEditing(false);
}, [editName, layer.name, onRename]);
// Timer-based single-click vs double-click differentiation for the name button
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleNameClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (clickTimerRef.current) {
// Second click within threshold: enter rename mode
clearTimeout(clickTimerRef.current);
clickTimerRef.current = null;
setEditing(true);
} else {
// First click: start timer; if no second click, select the layer
clickTimerRef.current = setTimeout(() => {
clickTimerRef.current = null;
onSelect();
}, 250);
}
},
[onSelect],
);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setEditing(true);
// Handled by the timer-based approach in handleNameClick
}, []);
const handleKeyDown = useCallback(
@@ -526,6 +548,7 @@ function LayerRow({
"block text-xs truncate text-left bg-transparent border-0 p-0 w-full cursor-pointer",
isActive ? "text-foreground font-medium" : "text-muted-foreground",
)}
onClick={handleNameClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`layer-name-${layer.id}`}