fix: resolve 15 critical/high/medium issues from self-review

CRITICAL fixes:
- #14: Render source image on canvas via Konva Image + use-image hook
- #3: Wire move tool handlers (onClick, onDragEnd, onTransformEnd, draggable)
  to all CanvasObjectRenderer shapes
- #4: Implement image object rendering for fill/gradient output
- #2: Show fallback text in histogram panel when no imageData provided
- #1: Forward all args in zundo handleSet debounce wrapper

HIGH fixes:
- #5: Track raw screen cursor position for brush overlay instead of
  using canvas-space coordinates
- #6: Export dialog uses Konva stage.toDataURL via module-level ref
  instead of DOM querySelector for correct export at any zoom/pan
- #7: Add _historyVersion increment to setAdjustment, resetAdjustments,
  toggleFilter, and setFilterParam for undo tracking
- #8: Include lastAction in partialize so history labels display correctly

MEDIUM fixes:
- #10: Move useEditorShortcuts from EditorCanvas to EditorPage with
  save/export callbacks
- #11: Remove _historyVersion increment from updateObject to prevent
  brush strokes from flooding undo history
- #12: Apply Konva filters (Brighten, Contrast, HSL, Blur, Grayscale,
  Sepia, Invert, Pixelate, Emboss, Posterize, Noise, Solarize,
  Threshold, Kaleidoscope) to source image node based on store state
