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 {
|
interface BrushCursorOverlayProps {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
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 activeTool = useEditorStore((s) => s.activeTool);
|
||||||
const brushSize = useEditorStore((s) => s.brushSize);
|
const brushSize = useEditorStore((s) => s.brushSize);
|
||||||
const zoom = useEditorStore((s) => s.zoom);
|
const zoom = useEditorStore((s) => s.zoom);
|
||||||
const cursorPosition = useEditorStore((s) => s.cursorPosition);
|
|
||||||
|
|
||||||
if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null;
|
if (!BRUSH_CURSOR_TOOLS.has(activeTool)) return null;
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ export function BrushCursorOverlay({ containerRef: _containerRef }: BrushCursorO
|
|||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute z-50"
|
className="pointer-events-none absolute z-50"
|
||||||
style={{
|
style={{
|
||||||
left: cursorPosition.x - displaySize / 2,
|
left: screenCursor.x - displaySize / 2,
|
||||||
top: cursorPosition.y - displaySize / 2,
|
top: screenCursor.y - displaySize / 2,
|
||||||
width: displaySize,
|
width: displaySize,
|
||||||
height: displaySize,
|
height: displaySize,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { editorStageRefHolder } from "@/components/editor/editor-canvas";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
import type {
|
import type {
|
||||||
@@ -68,33 +69,25 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
const aspectRatio = canvasSize.width / canvasSize.height;
|
const aspectRatio = canvasSize.width / canvasSize.height;
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Issue #6: Use Konva stage ref for proper export instead of DOM query
|
||||||
const generatePreview = useCallback(() => {
|
const generatePreview = useCallback(() => {
|
||||||
// Create a thumbnail canvas for preview
|
const stage = editorStageRefHolder.current;
|
||||||
const canvas = document.createElement("canvas");
|
if (!stage) return;
|
||||||
|
|
||||||
const maxPreview = 200;
|
const maxPreview = 200;
|
||||||
const scale = Math.min(maxPreview / canvasSize.width, maxPreview / canvasSize.height);
|
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");
|
const url = stage.toDataURL({
|
||||||
if (!ctx) return;
|
pixelRatio: scale,
|
||||||
|
mimeType: getMimeType(settings.format),
|
||||||
if (!settings.transparent || settings.format === "jpeg") {
|
quality: settings.quality / 100,
|
||||||
ctx.fillStyle = "#ffffff";
|
x: 0,
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
y: 0,
|
||||||
}
|
width: canvasSize.width,
|
||||||
|
height: canvasSize.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);
|
|
||||||
setPreviewUrl(url);
|
setPreviewUrl(url);
|
||||||
}, [canvasSize, settings.format, settings.quality, settings.transparent]);
|
}, [canvasSize, settings.format, settings.quality]);
|
||||||
|
|
||||||
// Generate preview thumbnail on format/transparency change
|
// Generate preview thumbnail on format/transparency change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,30 +128,26 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
[settings.lockAspect, aspectRatio],
|
[settings.lockAspect, aspectRatio],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Export as file download
|
// Issue #6: Export using Konva stage.toDataURL for correct output
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
const stageCanvas = document.querySelector(
|
const stage = editorStageRefHolder.current;
|
||||||
"[data-testid='editor-canvas'] canvas",
|
if (!stage) return;
|
||||||
) as HTMLCanvasElement | null;
|
|
||||||
if (!stageCanvas) return;
|
|
||||||
|
|
||||||
// Create export canvas at the requested dimensions
|
const pixelRatio = settings.width / canvasSize.width;
|
||||||
const exportCanvas = document.createElement("canvas");
|
const dataUrl = stage.toDataURL({
|
||||||
exportCanvas.width = settings.width;
|
pixelRatio,
|
||||||
exportCanvas.height = settings.height;
|
mimeType: getMimeType(settings.format),
|
||||||
const ctx = exportCanvas.getContext("2d");
|
quality: settings.format === "png" ? undefined : settings.quality / 100,
|
||||||
if (!ctx) return;
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: canvasSize.width,
|
||||||
|
height: canvasSize.height,
|
||||||
|
});
|
||||||
|
|
||||||
if (!settings.transparent || settings.format === "jpeg") {
|
// Convert data URL to blob for download
|
||||||
ctx.fillStyle = "#ffffff";
|
fetch(dataUrl)
|
||||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
.then((res) => res.blob())
|
||||||
}
|
.then((blob) => {
|
||||||
|
|
||||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
|
||||||
|
|
||||||
exportCanvas.toBlob(
|
|
||||||
(blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -168,44 +157,34 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
markClean();
|
markClean();
|
||||||
},
|
});
|
||||||
getMimeType(settings.format),
|
}, [settings, canvasSize, markClean]);
|
||||||
settings.format === "png" ? undefined : settings.quality / 100,
|
|
||||||
);
|
|
||||||
}, [settings, markClean]);
|
|
||||||
|
|
||||||
// Copy to clipboard
|
// Issue #6: Copy to clipboard using Konva stage
|
||||||
const handleCopyToClipboard = useCallback(async () => {
|
const handleCopyToClipboard = useCallback(async () => {
|
||||||
const stageCanvas = document.querySelector(
|
const stage = editorStageRefHolder.current;
|
||||||
"[data-testid='editor-canvas'] canvas",
|
if (!stage) return;
|
||||||
) as HTMLCanvasElement | null;
|
|
||||||
if (!stageCanvas) return;
|
|
||||||
|
|
||||||
const exportCanvas = document.createElement("canvas");
|
const pixelRatio = settings.width / canvasSize.width;
|
||||||
exportCanvas.width = settings.width;
|
const dataUrl = stage.toDataURL({
|
||||||
exportCanvas.height = settings.height;
|
pixelRatio,
|
||||||
const ctx = exportCanvas.getContext("2d");
|
mimeType: "image/png",
|
||||||
if (!ctx) return;
|
x: 0,
|
||||||
|
y: 0,
|
||||||
if (!settings.transparent || settings.format === "jpeg") {
|
width: canvasSize.width,
|
||||||
ctx.fillStyle = "#ffffff";
|
height: canvasSize.height,
|
||||||
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
ctx.drawImage(stageCanvas, 0, 0, settings.width, settings.height);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await new Promise<Blob | null>((resolve) =>
|
const res = await fetch(dataUrl);
|
||||||
exportCanvas.toBlob(resolve, "image/png"),
|
const blob = await res.blob();
|
||||||
);
|
|
||||||
if (!blob) return;
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||||
setCopyStatus("copied");
|
setCopyStatus("copied");
|
||||||
setTimeout(() => setCopyStatus("idle"), 2000);
|
setTimeout(() => setCopyStatus("idle"), 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API may not be available in all contexts
|
// Clipboard API may not be available in all contexts
|
||||||
}
|
}
|
||||||
}, [settings]);
|
}, [settings, canvasSize]);
|
||||||
|
|
||||||
// Project save (.snapotter file)
|
// Project save (.snapotter file)
|
||||||
const handleSaveProject = useCallback(() => {
|
const handleSaveProject = useCallback(() => {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// apps/web/src/components/editor/editor-canvas.tsx
|
// apps/web/src/components/editor/editor-canvas.tsx
|
||||||
|
|
||||||
import type Konva from "konva";
|
import type Konva from "konva";
|
||||||
|
import KonvaFilters from "konva";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Arrow,
|
Arrow,
|
||||||
Ellipse,
|
Ellipse,
|
||||||
Group,
|
Group,
|
||||||
|
Image as KonvaImage,
|
||||||
Layer,
|
Layer,
|
||||||
Line,
|
Line,
|
||||||
Rect,
|
Rect,
|
||||||
@@ -16,10 +18,10 @@ import {
|
|||||||
Star,
|
Star,
|
||||||
Text,
|
Text,
|
||||||
} from "react-konva";
|
} from "react-konva";
|
||||||
|
import useImage from "use-image";
|
||||||
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
|
import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
|
||||||
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
|
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
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 { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
|
||||||
import { LoadingOverlay } from "./common/loading-overlay";
|
import { LoadingOverlay } from "./common/loading-overlay";
|
||||||
import { useBrushTool } from "./tools/brush-tool";
|
import { useBrushTool } from "./tools/brush-tool";
|
||||||
@@ -29,6 +31,11 @@ import { useGradientTool } from "./tools/gradient-tool";
|
|||||||
import { MoveToolTransformer, useMoveTool } from "./tools/move-tool";
|
import { MoveToolTransformer, useMoveTool } from "./tools/move-tool";
|
||||||
import { useShapeTool } from "./tools/shape-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_SIZE = 20;
|
||||||
const CHECKERBOARD_CSS = `
|
const CHECKERBOARD_CSS = `
|
||||||
repeating-conic-gradient(
|
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) {
|
switch (obj.type) {
|
||||||
case "line": {
|
case "line": {
|
||||||
const a = obj.attrs;
|
const a = obj.attrs;
|
||||||
@@ -80,7 +247,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
cornerRadius={a.cornerRadius}
|
cornerRadius={a.cornerRadius}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,7 +268,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
strokeWidth={a.strokeWidth}
|
strokeWidth={a.strokeWidth}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,7 +295,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
height={a.height}
|
height={a.height}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -139,7 +315,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
pointerWidth={a.pointerWidth}
|
pointerWidth={a.pointerWidth}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,7 +336,10 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
strokeWidth={a.strokeWidth}
|
strokeWidth={a.strokeWidth}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,13 +358,23 @@ function CanvasObjectRenderer({ obj }: { obj: CanvasObject }) {
|
|||||||
strokeWidth={a.strokeWidth}
|
strokeWidth={a.strokeWidth}
|
||||||
rotation={a.rotation}
|
rotation={a.rotation}
|
||||||
opacity={a.opacity}
|
opacity={a.opacity}
|
||||||
draggable
|
draggable={draggable}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "image":
|
case "image":
|
||||||
// Skip image objects for now (complex: requires Konva.Image with useImage)
|
return (
|
||||||
return null;
|
<ImageObject
|
||||||
|
obj={obj}
|
||||||
|
onClick={onSelect}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onTransformEnd={onTransformEnd}
|
||||||
|
draggable={draggable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -248,15 +440,28 @@ export function EditorCanvas() {
|
|||||||
const layers = useEditorStore((s) => s.layers);
|
const layers = useEditorStore((s) => s.layers);
|
||||||
const activeLayerId = useEditorStore((s) => s.activeLayerId);
|
const activeLayerId = useEditorStore((s) => s.activeLayerId);
|
||||||
const activeTool = useEditorStore((s) => s.activeTool);
|
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();
|
const cursor = useEditorCursor();
|
||||||
useEditorShortcuts();
|
|
||||||
|
|
||||||
const { handlers, moveTool } = useActiveToolHandlers(stageRef);
|
const { handlers, moveTool } = useActiveToolHandlers(stageRef);
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(800);
|
const [stageWidth, setStageWidth] = useState(800);
|
||||||
const [stageHeight, setStageHeight] = useState(600);
|
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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -291,6 +496,8 @@ export function EditorCanvas() {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}, [objects, layers]);
|
}, [objects, layers]);
|
||||||
|
|
||||||
|
const isMoveTool = activeTool === "move";
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
@@ -332,9 +539,19 @@ export function EditorCanvas() {
|
|||||||
[handlers],
|
[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;
|
const checkerboardSize = CHECKERBOARD_SIZE * zoom;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// biome-ignore lint/a11y/noStaticElementInteractions: canvas container tracks cursor for brush overlay
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="relative flex-1 overflow-hidden"
|
className="relative flex-1 overflow-hidden"
|
||||||
@@ -344,6 +561,7 @@ export function EditorCanvas() {
|
|||||||
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
|
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
|
||||||
}}
|
}}
|
||||||
data-testid="editor-canvas"
|
data-testid="editor-canvas"
|
||||||
|
onMouseMove={handleContainerMouseMove}
|
||||||
>
|
>
|
||||||
<Stage
|
<Stage
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
@@ -360,13 +578,25 @@ export function EditorCanvas() {
|
|||||||
>
|
>
|
||||||
{/* Render objects grouped by layer */}
|
{/* Render objects grouped by layer */}
|
||||||
<Layer>
|
<Layer>
|
||||||
|
{/* Issue #14: Render source image as background */}
|
||||||
|
{sourceImageUrl && (
|
||||||
|
<SourceImage url={sourceImageUrl} adjustments={adjustments} filters={filters} />
|
||||||
|
)}
|
||||||
|
|
||||||
{layers.map((layer) => {
|
{layers.map((layer) => {
|
||||||
const layerObjects = objectsByLayer.get(layer.id) ?? [];
|
const layerObjects = objectsByLayer.get(layer.id) ?? [];
|
||||||
if (!layer.visible) return null;
|
if (!layer.visible) return null;
|
||||||
return (
|
return (
|
||||||
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
|
<Group key={layer.id} opacity={layer.opacity} listening={layer.id === activeLayerId}>
|
||||||
{layerObjects.map((obj) => (
|
{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>
|
</Group>
|
||||||
);
|
);
|
||||||
@@ -391,7 +621,7 @@ export function EditorCanvas() {
|
|||||||
</Layer>
|
</Layer>
|
||||||
)}
|
)}
|
||||||
</Stage>
|
</Stage>
|
||||||
<BrushCursorOverlay containerRef={containerRef} />
|
<BrushCursorOverlay containerRef={containerRef} screenCursor={screenCursor} />
|
||||||
<LoadingOverlay />
|
<LoadingOverlay />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ interface HistogramPanelProps {
|
|||||||
imageData?: ImageData | null;
|
imageData?: ImageData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Issue #2: When no imageData is provided, show a placeholder message
|
||||||
export function HistogramPanel({ imageData }: HistogramPanelProps) {
|
export function HistogramPanel({ imageData }: HistogramPanelProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [stats, setStats] = useState<HistogramStats | null>(null);
|
const [stats, setStats] = useState<HistogramStats | null>(null);
|
||||||
@@ -176,6 +177,11 @@ export function HistogramPanel({ imageData }: HistogramPanelProps) {
|
|||||||
className="w-full rounded border border-border"
|
className="w-full rounded border border-border"
|
||||||
style={{ imageRendering: "pixelated" }}
|
style={{ imageRendering: "pixelated" }}
|
||||||
/>
|
/>
|
||||||
|
{!imageData && !stats && (
|
||||||
|
<p className="text-[10px] text-muted-foreground text-center py-1">
|
||||||
|
No histogram data available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="grid grid-cols-3 gap-1 text-[10px] text-muted-foreground">
|
<div className="grid grid-cols-3 gap-1 text-[10px] text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// apps/web/src/pages/editor-page.tsx
|
// apps/web/src/pages/editor-page.tsx
|
||||||
import { Monitor } from "lucide-react";
|
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 { WelcomeScreen } from "@/components/editor/common/welcome-screen";
|
||||||
import { EditorCanvas } from "@/components/editor/editor-canvas";
|
import { EditorCanvas } from "@/components/editor/editor-canvas";
|
||||||
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
|
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
|
||||||
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
|
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
|
||||||
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
|
||||||
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
import { EditorToolbar } from "@/components/editor/editor-toolbar";
|
||||||
|
import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
|
||||||
import { useMobile } from "@/hooks/use-mobile";
|
import { useMobile } from "@/hooks/use-mobile";
|
||||||
import { useEditorStore } from "@/stores/editor-store";
|
import { useEditorStore } from "@/stores/editor-store";
|
||||||
|
|
||||||
@@ -15,6 +17,13 @@ export function EditorPage() {
|
|||||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||||
const isDirty = useEditorStore((s) => s.isDirty);
|
const isDirty = useEditorStore((s) => s.isDirty);
|
||||||
const loadImage = useEditorStore((s) => s.loadImage);
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
@@ -87,6 +96,7 @@ export function EditorPage() {
|
|||||||
<EditorRightPanel />
|
<EditorRightPanel />
|
||||||
</div>
|
</div>
|
||||||
<EditorStatusBar />
|
<EditorStatusBar />
|
||||||
|
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
|
||||||
</div>
|
</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) => {
|
updateObject: (id, attrs) => {
|
||||||
set({
|
set({
|
||||||
objects: get().objects.map((obj) =>
|
objects: get().objects.map((obj) =>
|
||||||
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
|
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
|
||||||
),
|
),
|
||||||
isDirty: true,
|
isDirty: true,
|
||||||
_historyVersion: get()._historyVersion + 1,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -524,6 +526,7 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Adjustments
|
// Adjustments
|
||||||
|
// Issue #7: Include _historyVersion increment for undo tracking
|
||||||
setAdjustment: (key, value) => {
|
setAdjustment: (key, value) => {
|
||||||
const clamps: Record<string, [number, number]> = {
|
const clamps: Record<string, [number, number]> = {
|
||||||
brightness: [-100, 100],
|
brightness: [-100, 100],
|
||||||
@@ -541,14 +544,26 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
...get().adjustments,
|
...get().adjustments,
|
||||||
[key]: Math.max(min, Math.min(max, value)),
|
[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) => {
|
toggleFilter: (type) => {
|
||||||
set({
|
set({
|
||||||
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
|
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) =>
|
filters: get().filters.map((f) =>
|
||||||
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : 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 }),
|
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
// Issue #8: Include lastAction in partialize so history labels work
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
layers: state.layers,
|
layers: state.layers,
|
||||||
objects: state.objects,
|
objects: state.objects,
|
||||||
@@ -706,17 +725,19 @@ export const useEditorStore = create<EditorState>()(
|
|||||||
guides: state.guides,
|
guides: state.guides,
|
||||||
sourceImageUrl: state.sourceImageUrl,
|
sourceImageUrl: state.sourceImageUrl,
|
||||||
sourceImageSize: state.sourceImageSize,
|
sourceImageSize: state.sourceImageSize,
|
||||||
|
lastAction: state.lastAction,
|
||||||
_historyVersion: state._historyVersion,
|
_historyVersion: state._historyVersion,
|
||||||
}),
|
}),
|
||||||
limit: MAX_HISTORY,
|
limit: MAX_HISTORY,
|
||||||
equality: (a, b) =>
|
equality: (a, b) =>
|
||||||
(a as { _historyVersion: number })._historyVersion ===
|
(a as { _historyVersion: number })._historyVersion ===
|
||||||
(b as { _historyVersion: number })._historyVersion,
|
(b as { _historyVersion: number })._historyVersion,
|
||||||
|
// Issue #1: Forward all arguments from zundo's internal _handleSet
|
||||||
handleSet: (handleSet) => {
|
handleSet: (handleSet) => {
|
||||||
let timeout: ReturnType<typeof setTimeout>;
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
return (state) => {
|
return (...args: Parameters<typeof handleSet>) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
timeout = setTimeout(() => handleSet(state), 500);
|
timeout = setTimeout(() => handleSet(...args), 500);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user