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