mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: recover Photoshop-style menu bar from orphaned branch
Recovers the editor menu bar (File/Edit/Image/Layer/Select/Filter/View) from orphaned commit b07ecd5 that was lost during a rebase. Integrates it into the current editor-page layout with PSD/TGA/EXR/HDR file open support and new document dialog.
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// apps/web/src/components/editor/editor-menu-bar.tsx
|
||||
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
|
||||
const IS_MAC = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
|
||||
function mod(label: string): string {
|
||||
return IS_MAC ? label.replace("Ctrl+", "⌘").replace("Shift+", "⇧") : label;
|
||||
}
|
||||
|
||||
export interface MenuBarCallbacks {
|
||||
onNewDocument: () => void;
|
||||
onOpenImage: () => void;
|
||||
onExport: () => void;
|
||||
onSave: () => void;
|
||||
onCanvasResize: () => void;
|
||||
onImageResize: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
action?: () => void;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
dividerAfter?: boolean;
|
||||
}
|
||||
|
||||
interface MenuDef {
|
||||
label: string;
|
||||
testId: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
function useMenuDefinitions(callbacks: MenuBarCallbacks): MenuDef[] {
|
||||
const sourceImageUrl = useEditorStore((s) => s.sourceImageUrl);
|
||||
const layers = useEditorStore((s) => s.layers);
|
||||
const activeLayerId = useEditorStore((s) => s.activeLayerId);
|
||||
const rulersVisible = useEditorStore((s) => s.rulersVisible);
|
||||
const gridVisible = useEditorStore((s) => s.gridVisible);
|
||||
const guidesVisible = useEditorStore((s) => s.guidesVisible);
|
||||
const snappingEnabled = useEditorStore((s) => s.snappingEnabled);
|
||||
const rightPanelVisible = useEditorStore((s) => s.rightPanelVisible);
|
||||
const setTool = useEditorStore((s) => s.setTool);
|
||||
const setZoom = useEditorStore((s) => s.setZoom);
|
||||
const zoom = useEditorStore((s) => s.zoom);
|
||||
const addLayer = useEditorStore((s) => s.addLayer);
|
||||
const removeLayer = useEditorStore((s) => s.removeLayer);
|
||||
const duplicateLayer = useEditorStore((s) => s.duplicateLayer);
|
||||
const mergeDown = useEditorStore((s) => s.mergeDown);
|
||||
const flattenAll = useEditorStore((s) => s.flattenAll);
|
||||
const setSelection = useEditorStore((s) => s.setSelection);
|
||||
const invertSelection = useEditorStore((s) => s.invertSelection);
|
||||
const canvasSize = useEditorStore((s) => s.canvasSize);
|
||||
const rotateCanvas = useEditorStore((s) => s.rotateCanvas);
|
||||
const flipCanvasHorizontal = useEditorStore((s) => s.flipCanvasHorizontal);
|
||||
const flipCanvasVertical = useEditorStore((s) => s.flipCanvasVertical);
|
||||
const trimCanvas = useEditorStore((s) => s.trimCanvas);
|
||||
const toggleFilter = useEditorStore((s) => s.toggleFilter);
|
||||
const toggleRulers = useEditorStore((s) => s.toggleRulers);
|
||||
const toggleGrid = useEditorStore((s) => s.toggleGrid);
|
||||
const toggleGuides = useEditorStore((s) => s.toggleGuides);
|
||||
const toggleSnapping = useEditorStore((s) => s.toggleSnapping);
|
||||
const toggleRightPanel = useEditorStore((s) => s.toggleRightPanel);
|
||||
const copyObjects = useEditorStore((s) => s.copyObjects);
|
||||
const cutObjects = useEditorStore((s) => s.cutObjects);
|
||||
const pasteObjects = useEditorStore((s) => s.pasteObjects);
|
||||
const pasteInPlace = useEditorStore((s) => s.pasteInPlace);
|
||||
const removeObjects = useEditorStore((s) => s.removeObjects);
|
||||
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
||||
const bringToFront = useEditorStore((s) => s.bringToFront);
|
||||
const bringForward = useEditorStore((s) => s.bringForward);
|
||||
const sendBackward = useEditorStore((s) => s.sendBackward);
|
||||
const sendToBack = useEditorStore((s) => s.sendToBack);
|
||||
const hasImage = !!sourceImageUrl;
|
||||
const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
|
||||
const singleLayer = layers.length <= 1;
|
||||
const undo = useCallback(() => {
|
||||
useEditorStore.temporal.getState().undo();
|
||||
}, []);
|
||||
const redo = useCallback(() => {
|
||||
useEditorStore.temporal.getState().redo();
|
||||
}, []);
|
||||
|
||||
return [
|
||||
{
|
||||
label: "File",
|
||||
testId: "file",
|
||||
items: [
|
||||
{ label: "New", shortcut: mod("Ctrl+N"), action: callbacks.onNewDocument },
|
||||
{ label: "Open", shortcut: mod("Ctrl+O"), action: callbacks.onOpenImage },
|
||||
{ label: "Save", shortcut: mod("Ctrl+S"), action: callbacks.onSave, dividerAfter: true },
|
||||
{ label: "Export As...", shortcut: mod("Ctrl+Shift+E"), action: callbacks.onExport },
|
||||
{ label: "Quick Export as PNG", shortcut: mod("Ctrl+Shift+P"), action: callbacks.onExport },
|
||||
{
|
||||
label: "Close",
|
||||
shortcut: mod("Ctrl+W"),
|
||||
disabled: !hasImage,
|
||||
action: () => {
|
||||
if (hasImage) {
|
||||
useEditorStore.setState({
|
||||
sourceImageUrl: null,
|
||||
sourceImageSize: null,
|
||||
objects: [],
|
||||
selectedObjectIds: [],
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
testId: "edit",
|
||||
items: [
|
||||
{ label: "Undo", shortcut: mod("Ctrl+Z"), action: undo },
|
||||
{ label: "Redo", shortcut: mod("Ctrl+Shift+Z"), action: redo, dividerAfter: true },
|
||||
{ label: "Cut", shortcut: mod("Ctrl+X"), action: cutObjects },
|
||||
{ label: "Copy", shortcut: mod("Ctrl+C"), action: copyObjects },
|
||||
{ label: "Copy Merged", shortcut: mod("Ctrl+Shift+C"), action: copyObjects },
|
||||
{ label: "Paste", shortcut: mod("Ctrl+V"), action: pasteObjects },
|
||||
{
|
||||
label: "Paste in Place",
|
||||
shortcut: mod("Ctrl+Shift+V"),
|
||||
action: pasteInPlace,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
shortcut: "Del",
|
||||
action: () => removeObjects(selectedObjectIds),
|
||||
disabled: selectedObjectIds.length === 0,
|
||||
},
|
||||
{
|
||||
label: "Free Transform",
|
||||
shortcut: mod("Ctrl+T"),
|
||||
action: () => setTool("transform"),
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Transform",
|
||||
submenu: [
|
||||
{ label: "Scale", action: () => setTool("transform") },
|
||||
{ label: "Rotate", action: () => setTool("transform") },
|
||||
{ label: "Skew", action: () => setTool("transform") },
|
||||
{ label: "Flip Horizontal", action: flipCanvasHorizontal },
|
||||
{ label: "Flip Vertical", action: flipCanvasVertical },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Image",
|
||||
testId: "image",
|
||||
items: [
|
||||
{
|
||||
label: "Image Size...",
|
||||
shortcut: mod("Ctrl+Alt+I"),
|
||||
action: callbacks.onImageResize,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{ label: "Canvas Size...", shortcut: mod("Ctrl+Alt+C"), action: callbacks.onCanvasResize },
|
||||
{
|
||||
label: "Image Rotation",
|
||||
submenu: [
|
||||
{ label: "90° CW", action: () => rotateCanvas(90) },
|
||||
{ label: "90° CCW", action: () => rotateCanvas(270) },
|
||||
{ label: "180°", action: () => rotateCanvas(180) },
|
||||
{ label: "Flip Horizontal", action: flipCanvasHorizontal },
|
||||
{ label: "Flip Vertical", action: flipCanvasVertical },
|
||||
],
|
||||
dividerAfter: true,
|
||||
},
|
||||
{ label: "Trim", action: trimCanvas },
|
||||
{
|
||||
label: "Adjustments",
|
||||
submenu: [
|
||||
{ label: "Brightness/Contrast" },
|
||||
{ label: "Hue/Saturation" },
|
||||
{ label: "Color Balance" },
|
||||
{ label: "Levels" },
|
||||
{ label: "Curves" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Layer",
|
||||
testId: "layer",
|
||||
items: [
|
||||
{ label: "New Layer", shortcut: mod("Ctrl+Shift+N"), action: addLayer },
|
||||
{ label: "Duplicate Layer", action: () => duplicateLayer(activeLayerId) },
|
||||
{
|
||||
label: "Delete Layer",
|
||||
action: () => removeLayer(activeLayerId),
|
||||
disabled: singleLayer,
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Arrange",
|
||||
submenu: [
|
||||
{
|
||||
label: "Bring to Front",
|
||||
action: () => {
|
||||
if (selectedObjectIds[0]) bringToFront(selectedObjectIds[0]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Bring Forward",
|
||||
action: () => {
|
||||
if (selectedObjectIds[0]) bringForward(selectedObjectIds[0]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Send Backward",
|
||||
action: () => {
|
||||
if (selectedObjectIds[0]) sendBackward(selectedObjectIds[0]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Send to Back",
|
||||
action: () => {
|
||||
if (selectedObjectIds[0]) sendToBack(selectedObjectIds[0]);
|
||||
},
|
||||
},
|
||||
],
|
||||
dividerAfter: true,
|
||||
},
|
||||
{
|
||||
label: "Merge Down",
|
||||
shortcut: mod("Ctrl+E"),
|
||||
action: () => mergeDown(activeLayerId),
|
||||
disabled: activeIndex <= 0,
|
||||
},
|
||||
{ label: "Flatten Image", action: flattenAll },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Select",
|
||||
testId: "select",
|
||||
items: [
|
||||
{
|
||||
label: "All",
|
||||
shortcut: mod("Ctrl+A"),
|
||||
action: () =>
|
||||
setSelection({
|
||||
type: "rect",
|
||||
points: [
|
||||
0,
|
||||
0,
|
||||
canvasSize.width,
|
||||
0,
|
||||
canvasSize.width,
|
||||
canvasSize.height,
|
||||
0,
|
||||
canvasSize.height,
|
||||
],
|
||||
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Deselect",
|
||||
shortcut: mod("Ctrl+D"),
|
||||
action: () => setSelection(null),
|
||||
dividerAfter: true,
|
||||
},
|
||||
{ label: "Inverse", shortcut: mod("Ctrl+Shift+I"), action: invertSelection },
|
||||
{ label: "Color Range..." },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Filter",
|
||||
testId: "filter",
|
||||
items: [
|
||||
{
|
||||
label: "Blur",
|
||||
submenu: [
|
||||
{ label: "Gaussian Blur", action: () => toggleFilter("blur") },
|
||||
{ label: "Motion Blur", action: () => toggleFilter("motionBlur") },
|
||||
{ label: "Radial Blur", action: () => toggleFilter("radialBlur") },
|
||||
{ label: "Surface Blur", action: () => toggleFilter("surfaceBlur") },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Sharpen",
|
||||
submenu: [
|
||||
{ label: "Sharpen", action: () => toggleFilter("sharpen") },
|
||||
{ label: "Unsharp Mask" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Noise",
|
||||
submenu: [
|
||||
{ label: "Add Noise", action: () => toggleFilter("noise") },
|
||||
{ label: "Reduce Noise" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Pixelate",
|
||||
submenu: [
|
||||
{ label: "Pixelate", action: () => toggleFilter("pixelate") },
|
||||
{ label: "Mosaic" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Stylize",
|
||||
submenu: [
|
||||
{ label: "Emboss", action: () => toggleFilter("emboss") },
|
||||
{ label: "Solarize", action: () => toggleFilter("solarize") },
|
||||
{ label: "Posterize", action: () => toggleFilter("posterize") },
|
||||
],
|
||||
dividerAfter: true,
|
||||
},
|
||||
{ label: "Grayscale", action: () => toggleFilter("grayscale") },
|
||||
{ label: "Sepia", action: () => toggleFilter("sepia") },
|
||||
{ label: "Invert", action: () => toggleFilter("invert") },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
testId: "view",
|
||||
items: [
|
||||
{ label: "Zoom In", shortcut: mod("Ctrl+="), action: () => setZoom(zoom * 1.25) },
|
||||
{ label: "Zoom Out", shortcut: mod("Ctrl+-"), action: () => setZoom(zoom / 1.25) },
|
||||
{ label: "Fit on Screen", shortcut: mod("Ctrl+0"), action: () => setZoom(1) },
|
||||
{
|
||||
label: "Actual Pixels",
|
||||
shortcut: mod("Ctrl+1"),
|
||||
action: () => setZoom(1),
|
||||
dividerAfter: true,
|
||||
},
|
||||
{ label: "Rulers", checked: rulersVisible, action: toggleRulers },
|
||||
{ label: "Grid", checked: gridVisible, action: toggleGrid },
|
||||
{ label: "Guides", checked: guidesVisible, action: toggleGuides },
|
||||
{ label: "Snap", checked: snappingEnabled, action: toggleSnapping, dividerAfter: true },
|
||||
{ label: "Panels", checked: rightPanelVisible, action: toggleRightPanel },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function toTestId(label: string): string {
|
||||
return label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
}
|
||||
|
||||
function MenuItemRow({ item, onClose }: { item: MenuItem; onClose: () => void }) {
|
||||
const [submenuOpen, setSubmenuOpen] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const handleEnter = () => {
|
||||
if (item.submenu) {
|
||||
clearTimeout(timerRef.current);
|
||||
setSubmenuOpen(true);
|
||||
}
|
||||
};
|
||||
const handleLeave = () => {
|
||||
if (item.submenu) {
|
||||
timerRef.current = setTimeout(() => setSubmenuOpen(false), 150);
|
||||
}
|
||||
};
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
if (item.submenu) {
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-1 text-xs cursor-default select-none rounded-sm",
|
||||
item.disabled
|
||||
? "text-muted-foreground/50"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`menu-item-${toTestId(item.label)}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<ChevronRight size={12} className="ml-4 text-muted-foreground" />
|
||||
</div>
|
||||
{submenuOpen && (
|
||||
<div
|
||||
className="absolute left-full top-0 ml-0.5 min-w-[180px] bg-popover border border-border rounded-md shadow-lg py-1 z-[60]"
|
||||
role="menu"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{item.submenu.map((sub) => (
|
||||
<MenuItemRow key={sub.label} item={sub} onClose={onClose} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.dividerAfter && <div className="my-1 border-t border-border" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-3 py-1 text-xs cursor-default select-none rounded-sm text-left",
|
||||
item.disabled
|
||||
? "text-muted-foreground/50 pointer-events-none"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.action?.();
|
||||
onClose();
|
||||
}}
|
||||
data-testid={`menu-item-${toTestId(item.label)}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.checked !== undefined && (
|
||||
<span className="w-3.5">{item.checked && <Check size={12} />}</span>
|
||||
)}
|
||||
{item.label}
|
||||
</span>
|
||||
{item.shortcut && (
|
||||
<span className="ml-6 text-[10px] text-muted-foreground">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
{item.dividerAfter && <div className="my-1 border-t border-border" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditorMenuBar(props: MenuBarCallbacks) {
|
||||
const menus = useMenuDefinitions(props);
|
||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const close = useCallback(() => setOpenMenu(null), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [openMenu, close]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (barRef.current && !barRef.current.contains(e.target as Node)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("mousedown", handleClick);
|
||||
return () => window.removeEventListener("mousedown", handleClick);
|
||||
}, [openMenu, close]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={barRef}
|
||||
className="flex items-center h-7 bg-card border-b border-border px-1 select-none shrink-0"
|
||||
data-testid="editor-menu-bar"
|
||||
>
|
||||
{menus.map((menu) => (
|
||||
<div key={menu.testId} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2.5 py-0.5 text-xs rounded-sm transition-colors",
|
||||
openMenu === menu.testId
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
data-testid={`menu-${menu.testId}`}
|
||||
onClick={() => setOpenMenu(openMenu === menu.testId ? null : menu.testId)}
|
||||
onMouseEnter={() => {
|
||||
if (openMenu) setOpenMenu(menu.testId);
|
||||
}}
|
||||
>
|
||||
{menu.label}
|
||||
</button>
|
||||
{openMenu === menu.testId && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-0.5 min-w-[220px] bg-popover border border-border rounded-md shadow-lg py-1 z-50"
|
||||
data-testid={`menu-dropdown-${menu.testId}`}
|
||||
role="menu"
|
||||
>
|
||||
{menu.items.map((item) => (
|
||||
<MenuItemRow key={item.label} item={item} onClose={close} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} 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";
|
||||
@@ -21,6 +23,8 @@ 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);
|
||||
@@ -31,6 +35,7 @@ export function EditorPage() {
|
||||
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();
|
||||
@@ -84,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");
|
||||
@@ -110,6 +152,14 @@ 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
|
||||
@@ -139,6 +189,7 @@ export function EditorPage() {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user