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
+11 -4
View File
@@ -26,10 +26,6 @@ export function useCanvasZoom() {
if (tweenRef.current) {
tweenRef.current.destroy();
}
// Update store immediately so all tools get correct coordinates
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current = new Konva.Tween({
node: stage,
scaleX: targetZoom,
@@ -38,7 +34,18 @@ export function useCanvasZoom() {
y: targetPos.y,
duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut,
onUpdate: () => {
// Progressively sync store with the stage's current animated values
// so tools always have accurate coordinates during the tween.
if (stage) {
setZoom(stage.scaleX());
setPanOffset({ x: stage.x(), y: stage.y() });
}
},
onFinish: () => {
// Ensure final values are exact (no floating-point drift)
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current?.destroy();
tweenRef.current = null;
},
+43 -19
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
@@ -55,7 +56,11 @@ function cycleSubtool(current: ToolType, cycle: ToolType[]): ToolType {
*
* @param callbacks Optional callbacks for save/export dialogs
*/
export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?: () => void }) {
export function useEditorShortcuts(callbacks?: {
onSave?: () => void;
onExport?: () => void;
onFillDialog?: () => void;
}) {
const previousToolRef = useRef<ToolType | null>(null);
const isSpaceHeldRef = useRef(false);
@@ -435,24 +440,34 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
{ preventDefault: false },
);
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged
// Ctrl+Shift+C / Cmd+Shift+C - Copy merged (use Konva stage ref for proper composite)
useHotkeys(
"mod+shift+c",
(e) => {
e.preventDefault();
// Export visible layers to clipboard as PNG
const stageCanvas = document.querySelector(
"[data-testid='editor-canvas'] canvas",
) as HTMLCanvasElement | null;
if (!stageCanvas) return;
stageCanvas.toBlob(async (blob) => {
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch {
// Clipboard API not available
}
}, "image/png");
const stage = editorStageRefHolder.current;
if (!stage) return;
const { canvasSize } = useEditorStore.getState();
const dataUrl = stage.toDataURL({
pixelRatio: 1,
mimeType: "image/png",
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
fetch(dataUrl)
.then((res) => res.blob())
.then(async (blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch {
// Clipboard API not available
}
})
.catch(() => {
// Export failed silently
});
},
{ preventDefault: true },
);
@@ -806,15 +821,18 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
{ preventDefault: true },
);
// Shift+Backspace - Fill dialog (trigger callback or use fill tool)
// Shift+Backspace - Open fill dialog
useHotkeys(
"shift+backspace",
(e) => {
if (isInputFocused()) return;
e.preventDefault();
// Fill dialog would be handled by Agent 1's fill-dialog component
// For now, switch to fill tool as a fallback
useEditorStore.getState().setTool("fill");
if (callbacks?.onFillDialog) {
callbacks.onFillDialog();
} else {
// Fallback: dispatch custom event for FillDialog listener
window.dispatchEvent(new CustomEvent("snapotter:open-fill-dialog"));
}
},
{ preventDefault: true },
);
@@ -863,6 +881,7 @@ export function useEditorShortcuts(callbacks?: { onSave?: () => void; onExport?:
/** Nudge all selected objects by (dx, dy) pixels. */
function nudgeSelected(dx: number, dy: number): void {
const state = useEditorStore.getState();
if (state.selectedObjectIds.length === 0) return;
for (const id of state.selectedObjectIds) {
const obj = state.objects.find((o) => o.id === id);
if (!obj) continue;
@@ -874,4 +893,9 @@ function nudgeSelected(dx: number, dy: number): void {
});
}
}
// Create a history entry so the nudge is undoable
useEditorStore.setState((s) => ({
_historyVersion: s._historyVersion + 1,
lastAction: "Nudge",
}));
}