This commit is contained in:
SnapOtter
2026-05-07 10:00:12 +08:00
parent e11e3660d9
commit 3cc4ec87d7
6 changed files with 340 additions and 94 deletions
@@ -57,13 +57,13 @@ export function useEditorCursor(): string {
interface BrushCursorOverlayProps {
containerRef: React.RefObject<HTMLDivElement | null>;
screenCursor: { x: number; y: number };
}
export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorOverlayProps) {
export function BrushCursorOverlay({ screenCursor }: BrushCursorOverlayProps) {
const activeTool = useEditorStore((s) => s.activeTool);
const brushSize = useEditorStore((s) => s.brushSize);
const zoom = useEditorStore((s) => s.zoom);
const cursorPosition = useEditorStore((s) => s.cursorPosition);
if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null;
@@ -74,8 +74,8 @@ export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorO
<div
className="pointer-events-none absolute z-50"
style={{
left: cursorPosition.x - displaySize / 2,
top: cursorPosition.y - displaySize / 2,
left: screenCursor.x - displaySize / 2,
top: screenCursor.y - displaySize / 2,
width: displaySize,
height: displaySize,
borderRadius: "50%",
@@ -12,6 +12,7 @@ import {
X,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type {
@@ -68,33 +69,25 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
const aspectRatio = canvasSize.width / canvasSize.height;
const dialogRef = useRef<HTMLDivElement>(null);
// Issue #6: Use Konva stage ref for proper export instead of DOM query
const generatePreview = useCallback(() => {
// Create a thumbnail canvas for preview
const canvas = document.createElement("canvas");
const stage = editorStageRefHolder.current;
if (!stage) return;
const maxPreview = 200;
const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height);
canvas.width = Math.round(canvasSize.width * scale);
canvas.height = Math.round(canvasSize.height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!settings.transparent || settings.format === "jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// Try to draw from the Konva stage if available
const stageCanvas = document.querySelector(
"[data-testid='editor-canvas'] canvas",
) as HTMLCanvasElement | null;
if (stageCanvas) {
ctx.drawImage(stageCanvas, 0, 0, canvas.width, canvas.height);
}
const url = canvas.toDataURL(getMimeType(settings.format), settings.quality / 100);
const url = stage.toDataURL({
pixelRatio: scale,
mimeType: getMimeType(settings.format),
quality: settings.quality / 100,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
setPreviewUrl(url);
}, [canvasSize, settings.format, settings.quality, settings.transparent]);
}, [canvasSize, settings.format, settings.quality]);
// Generate preview thumbnail on format/transparency change
useEffect(() => {
@@ -135,30 +128,26 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
[settings.lockAspect, aspectRatio],
);
// Export as file download
// Issue #6: Export using Konva stage.toDataURL for correct output
const handleExport = useCallback(() => {
const stageCanvas = document.querySelector(
"[data-testid='editor-canvas'] canvas",
) as HTMLCanvasElement | null;
if (!stageCanvas) return;
const stage = editorStageRefHolder.current;
if (!stage) return;
// Create export canvas at the requested dimensions
const exportCanvas = document.createElement("canvas");
exportCanvas.width = settings.width;
exportCanvas.height = settings.height;
const ctx = exportCanvas.getContext("2d");
if (!ctx) return;
const pixelRatio = settings.width / canvasSize.width;
const dataUrl = stage.toDataURL({
pixelRatio,
mimeType: getMimeType(settings.format),
quality: settings.format === "png" ? undefined : settings.quality / 100,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
if (!settings.transparent || settings.format === "jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
exportCanvas.toBlob(
(blob) => {
if (!blob) return;
// Convert data URL to blob for download
fetch(dataUrl)
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
@@ -168,44 +157,34 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
document.body.removeChild(a);
URL.revokeObjectURL(url);
markClean();
},
getMimeType(settings.format),
settings.format === "png" ? undefined : settings.quality / 100,
);
}, [settings, markClean]);
});
}, [settings, canvasSize, markClean]);
// Copy to clipboard
// Issue #6: Copy to clipboard using Konva stage
const handleCopyToClipboard = useCallback(async () => {
const stageCanvas = document.querySelector(
"[data-testid='editor-canvas'] canvas",
) as HTMLCanvasElement | null;
if (!stageCanvas) return;
const stage = editorStageRefHolder.current;
if (!stage) return;
const exportCanvas = document.createElement("canvas");
exportCanvas.width = settings.width;
exportCanvas.height = settings.height;
const ctx = exportCanvas.getContext("2d");
if (!ctx) return;
if (!settings.transparent || settings.format === "jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
}
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
const pixelRatio = settings.width / canvasSize.width;
const dataUrl = stage.toDataURL({
pixelRatio,
mimeType: "image/png",
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
try {
const blob = await new Promise<Blob | null>((resolve) =>
exportCanvas.toBlob(resolve, "image/png"),
);
if (!blob) return;
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
setCopyStatus("copied");
setTimeout(() => setCopyStatus("idle"), 2000);
} catch {
// Clipboard API may not be available in all contexts
}
}, [settings]);
}, [settings, canvasSize]);
// Project save (.snapotter file)
const handleSaveProject = useCallback(() => {
+245 -15
View File
@@ -1,12 +1,14 @@
// apps/web/src/components/editor/editor-canvas.tsx
import type Konva from "konva";
import KonvaFilters from "konva";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Arrow,
Ellipse,
Group,
Image as KonvaImage,
Layer,
Line,
Rect,
@@ -16,10 +18,10 @@ import {
Star,
Text,
} from "react-konva";
import useImage from "use-image";
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
import { LoadingOverlay } from "./common/loading-overlay";
import { useBrushTool } from "./tools/brush-tool";
@@ -29,6 +31,11 @@ import { useGradientTool } from "./tools/gradient-tool";
import { MoveToolTransformer, useMoveTool } from "./tools/move-tool";
import { useShapeTool } from "./tools/shape-tool";
// Module-level stage ref for export dialog access (Issue #6)
export const editorStageRefHolder: { current: Konva.Stage | null } = {
current: null,
};
const CHECKERBOARD_SIZE = 20;
const CHECKERBOARD_CSS = `
repeating-conic-gradient(
@@ -38,10 +45,170 @@ const CHECKERBOARD_CSS = `
`;
// ---------------------------------------------------------------------------
// Canvas Object Renderer
// Source Image Component (Issue #14)
// ---------------------------------------------------------------------------
function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
function SourceImage({
url,
adjustments,
filters,
}: {
url: string;
adjustments: AdjustmentValues;
filters: FilterConfig[];
}) {
const [image] = useImage(url);
const imageRef = useRef<Konva.Image>(null);
// Issue #12: Apply adjustments/filters to the source image node
const hasActiveAdjustments = Object.values(adjustments).some((v) => v !== 0);
const hasActiveFilters = filters.some((f) => f.enabled);
useEffect(() => {
const node = imageRef.current;
if (!node || !image) return;
if (hasActiveAdjustments || hasActiveFilters) {
const konvaFilters: Array<((this: Konva.Node, imageData: ImageData) => void) | string> = [];
if (adjustments.brightness !== 0) {
konvaFilters.push(KonvaFilters.Filters.Brighten);
node.brightness(adjustments.brightness / 100);
}
if (adjustments.contrast !== 0) {
konvaFilters.push(KonvaFilters.Filters.Contrast);
node.contrast(adjustments.contrast);
}
if (adjustments.hue !== 0 || adjustments.saturation !== 0 || adjustments.luminance !== 0) {
konvaFilters.push(KonvaFilters.Filters.HSL);
node.hue(adjustments.hue);
node.saturation(adjustments.saturation / 100);
node.luminance(adjustments.luminance / 100);
}
// Apply enabled filters
for (const f of filters) {
if (!f.enabled) continue;
switch (f.type) {
case "blur":
konvaFilters.push(KonvaFilters.Filters.Blur);
node.blurRadius(f.params.radius ?? 0);
break;
case "grayscale":
konvaFilters.push(KonvaFilters.Filters.Grayscale);
break;
case "sepia":
konvaFilters.push(KonvaFilters.Filters.Sepia);
break;
case "invert":
konvaFilters.push(KonvaFilters.Filters.Invert);
break;
case "pixelate":
konvaFilters.push(KonvaFilters.Filters.Pixelate);
node.pixelSize(f.params.size ?? 1);
break;
case "emboss":
konvaFilters.push(KonvaFilters.Filters.Emboss);
node.embossStrength(f.params.strength ?? 0);
node.embossWhiteLevel(0.5);
node.embossBlend(true);
break;
case "posterize":
konvaFilters.push(KonvaFilters.Filters.Posterize);
node.levels(f.params.levels ?? 8);
break;
case "noise":
konvaFilters.push(KonvaFilters.Filters.Noise);
node.noise(f.params.amount ?? 0);
break;
case "solarize":
konvaFilters.push(KonvaFilters.Filters.Solarize);
break;
case "threshold":
konvaFilters.push(KonvaFilters.Filters.Threshold);
node.threshold((f.params.level ?? 0.5) * 255);
break;
case "kaleidoscope":
konvaFilters.push(KonvaFilters.Filters.Kaleidoscope);
node.kaleidoscopePower(f.params.power ?? 2);
node.kaleidoscopeAngle(f.params.angle ?? 0);
break;
}
}
node.cache();
node.filters(konvaFilters);
node.getLayer()?.batchDraw();
} else {
node.clearCache();
node.filters([]);
node.getLayer()?.batchDraw();
}
}, [image, adjustments, filters, hasActiveAdjustments, hasActiveFilters]);
if (!image) return null;
return <KonvaImage ref={imageRef} image={image} x={0} y={0} listening={false} />;
}
// ---------------------------------------------------------------------------
// Image Object Component (Issue #4)
// ---------------------------------------------------------------------------
function ImageObject({
obj,
onClick,
onDragEnd,
onTransformEnd,
draggable,
}: {
obj: CanvasObject & { type: "image" };
onClick?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
draggable: boolean;
}) {
const a = obj.attrs as ImageAttrs;
const [image] = useImage(a.src);
if (!image) return null;
return (
<KonvaImage
id={obj.id}
image={image}
x={a.x}
y={a.y}
width={a.width}
height={a.height}
rotation={a.rotation}
opacity={a.opacity}
draggable={draggable}
onClick={onClick}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
// ---------------------------------------------------------------------------
// Canvas Object Renderer (Issue #3: wire move tool handlers)
// ---------------------------------------------------------------------------
function CanvasObjectRenderer({
obj,
isMoveTool,
onSelect,
onDragEnd,
onTransformEnd,
}: {
obj: CanvasObject;
isMoveTool: boolean;
onSelect?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
}) {
const draggable = isMoveTool;
switch (obj.type) {
case "line": {
const a = obj.attrs;
@@ -80,7 +247,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
cornerRadius={a.cornerRadius}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
@@ -98,7 +268,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
@@ -122,7 +295,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
height={a.height}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
@@ -139,7 +315,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
pointerWidth={a.pointerWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
@@ -157,7 +336,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
@@ -176,13 +358,23 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
strokeWidth={a.strokeWidth}
rotation={a.rotation}
opacity={a.opacity}
draggable
draggable={draggable}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
/>
);
}
case "image":
// Skip image objects for now (complex: requires Konva.Image with useImage)
return null;
return (
<ImageObject
obj={obj}
onClick={onSelect}
onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd}
draggable={draggable}
/>
);
default:
return null;
}
@@ -248,15 +440,28 @@ export function EditorCanvas() {
const layers = useEditorStore((s) => s.layers);
const activeLayerId = useEditorStore((s) => s.activeLayerId);
const activeTool = useEditorStore((s) => s.activeTool);
const adjustments = useEditorStore((s) => s.adjustments);
const filters = useEditorStore((s) => s.filters);
// Issue #10: Shortcuts moved to EditorPage, removed from here
const cursor = useEditorCursor();
useEditorShortcuts();
const { handlers, moveTool } = useActiveToolHandlers(stageRef);
const [stageWidth, setStageWidth] = useState(800);
const [stageHeight, setStageHeight] = useState(600);
// Issue #6: Keep module-level stage ref in sync
useEffect(() => {
editorStageRefHolder.current = stageRef.current;
return () => {
editorStageRefHolder.current = null;
};
});
// Issue #5: Track raw screen cursor position for brush overlay
const [screenCursor, setScreenCursor] = useState({ x: 0, y: 0 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@@ -291,6 +496,8 @@ export function EditorCanvas() {
return grouped;
}, [objects, layers]);
const isMoveTool = activeTool === "move";
const handleMouseMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
@@ -332,9 +539,19 @@ export function EditorCanvas() {
[handlers],
);
// Issue #5: Track screen-space cursor for brush overlay
const handleContainerMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setScreenCursor({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}, []);
const checkerboardSize = CHECKERBOARD_SIZE * zoom;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: canvas container tracks cursor for brush overlay
<div
ref={containerRef}
className="relative flex-1 overflow-hidden"
@@ -344,6 +561,7 @@ export function EditorCanvas() {
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
}}
data-testid="editor-canvas"
onMouseMove={handleContainerMouseMove}
>
<Stage
ref={stageRef}
@@ -360,13 +578,25 @@ export function EditorCanvas() {
>
{/* Render objects grouped by layer */}
<Layer>
{/* Issue #14: Render source image as background */}
{sourceImageUrl && (
<SourceImage url={sourceImageUrl} adjustments={adjustments} filters={filters} />
)}
{layers.map((layer) => {
const layerObjects = objectsByLayer.get(layer.id) ?? [];
if (!layer.visible) return null;
return (
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
{layerObjects.map((obj) => (
<CanvasObjectRenderer key={obj.id} obj={obj} />
<CanvasObjectRenderer
key={obj.id}
obj={obj}
isMoveTool={isMoveTool}
onSelect={isMoveTool ? moveTool.onSelect : undefined}
onDragEnd={isMoveTool ? moveTool.onDragEnd : undefined}
onTransformEnd={isMoveTool ? moveTool.onTransformEnd : undefined}
/>
))}
</Group>
);
@@ -391,7 +621,7 @@ export function EditorCanvas() {
</Layer>
)}
</Stage>
<BrushCursorOverlay containerRef={containerRef} />
<BrushCursorOverlay containerRef={containerRef} screenCursor={screenCursor} />
<LoadingOverlay />
</div>
);
@@ -116,6 +116,7 @@ interface HistogramPanelProps {
imageData?: ImageData | null;
}
// Issue #2: When no imageData is provided, show a placeholder message
export function HistogramPanel({ imageData }: HistogramPanelProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [stats, setStats] = useState<HistogramStats | null>(null);
@@ -176,6 +177,11 @@ export function HistogramPanel({ imageData }: HistogramPanelProps) {
className="w-full rounded border border-border"
style={{ imageRendering: "pixelated" }}
/>
{!imageData && !stats && (
<p className="text-[10px] text-muted-foreground text-center py-1">
No histogram data available
</p>
)}
{stats && (
<div className="grid grid-cols-3 gap-1 text-[10px] text-muted-foreground">
<div>
+11 -1
View File
@@ -1,12 +1,14 @@
// apps/web/src/pages/editor-page.tsx
import { Monitor } from "lucide-react";
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog";
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas";
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
import { EditorToolbar } from "@/components/editor/editor-toolbar";
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/stores/editor-store";
@@ -15,6 +17,13 @@ export function EditorPage() {
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage);
const [showExport, setShowExport] = useState(false);
// Issue #10: Shortcuts belong at page level, not canvas level
useEditorShortcuts({
onSave: () => saveEditorState(),
onExport: () => setShowExport(true),
});
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
@@ -87,6 +96,7 @@ export function EditorPage() {
<EditorRightPanel />
</div>
<EditorStatusBar />
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
</div>
);
}
+25 -4
View File
@@ -329,13 +329,15 @@ export const useEditorStore = create<EditorState>()(
});
},
// Issue #11: Don't increment _historyVersion here -- updateObject is called
// on every mousemove during brush strokes for live preview. History is only
// recorded when addObject finalizes the stroke.
updateObject: (id, attrs) => {
set({
objects: get().objects.map((obj) =>
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
),
isDirty: true,
_historyVersion: get()._historyVersion + 1,
});
},
@@ -524,6 +526,7 @@ export const useEditorStore = create<EditorState>()(
},
// Adjustments
// Issue #7: Include _historyVersion increment for undo tracking
setAdjustment: (key, value) => {
const clamps: Record<string, [number, number]> = {
brightness: [-100, 100],
@@ -541,14 +544,26 @@ export const useEditorStore = create<EditorState>()(
...get().adjustments,
[key]: Math.max(min, Math.min(max, value)),
},
isDirty: true,
lastAction: `Adjust ${key.charAt(0).toUpperCase() + key.slice(1)}`,
_historyVersion: get()._historyVersion + 1,
});
},
resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }),
resetAdjustments: () =>
set({
adjustments: { ...DEFAULT_ADJUSTMENTS },
isDirty: true,
lastAction: "Reset Adjustments",
_historyVersion: get()._historyVersion + 1,
}),
toggleFilter: (type) => {
set({
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
isDirty: true,
lastAction: `Toggle ${type.charAt(0).toUpperCase() + type.slice(1)} Filter`,
_historyVersion: get()._historyVersion + 1,
});
},
@@ -557,6 +572,9 @@ export const useEditorStore = create<EditorState>()(
filters: get().filters.map((f) =>
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
),
isDirty: true,
lastAction: `Set ${type} ${key}`,
_historyVersion: get()._historyVersion + 1,
});
},
@@ -697,6 +715,7 @@ export const useEditorStore = create<EditorState>()(
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
}),
{
// Issue #8: Include lastAction in partialize so history labels work
partialize: (state) => ({
layers: state.layers,
objects: state.objects,
@@ -706,17 +725,19 @@ export const useEditorStore = create<EditorState>()(
guides: state.guides,
sourceImageUrl: state.sourceImageUrl,
sourceImageSize: state.sourceImageSize,
lastAction: state.lastAction,
_historyVersion: state._historyVersion,
}),
limit: MAX_HISTORY,
equality: (a, b) =>
(a as { _historyVersion: number })._historyVersion ===
(b as { _historyVersion: number })._historyVersion,
// Issue #1: Forward all arguments from zundo's internal _handleSet
handleSet: (handleSet) => {
let timeout: ReturnType<typeof setTimeout>;
return (state) => {
return (...args: Parameters<typeof handleSet>) => {
clearTimeout(timeout);
timeout = setTimeout(() => handleSet(state), 500);
timeout = setTimeout(() => handleSet(...args), 500);
};
},
},