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(() => {