Merge branch 'testing/image-editor' into main

Merges all image editor work: RAW decode improvements, expanded format
export (AVIF/TIFF/GIF/JXL/PSD), Photoshop-style menu bar, custom Konva
filters, smart guides, clone stamp, dodge/burn, eyedropper, pixel brush,
selection and transform tool overlays, autosave blob URL persistence,
and 49+ bug fixes across editor canvas and E2E tests.

# Conflicts:
#	apps/api/src/routes/tool-factory.ts
#	apps/api/src/routes/tools/convert.ts
#	apps/web/src/components/editor/common/export-dialog.tsx
#	apps/web/src/components/editor/editor-canvas.tsx
This commit is contained in:
SnapOtter
2026-05-08 21:37:52 +08:00
36 changed files with 4648 additions and 289 deletions
+85 -1
View File
@@ -2,10 +2,19 @@
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog";
import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog";
import {
AutosaveRecoveryBanner,
ExportDialog,
saveEditorState,
useAutosave,
} from "@/components/editor/common/export-dialog";
import { FillDialog } from "@/components/editor/common/fill-dialog";
import { ImageResizeDialog } from "@/components/editor/common/image-resize-dialog";
import { NewDocumentDialog } from "@/components/editor/common/new-document-dialog";
import { HorizontalRuler, VerticalRuler } from "@/components/editor/common/rulers";
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas";
import { EditorMenuBar } from "@/components/editor/editor-menu-bar";
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
import { EditorRightPanel } from "@/components/editor/editor-right-panel";
import { EditorStatusBar } from "@/components/editor/editor-status-bar";
@@ -14,21 +23,37 @@ import { useEditorShortcuts } from "@/hooks/use-editor-shortcuts";
import { useMobile } from "@/hooks/use-mobile";
import { useEditorStore } from "@/stores/editor-store";
const SERVER_DECODED_EXTS = new Set(["psd", "tga", "exr", "hdr"]);
export function EditorPage() {
const isMobile = useMobile();
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage);
const rulersVisible = useEditorStore((s) => s.rulersVisible);
const [showExport, setShowExport] = useState(false);
const [showCanvasResize, setShowCanvasResize] = useState(false);
const [showImageResize, setShowImageResize] = useState(false);
const [fillDialogOpen, setFillDialogOpen] = useState(false);
const [showNewDocument, setShowNewDocument] = useState(false);
// Autosave recovery
const { recoveryData, dismissRecovery, restoreRecovery } = useAutosave();
// Issue #10: Shortcuts belong at page level, not canvas level
useEditorShortcuts({
onSave: () => saveEditorState(),
onExport: () => setShowExport(true),
onFillDialog: () => setFillDialogOpen(true),
});
// Listen for fill-dialog custom event (dispatched from Shift+Backspace shortcut)
useEffect(() => {
const handler = () => setFillDialogOpen(true);
window.addEventListener("snapotter:open-fill-dialog", handler);
return () => window.removeEventListener("snapotter:open-fill-dialog", handler);
}, []);
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (isDirty) {
@@ -64,6 +89,43 @@ export function EditorPage() {
return () => document.removeEventListener("paste", handlePaste);
}, [handlePaste]);
const handleOpenImage = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,.psd,.tga,.exr,.hdr";
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (SERVER_DECODED_EXTS.has(ext)) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("settings", JSON.stringify({ format: "png" }));
const res = await fetch("/api/v1/tools/convert", { method: "POST", body: formData });
if (!res.ok) throw new Error("Server decode failed");
const json = await res.json();
if (json.downloadUrl) {
const imgRes = await fetch(json.downloadUrl);
const blob = await imgRes.blob();
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
img.src = url;
}
} catch (err) {
console.error("Failed to decode file via server:", err);
}
return;
}
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => loadImage(url, img.naturalWidth, img.naturalHeight);
img.src = url;
};
input.click();
}, [loadImage]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const url = params.get("url");
@@ -90,9 +152,29 @@ export function EditorPage() {
return (
<div className="flex flex-col h-full overflow-hidden">
<EditorMenuBar
onNewDocument={() => setShowNewDocument(true)}
onOpenImage={handleOpenImage}
onExport={() => setShowExport(true)}
onSave={() => saveEditorState()}
onCanvasResize={() => setShowCanvasResize(true)}
onImageResize={() => setShowImageResize(true)}
/>
{/* Autosave recovery banner */}
{recoveryData && (
<AutosaveRecoveryBanner
data={recoveryData}
onRestore={restoreRecovery}
onDiscard={dismissRecovery}
/>
)}
<EditorOptionsBar />
{/* Horizontal ruler along the top edge */}
{rulersVisible && <HorizontalRuler />}
<div className="flex flex-1 overflow-hidden">
<EditorToolbar />
{/* Vertical ruler along the left edge */}
{rulersVisible && <VerticalRuler />}
<div className="relative flex-1 overflow-hidden bg-muted/30">
<EditorCanvas
onCanvasResize={() => setShowCanvasResize(true)}
@@ -106,6 +188,8 @@ export function EditorPage() {
{showExport && <ExportDialog onClose={() => setShowExport(false)} />}
<CanvasResizeDialog open={showCanvasResize} onClose={() => setShowCanvasResize(false)} />
<ImageResizeDialog open={showImageResize} onClose={() => setShowImageResize(false)} />
<FillDialog open={fillDialogOpen} onClose={() => setFillDialogOpen(false)} />
<NewDocumentDialog open={showNewDocument} onClose={() => setShowNewDocument(false)} />
</div>
);
}