mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user