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:
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user