diff --git a/apps/web/src/components/editor/editor-menu-bar.tsx b/apps/web/src/components/editor/editor-menu-bar.tsx new file mode 100644 index 00000000..2a4e8119 --- /dev/null +++ b/apps/web/src/components/editor/editor-menu-bar.tsx @@ -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 | 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 ( +
+
+ {item.label} + +
+ {submenuOpen && ( +
+ {item.submenu.map((sub) => ( + + ))} +
+ )} + {item.dividerAfter &&
} +
+ ); + } + + return ( + <> + + {item.dividerAfter &&
} + + ); +} + +export function EditorMenuBar(props: MenuBarCallbacks) { + const menus = useMenuDefinitions(props); + const [openMenu, setOpenMenu] = useState(null); + const barRef = useRef(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 ( +
+ {menus.map((menu) => ( +
+ + {openMenu === menu.testId && ( +
+ {menu.items.map((item) => ( + + ))} +
+ )} +
+ ))} +
+ ); +} diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx index f082da65..81378b39 100644 --- a/apps/web/src/pages/editor-page.tsx +++ b/apps/web/src/pages/editor-page.tsx @@ -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 (
+ setShowNewDocument(true)} + onOpenImage={handleOpenImage} + onExport={() => setShowExport(true)} + onSave={() => saveEditorState()} + onCanvasResize={() => setShowCanvasResize(true)} + onImageResize={() => setShowImageResize(true)} + /> {/* Autosave recovery banner */} {recoveryData && ( setShowCanvasResize(false)} /> setShowImageResize(false)} /> setFillDialogOpen(false)} /> + setShowNewDocument(false)} />
); } diff --git a/tests/e2e/gui-editor-menubar.spec.ts b/tests/e2e/gui-editor-menubar.spec.ts new file mode 100644 index 00000000..f315e005 --- /dev/null +++ b/tests/e2e/gui-editor-menubar.spec.ts @@ -0,0 +1,328 @@ +import { expect, getTestImagePath, test } from "./helpers"; + +test.describe("Editor Menu Bar", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/editor"); + await page.waitForSelector('[data-testid="editor-menu-bar"]', { timeout: 10_000 }); + }); + + test("renders all 7 top-level menus", async ({ page }) => { + for (const id of ["file", "edit", "image", "layer", "select", "filter", "view"]) { + await expect(page.locator(`[data-testid="menu-${id}"]`)).toBeVisible(); + } + }); + + test("menu bar has correct height and styling", async ({ page }) => { + const bar = page.locator('[data-testid="editor-menu-bar"]'); + await expect(bar).toHaveClass(/h-7/); + await expect(bar).toHaveClass(/bg-card/); + }); + + test("File menu opens on click and shows items", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + const dropdown = page.locator('[data-testid="menu-dropdown-file"]'); + await expect(dropdown).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-new"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-open"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-save"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-export-as"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-close"]')).toBeVisible(); + }); + + test("File > New opens new document dialog", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await page.click('[data-testid="menu-item-new"]'); + await expect(page.getByText("New Document").first()).toBeVisible({ timeout: 3000 }); + }); + + test("File > Open triggers file chooser", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.click('[data-testid="menu-item-open"]'); + const chooser = await fileChooserPromise; + expect(chooser).toBeTruthy(); + }); + + test("File > Close is disabled when no image loaded", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + const closeBtn = page.locator('[data-testid="menu-item-close"]'); + await expect(closeBtn).toBeDisabled(); + }); + + test("Edit menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-edit"]'); + await expect(page.locator('[data-testid="menu-dropdown-edit"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-undo"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-redo"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-cut"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-copy"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-paste"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-delete"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-free-transform"]')).toBeVisible(); + }); + + test("Edit > Delete is disabled when no objects selected", async ({ page }) => { + await page.click('[data-testid="menu-edit"]'); + await expect(page.locator('[data-testid="menu-item-delete"]')).toBeDisabled(); + }); + + test("Edit > Transform submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-edit"]'); + const transform = page.locator('[data-testid="menu-item-transform"]'); + await expect(transform).toBeVisible(); + await transform.hover(); + await expect(page.locator('[data-testid="menu-item-scale"]')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('[data-testid="menu-item-rotate"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-flip-horizontal"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-flip-vertical"]')).toBeVisible(); + }); + + test("Image menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-image"]'); + await expect(page.locator('[data-testid="menu-dropdown-image"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-image-size"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-canvas-size"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-image-rotation"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-trim"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-adjustments"]')).toBeVisible(); + }); + + test("Image > Image Rotation submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-image"]'); + await page.locator('[data-testid="menu-item-image-rotation"]').hover(); + await expect(page.locator('[data-testid="menu-item-90-cw"]')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('[data-testid="menu-item-90-ccw"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-180"]')).toBeVisible(); + }); + + test("Image > Adjustments submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-image"]'); + await page.locator('[data-testid="menu-item-adjustments"]').hover(); + await expect(page.locator('[data-testid="menu-item-brightness-contrast"]')).toBeVisible({ + timeout: 3000, + }); + await expect(page.locator('[data-testid="menu-item-hue-saturation"]')).toBeVisible(); + }); + + test("Layer menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-layer"]'); + await expect(page.locator('[data-testid="menu-dropdown-layer"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-new-layer"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-duplicate-layer"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-delete-layer"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-arrange"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-merge-down"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-flatten-image"]')).toBeVisible(); + }); + + test("Layer > Delete Layer is disabled with single layer", async ({ page }) => { + await page.click('[data-testid="menu-layer"]'); + await expect(page.locator('[data-testid="menu-item-delete-layer"]')).toBeDisabled(); + }); + + test("Layer > Arrange submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-layer"]'); + await page.locator('[data-testid="menu-item-arrange"]').hover(); + await expect(page.locator('[data-testid="menu-item-bring-to-front"]')).toBeVisible({ + timeout: 3000, + }); + await expect(page.locator('[data-testid="menu-item-send-to-back"]')).toBeVisible(); + }); + + test("Select menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-select"]'); + await expect(page.locator('[data-testid="menu-dropdown-select"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-all"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-deselect"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-inverse"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-color-range"]')).toBeVisible(); + }); + + test("Filter menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-filter"]'); + await expect(page.locator('[data-testid="menu-dropdown-filter"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-blur"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-sharpen"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-noise"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-pixelate"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-stylize"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-grayscale"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-sepia"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-invert"]')).toBeVisible(); + }); + + test("Filter > Blur submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-filter"]'); + await page.locator('[data-testid="menu-item-blur"]').hover(); + await expect(page.locator('[data-testid="menu-item-gaussian-blur"]')).toBeVisible({ + timeout: 3000, + }); + await expect(page.locator('[data-testid="menu-item-motion-blur"]')).toBeVisible(); + }); + + test("Filter > Stylize submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-filter"]'); + await page.locator('[data-testid="menu-item-stylize"]').hover(); + await expect(page.locator('[data-testid="menu-item-emboss"]')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('[data-testid="menu-item-solarize"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-posterize"]')).toBeVisible(); + }); + + test("Filter > Noise submenu renders", async ({ page }) => { + await page.click('[data-testid="menu-filter"]'); + await page.locator('[data-testid="menu-item-noise"]').hover(); + await expect(page.locator('[data-testid="menu-item-add-noise"]')).toBeVisible({ + timeout: 3000, + }); + }); + + test("View menu opens and shows items", async ({ page }) => { + await page.click('[data-testid="menu-view"]'); + await expect(page.locator('[data-testid="menu-dropdown-view"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-zoom-in"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-zoom-out"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-fit-on-screen"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-actual-pixels"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-rulers"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-grid"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-guides"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-snap"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-item-panels"]')).toBeVisible(); + }); + + test("View > Rulers toggles checkmark", async ({ page }) => { + await page.click('[data-testid="menu-view"]'); + await page.click('[data-testid="menu-item-rulers"]'); + await page.click('[data-testid="menu-view"]'); + const rulersItem = page.locator('[data-testid="menu-item-rulers"]'); + await expect(rulersItem.locator("svg")).toBeVisible(); + }); + + test("View > Grid toggles checkmark", async ({ page }) => { + await page.click('[data-testid="menu-view"]'); + await page.click('[data-testid="menu-item-grid"]'); + await page.click('[data-testid="menu-view"]'); + await expect(page.locator('[data-testid="menu-item-grid"]').locator("svg")).toBeVisible(); + }); + + test("keyboard shortcuts are displayed on menu items", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + const text = await page.locator('[data-testid="menu-item-new"]').textContent(); + expect(text).toMatch(/N/); + }); + + test("clicking a menu toggles it open and closed", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).toBeVisible(); + await page.click('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).not.toBeVisible(); + }); + + test("hover switches between open menus", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).toBeVisible(); + await page.hover('[data-testid="menu-edit"]'); + await expect(page.locator('[data-testid="menu-dropdown-edit"]')).toBeVisible(); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).not.toBeVisible(); + }); + + test("clicking outside closes menu", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).toBeVisible(); + await page.click('[data-testid="editor-canvas"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).not.toBeVisible(); + }); + + test("Escape closes open menu", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).not.toBeVisible(); + }); + + test("hover does not open menu when none are active", async ({ page }) => { + await page.hover('[data-testid="menu-file"]'); + await expect(page.locator('[data-testid="menu-dropdown-file"]')).not.toBeVisible(); + }); + + test("Layer > New Layer adds a layer", async ({ page }) => { + await page.click('[data-testid="menu-layer"]'); + await page.click('[data-testid="menu-item-new-layer"]'); + await page.click('[data-testid="menu-layer"]'); + await expect(page.locator('[data-testid="menu-item-delete-layer"]')).toBeEnabled(); + }); + + test("Layer > Merge Down is disabled on bottom layer", async ({ page }) => { + await page.click('[data-testid="menu-layer"]'); + await expect(page.locator('[data-testid="menu-item-merge-down"]')).toBeDisabled(); + }); + + test("File > Export As opens export dialog", async ({ page }) => { + await page.click('[data-testid="menu-file"]'); + await page.click('[data-testid="menu-item-export-as"]'); + await expect(page.getByText("Export Image")).toBeVisible({ timeout: 3000 }); + }); + + test("Edit > Copy Merged is visible", async ({ page }) => { + await page.click('[data-testid="menu-edit"]'); + await expect(page.locator('[data-testid="menu-item-copy-merged"]')).toBeVisible(); + }); + + test("Edit > Paste in Place is visible", async ({ page }) => { + await page.click('[data-testid="menu-edit"]'); + await expect(page.locator('[data-testid="menu-item-paste-in-place"]')).toBeVisible(); + }); + + test("View > Panels toggles right panel", async ({ page }) => { + await page.click('[data-testid="menu-view"]'); + await page.click('[data-testid="menu-item-panels"]'); + }); + + test("Image > Canvas Size opens dialog", async ({ page }) => { + await page.click('[data-testid="menu-image"]'); + await page.click('[data-testid="menu-item-canvas-size"]'); + await expect(page.getByText("Canvas Size").first()).toBeVisible({ timeout: 3000 }); + }); + + test("Image > Image Size opens dialog", async ({ page }) => { + await page.click('[data-testid="menu-image"]'); + await page.click('[data-testid="menu-item-image-size"]'); + await expect(page.getByText("Image Size").first()).toBeVisible({ timeout: 3000 }); + }); + + test("Select > Deselect does not crash", async ({ page }) => { + await page.click('[data-testid="menu-select"]'); + await page.click('[data-testid="menu-item-deselect"]'); + }); + + test("Select > Inverse does not crash", async ({ page }) => { + await page.click('[data-testid="menu-select"]'); + await page.click('[data-testid="menu-item-inverse"]'); + }); + + test("View > Snap toggles", async ({ page }) => { + await page.click('[data-testid="menu-view"]'); + const snapItem = page.locator('[data-testid="menu-item-snap"]'); + const initialCheck = await snapItem.locator("svg").count(); + expect(initialCheck).toBeGreaterThan(0); + await page.click('[data-testid="menu-item-snap"]'); + await page.click('[data-testid="menu-view"]'); + const afterToggleCheck = await page + .locator('[data-testid="menu-item-snap"]') + .locator("svg") + .count(); + expect(afterToggleCheck).toBe(0); + }); + + test("Select > All sets selection", async ({ page }) => { + const testImagePath = getTestImagePath(); + await page.click('[data-testid="menu-file"]'); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.click('[data-testid="menu-item-open"]'); + const chooser = await fileChooserPromise; + await chooser.setFiles(testImagePath); + await page.waitForTimeout(1000); + await page.click('[data-testid="menu-select"]'); + await page.click('[data-testid="menu-item-all"]'); + }); +});