diff --git a/docs/canvas-interaction-matrix.md b/docs/canvas-interaction-matrix.md index 5cddd0c..9a77e06 100644 --- a/docs/canvas-interaction-matrix.md +++ b/docs/canvas-interaction-matrix.md @@ -287,13 +287,14 @@ These are first-class invariants. Drilled-in editing must not silently fall back | FL-01 | Toolbar/File | Any state | Open project file | Layers/canvas update | Document loaded | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-02 | Toolbar/File | Any state | Save project file | Download occurs | Project serialized | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-03 | Toolbar | Any state | New project | Canvas clears | Document reset | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | -| FL-04 | Toolbar | Any state | Export PNG | Download occurs | PNG exported | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | +| FL-04 | Toolbar/Export | Any state | Open Export menu and pick PNG | Download occurs | PNG exported | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-05 | Toolbar/File | Any state | Upload image | Image row and image item appear | Image node added | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-06 | Toolbar/File | Text selected | Upload font, select it, reload | Font picker keeps the uploaded family and no missing-font warning appears | Text font changes and persisted uploaded font rehydrates for reload | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-07 | File + Browser reload | Grouped project exists | Save, reopen, reload | Layers and group affordances restore | Grouped document round-trips | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-08 | IndexedDB | Persisted state exists | Reload app | Visible state restores, uploaded fonts rehydrate if still referenced, and unused uploaded fonts disappear after reload | Persisted state and retained uploaded-font assets load from local storage | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | | FL-09 | IndexedDB | Corrupt persisted state exists | Reload app | Safe empty state loads | Corrupt persistence cleared | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | -| FL-10 | Toolbar | Any state | Hover or focus Export PNG | Workspace outside the canvas darkens while the canvas interior stays clear | Export bounds cue appears and clears with intent state | user-flow | Chromium | Covered in [e2e/editor.toolbar.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.toolbar.spec.ts) | +| FL-10 | Toolbar | Any state | Hover, focus, or open the Export menu | Workspace outside the canvas darkens while the canvas interior stays clear | Export bounds cue appears and stays active while the menu is open | user-flow | Chromium | Covered in [e2e/editor.toolbar.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.toolbar.spec.ts) | +| FL-11 | Toolbar/Export | Any state | Open Export menu and pick "To clipboard" | "Copied to clipboard" status bubble appears next to the Export trigger | image/png ClipboardItem written via navigator.clipboard.write | user-flow | Chromium | Covered in [e2e/editor.files-and-persistence.spec.ts](/home/mikek/src/billboard-builder/e2e/editor.files-and-persistence.spec.ts) | ## 14. UI Regression Matrix diff --git a/e2e/editor.files-and-persistence.spec.ts b/e2e/editor.files-and-persistence.spec.ts index e1d9414..81044b7 100644 --- a/e2e/editor.files-and-persistence.spec.ts +++ b/e2e/editor.files-and-persistence.spec.ts @@ -191,12 +191,73 @@ test.describe('editor file and persistence flows', () => { const pngSize = await readDownloadedPngSize( await captureDownload(page, async () => { - await page.getByRole('button', { name: 'Export PNG' }).click(); + await clickToolbarPopoverItem(page, 'Export', 'PNG'); }) ); expect(pngSize).toEqual({ width: 1024, height: 1024 }); }); + test('writes the canvas to the system clipboard as image/png from the Export menu', async ({ page }) => { + await openFreshEditor(page); + await uploadProject( + page, + createProjectDocument([ + createRectangleFixture({ id: 'clipboard-rect', x: 200, y: 200, width: 240, height: 160 }), + ]), + 'clipboard-fixture.json', + ); + + // Stub the clipboard API before any user-script can call it. We can't rely + // on real clipboard permissions in headless Chromium, so we observe the + // call instead and recover the captured Blob via a base64 round-trip + // (Blob isn't serializable across the page<->test boundary). + await page.evaluate(() => { + const captured: Array<{ types: string[]; payloads: Record }> = []; + const originalClipboardItem = window.ClipboardItem; + const PatchedClipboardItem = function (this: object, data: Record) { + Object.defineProperty(this, '__bbData', { value: data, enumerable: false }); + Object.defineProperty(this, 'types', { value: Object.keys(data), enumerable: true }); + } as unknown as typeof ClipboardItem; + PatchedClipboardItem.supports = originalClipboardItem?.supports ?? (() => true); + window.ClipboardItem = PatchedClipboardItem; + + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + write: async (items: Array<{ __bbData: Record }>) => { + const result: Array<{ types: string[]; payloads: Record }> = []; + for (const item of items) { + const types = Object.keys(item.__bbData); + const payloads: Record = {}; + for (const type of types) { + const blob = item.__bbData[type]; + const buffer = await blob.arrayBuffer(); + payloads[type] = btoa(String.fromCharCode(...new Uint8Array(buffer.slice(0, 8)))); + } + result.push({ types, payloads }); + } + captured.push(...result); + (window as unknown as { __clipboardCaptures: typeof captured }).__clipboardCaptures = captured; + }, + }, + }); + (window as unknown as { __clipboardCaptures: typeof captured }).__clipboardCaptures = captured; + }); + + await clickToolbarPopoverItem(page, 'Export', 'To clipboard'); + + await expect(page.getByRole('status', { name: '' })).toHaveText('Copied to clipboard'); + + const captures = await page.evaluate( + () => (window as unknown as { __clipboardCaptures: Array<{ types: string[]; payloads: Record }> }).__clipboardCaptures, + ); + expect(captures).toHaveLength(1); + expect(captures[0].types).toEqual(['image/png']); + // The first 8 bytes of any PNG blob are the PNG signature: 89 50 4E 47 0D 0A 1A 0A. + // Base64 of that signature is 'iVBORw0KGgo='. + expect(captures[0].payloads['image/png']).toBe('iVBORw0KGgo='); + }); + test('resets to a new empty project through the real toolbar flow', async ({ page }) => { const document = createProjectDocument([ createRectangleFixture({ id: 'new-project-rect', x: 140, y: 140, width: 180, height: 120 }), @@ -284,7 +345,7 @@ test.describe('editor file and persistence flows', () => { await expect(page.getByTestId('canvas-name-display')).toHaveText('Loaded Project'); const renamedDownload = await captureDownload(page, async () => { - await page.getByRole('button', { name: 'Export PNG' }).click(); + await clickToolbarPopoverItem(page, 'Export', 'PNG'); }); expect(renamedDownload.suggestedFilename()).toBe('Loaded Project.png'); }); diff --git a/e2e/editor.smoke.spec.ts b/e2e/editor.smoke.spec.ts index d96f676..a938ebb 100644 --- a/e2e/editor.smoke.spec.ts +++ b/e2e/editor.smoke.spec.ts @@ -40,7 +40,7 @@ test.describe('editor smoke flows', () => { await openFreshEditor(page); await expect(page.getByRole('button', { name: 'File', exact: true })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Export PNG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export', exact: true })).toBeVisible(); await expect(page.getByRole('toolbar', { name: 'Tools' })).toBeVisible(); await selectTool(page, 'Rect'); diff --git a/e2e/editor.toolbar.spec.ts b/e2e/editor.toolbar.spec.ts index 1c13a13..b4692ff 100644 --- a/e2e/editor.toolbar.spec.ts +++ b/e2e/editor.toolbar.spec.ts @@ -24,7 +24,7 @@ test.describe('editor toolbar flows', () => { test('renders the top toolbar and opens and closes popovers from the real triggers', async ({ page }) => { await openFreshEditor(page); - await expect(page.getByRole('button', { name: 'Export PNG' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Export', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'File', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: /^Undo/ })).toBeVisible(); await expect(page.getByRole('button', { name: /^Redo/ })).toBeVisible(); @@ -60,7 +60,7 @@ test.describe('editor toolbar flows', () => { 'toolbar-export-cue.json', ); - const exportButton = page.getByRole('button', { name: 'Export PNG' }); + const exportButton = page.getByRole('button', { name: 'Export', exact: true }); const canvasButton = page.getByRole('button', { name: 'File', exact: true }); const exportCue = page.getByTestId('export-bounds-cue'); const cuePanels = [ @@ -90,6 +90,14 @@ test.describe('editor toolbar flows', () => { await canvasButton.focus(); await expect(exportCue).not.toHaveClass(/active/); + + // Opening the export menu should keep the cue active so users see exactly + // what the imminent click is going to capture. + await exportButton.click(); + await expect(page.getByRole('button', { name: 'PNG', exact: true })).toBeVisible(); + await expect(exportCue).toHaveClass(/active/); + await page.keyboard.press('Escape'); + await expect(page.getByRole('button', { name: 'PNG', exact: true })).toHaveCount(0); }); test('keeps action icons visible and updates enabled states through real selection and history flows', async ({ page }) => { diff --git a/src/App.integration.test.tsx b/src/App.integration.test.tsx index 1302ec6..98d78d2 100644 --- a/src/App.integration.test.tsx +++ b/src/App.integration.test.tsx @@ -274,9 +274,8 @@ describe('App integration', () => { render(); await screen.findByRole('button', { name: 'File' }); - const exportButton = screen.getByRole('button', { name: 'Export PNG' }); clickToolbarPopoverItem('File', 'Save'); - fireEvent.click(exportButton); + clickToolbarPopoverItem('Export', 'PNG'); expect(mockDownloadProject).toHaveBeenCalledOnce(); expect(mockDownloadCanvasAsPng).toHaveBeenCalledWith(expect.anything(), 2048, 2048, 1, 'Untitled canvas.png'); diff --git a/src/App.test.tsx b/src/App.test.tsx index b0e87a5..4e38367 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -162,7 +162,7 @@ describe('App shell', () => { it('renders the top toolbar controls', async () => { await renderApp(); - expect(screen.getByRole('button', { name: 'Export PNG' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Export/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'File' })).toBeInTheDocument(); }); diff --git a/src/App.tsx b/src/App.tsx index ef23552..371543b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ export default function App() { const boundsKeyHeld = useKeyHeld('f'); const showExportBoundsCue = canvasFocusActive || exportButtonHovered || boundsKeyHeld; const favoriteStatus = useStatusToast(); + const clipboardStatus = useStatusToast(); const [topbarHeight, setTopbarHeight] = useState(56); const topbarRef = useRef(null); @@ -41,6 +42,7 @@ export default function App() { dispatch, groupSelectedNodes, handleExport, + handleExportToClipboard, handleFontUpload, handleImageUpload, handleNewProject, @@ -139,10 +141,18 @@ export default function App() { onCanvasNameChange={(name) => dispatch({ type: 'set_canvas_name', name })} favoriteStatusFading={favoriteStatus.fading} favoriteStatusMessage={favoriteStatus.message} + clipboardStatusFading={clipboardStatus.fading} + clipboardStatusMessage={clipboardStatus.message} canvasFocusActive={canvasFocusActive} onCanvasFocusToggle={handleCanvasFocusToggle} onDelete={deleteSelectedNodes} onExport={() => handleExport(stageRef.current)} + onExportToClipboard={async () => { + const ok = await handleExportToClipboard(stageRef.current); + if (ok) { + clipboardStatus.show('Copied to clipboard'); + } + }} onExportIntentChange={handleExportIntentChange} onGroup={groupSelectedNodes} onLoad={() => openInputRef.current?.click()} diff --git a/src/app/useEditorController.test.tsx b/src/app/useEditorController.test.tsx index 9f61a6a..e9d6ca7 100644 --- a/src/app/useEditorController.test.tsx +++ b/src/app/useEditorController.test.tsx @@ -16,6 +16,7 @@ const { mockCanvasPersistenceService, mockDownloadProject, mockDownloadCanvasAsPng, + mockCopyCanvasToClipboard, mockImportImageFile, mockReadProjectFile, mockRegisterFontFile, @@ -30,6 +31,7 @@ const { }, mockDownloadProject: vi.fn(), mockDownloadCanvasAsPng: vi.fn(), + mockCopyCanvasToClipboard: vi.fn(), mockImportImageFile: vi.fn(), mockReadProjectFile: vi.fn(), mockRegisterFontFile: vi.fn(), @@ -68,6 +70,7 @@ vi.mock('../editor/persistence/uploadedFontPersistenceService', async () => { vi.mock('../editor/io/exportPng', () => ({ downloadCanvasAsPng: mockDownloadCanvasAsPng, + copyCanvasToClipboard: mockCopyCanvasToClipboard, })); vi.mock('../editor/io/images', async () => { @@ -309,10 +312,29 @@ describe('useEditorController', () => { await act(async () => { await result.current.actions.handleOpenProject(makeFileList(new File(['{}'], 'project.json'))); }); + mockCopyCanvasToClipboard.mockResolvedValue(undefined); + let clipboardOk: boolean | undefined; + let clipboardNoHandle: boolean | undefined; await act(async () => { result.current.actions.handleExport(null); result.current.actions.handleExport(stage); result.current.actions.handleSave(); + clipboardNoHandle = await result.current.actions.handleExportToClipboard(null); + clipboardOk = await result.current.actions.handleExportToClipboard(stage); + }); + expect(clipboardNoHandle).toBe(false); + expect(clipboardOk).toBe(true); + expect(mockCopyCanvasToClipboard).toHaveBeenCalledOnce(); + expect(mockCopyCanvasToClipboard).toHaveBeenCalledWith(stage, 2048, 2048, 1); + + mockCopyCanvasToClipboard.mockRejectedValueOnce(new Error('clipboard nope')); + let clipboardFailed: boolean | undefined; + await act(async () => { + clipboardFailed = await result.current.actions.handleExportToClipboard(stage); + }); + expect(clipboardFailed).toBe(false); + await waitFor(() => { + expect(result.current.state.errorMessage).toBe('Failed to copy to clipboard: clipboard nope'); }); await waitFor(() => { diff --git a/src/app/useEditorController.ts b/src/app/useEditorController.ts index 9e564b8..a22f29e 100644 --- a/src/app/useEditorController.ts +++ b/src/app/useEditorController.ts @@ -137,6 +137,7 @@ export function useEditorController() { handleOpenProject, handleNewProject, handleExport, + handleExportToClipboard, handleSave, } = useFileIOController({ document, @@ -193,6 +194,7 @@ export function useEditorController() { duplicateSelectedNodes: wrappedDuplicateSelectedNodes, groupSelectedNodes, handleExport, + handleExportToClipboard, handleFontUpload, handleImageUpload, handleNewProject, diff --git a/src/app/useFileIOController.ts b/src/app/useFileIOController.ts index 6e78265..0eeffb1 100644 --- a/src/app/useFileIOController.ts +++ b/src/app/useFileIOController.ts @@ -1,6 +1,6 @@ import type { CanvasRendererHandle } from '../editor/rendering/renderer/canvasRendererTypes'; -import { downloadCanvasAsPng } from '../editor/io/exportPng'; +import { copyCanvasToClipboard, downloadCanvasAsPng } from '../editor/io/exportPng'; import { sanitizeBasename } from '../editor/io/filename'; import { importImageFile } from '../editor/io/images'; import { downloadProject, readProjectFile } from '../editor/io/projectFile'; @@ -88,6 +88,20 @@ export function useFileIOController({ void downloadCanvasAsPng(handle, document.canvas.width, document.canvas.height, 1, `${basename}.png`); } + async function handleExportToClipboard(handle: CanvasRendererHandle | null): Promise { + if (!handle) { + return false; + } + try { + await copyCanvasToClipboard(handle, document.canvas.width, document.canvas.height, 1); + setErrorMessage(null); + return true; + } catch (error) { + setErrorMessage(`Failed to copy to clipboard: ${getErrorMessage(error, 'Unknown error.')}`); + return false; + } + } + function handleSave() { const basename = sanitizeBasename(document.name, DEFAULT_CANVAS_NAME); downloadProject(document, `${basename}.json`); @@ -99,6 +113,7 @@ export function useFileIOController({ handleOpenProject, handleNewProject, handleExport, + handleExportToClipboard, handleSave, }; } diff --git a/src/editor/io/exportPng.test.ts b/src/editor/io/exportPng.test.ts index 7b53426..fc4ea63 100644 --- a/src/editor/io/exportPng.test.ts +++ b/src/editor/io/exportPng.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { downloadCanvasAsPng } from './exportPng'; +import { copyCanvasToClipboard, downloadCanvasAsPng } from './exportPng'; import type { CanvasRendererHandle } from '../rendering/renderer/canvasRendererTypes'; describe('downloadCanvasAsPng', () => { @@ -33,3 +33,60 @@ describe('downloadCanvasAsPng', () => { createElementSpy.mockRestore(); }); }); + +describe('copyCanvasToClipboard', () => { + const originalClipboard = navigator.clipboard; + const originalClipboardItem = (globalThis as { ClipboardItem?: unknown }).ClipboardItem; + const originalFetch = globalThis.fetch; + + afterEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + configurable: true, + }); + (globalThis as { ClipboardItem?: unknown }).ClipboardItem = originalClipboardItem; + globalThis.fetch = originalFetch; + }); + + it('writes the rendered canvas to the system clipboard as image/png', async () => { + const blob = new Blob(['png-bytes'], { type: 'image/png' }); + globalThis.fetch = vi.fn(async () => ({ blob: async () => blob })) as unknown as typeof fetch; + + const writes: unknown[][] = []; + const write = vi.fn(async (items: unknown[]) => { + writes.push(items); + }); + Object.defineProperty(navigator, 'clipboard', { + value: { write }, + configurable: true, + }); + + type ClipboardItemCtor = new (data: Record) => { types: string[]; data: Record }; + const ClipboardItemMock = vi.fn(function (this: { types: string[]; data: Record }, data: Record) { + this.types = Object.keys(data); + this.data = data; + }) as unknown as ClipboardItemCtor; + (globalThis as { ClipboardItem?: unknown }).ClipboardItem = ClipboardItemMock; + + const handle: CanvasRendererHandle = { + getContainerElement: vi.fn(() => null), + getPointerPosition: vi.fn(() => null), + exportToDataURL: vi.fn(async () => 'data:image/png;base64,abc123'), + }; + + await copyCanvasToClipboard(handle, 800, 600, 1); + + expect(handle.exportToDataURL).toHaveBeenCalledWith({ + contentWidth: 800, + contentHeight: 600, + pixelRatio: 1, + mimeType: 'image/png', + }); + expect(globalThis.fetch).toHaveBeenCalledWith('data:image/png;base64,abc123'); + expect(write).toHaveBeenCalledOnce(); + const items = writes[0] as Array<{ types: string[]; data: Record }>; + expect(items).toHaveLength(1); + expect(items[0].types).toEqual(['image/png']); + expect(items[0].data['image/png']).toBe(blob); + }); +}); diff --git a/src/editor/io/exportPng.ts b/src/editor/io/exportPng.ts index 3da0b31..f977229 100644 --- a/src/editor/io/exportPng.ts +++ b/src/editor/io/exportPng.ts @@ -18,3 +18,19 @@ export async function downloadCanvasAsPng( anchor.download = fileName; anchor.click(); } + +export async function copyCanvasToClipboard( + handle: CanvasRendererHandle, + contentWidth: number, + contentHeight: number, + pixelRatio: number, +) { + const dataUrl = await handle.exportToDataURL({ + contentWidth, + contentHeight, + pixelRatio, + mimeType: 'image/png', + }); + const blob = await (await fetch(dataUrl)).blob(); + await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); +} diff --git a/src/editor/ui/Toolbar.test.tsx b/src/editor/ui/Toolbar.test.tsx index 3a9bc53..aa789fd 100644 --- a/src/editor/ui/Toolbar.test.tsx +++ b/src/editor/ui/Toolbar.test.tsx @@ -23,6 +23,7 @@ function renderToolbar(overrides: Partial> = {}) onCanvasFocusToggle: vi.fn(), onDelete: vi.fn(), onExport: vi.fn(), + onExportToClipboard: vi.fn(), onGroup: vi.fn(), onInspectorTabChange: vi.fn(), onLoad: vi.fn(), @@ -46,7 +47,7 @@ describe('Toolbar', () => { it('renders the redesigned toolbar controls and always-visible action icons', () => { renderToolbar(); - expect(screen.getByRole('button', { name: 'Export PNG' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Export/ })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'File' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Undo/ })).toBeDisabled(); @@ -78,13 +79,13 @@ describe('Toolbar', () => { expect(onNewProject).toHaveBeenCalledOnce(); }); - it('publishes export-intent activity on hover and focus for the export button', async () => { + it('publishes export-intent activity on hover and focus for the export trigger', async () => { const user = userEvent.setup(); const onExportIntentChange = vi.fn(); renderToolbar({ onExportIntentChange }); - const exportButton = screen.getByRole('button', { name: 'Export PNG' }); + const exportButton = screen.getByRole('button', { name: /^Export/ }); await user.hover(exportButton); expect(onExportIntentChange).toHaveBeenLastCalledWith(true); @@ -99,6 +100,76 @@ describe('Toolbar', () => { expect(onExportIntentChange).toHaveBeenLastCalledWith(false); }); + it('does not leave the export-intent cue lit after a click toggles the menu shut', async () => { + const user = userEvent.setup(); + const onExportIntentChange = vi.fn(); + + renderToolbar({ onExportIntentChange }); + + const exportButton = screen.getByRole('button', { name: /^Export/ }); + + await user.hover(exportButton); + await user.click(exportButton); + // Menu open — cue stays. + expect(onExportIntentChange).toHaveBeenLastCalledWith(true); + + await user.click(exportButton); + // Menu closed by click. Mouse-leave should clear the cue even though the + // browser keeps focus on the trigger after the click. + await user.unhover(exportButton); + expect(onExportIntentChange).toHaveBeenLastCalledWith(false); + }); + + it('keeps export-intent active while the export menu is open', async () => { + const user = userEvent.setup(); + const onExportIntentChange = vi.fn(); + + renderToolbar({ onExportIntentChange }); + + const exportTrigger = screen.getByRole('button', { name: /^Export/ }); + await user.click(exportTrigger); + + expect(onExportIntentChange).toHaveBeenLastCalledWith(true); + }); + + it('routes export menu items through the matching callbacks', async () => { + const user = userEvent.setup(); + const onExport = vi.fn(); + const onExportToClipboard = vi.fn(); + + renderToolbar({ onExport, onExportToClipboard }); + + const exportTrigger = screen.getByRole('button', { name: /^Export/ }); + + // Trigger itself does not fire onExport — it opens the menu. + await user.click(exportTrigger); + expect(onExport).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'PNG' })); + expect(onExport).toHaveBeenCalledOnce(); + expect(onExportToClipboard).not.toHaveBeenCalled(); + + await user.click(exportTrigger); + await user.click(screen.getByRole('button', { name: 'To clipboard' })); + expect(onExportToClipboard).toHaveBeenCalledOnce(); + }); + + it('renders the clipboard status bubble next to the export trigger', () => { + renderToolbar({ clipboardStatusMessage: 'Copied to clipboard' }); + + const bubble = screen.getByText('Copied to clipboard'); + expect(bubble).toHaveClass('top-toolbar-status-bubble'); + }); + + it('applies the fading state to the clipboard status bubble', () => { + renderToolbar({ + clipboardStatusFading: true, + clipboardStatusMessage: 'Copied to clipboard', + }); + + expect(screen.getByText('Copied to clipboard')).toHaveClass('fading'); + }); + it('shows keyboard shortcuts in action button tooltips', () => { renderToolbar(); diff --git a/src/editor/ui/Toolbar.tsx b/src/editor/ui/Toolbar.tsx index 325f3e2..5923284 100644 --- a/src/editor/ui/Toolbar.tsx +++ b/src/editor/ui/Toolbar.tsx @@ -1,10 +1,10 @@ import { useEffect, useId, useRef, useState, type ReactNode } from 'react'; import type { InspectorTab } from './inspector/types'; -import { ToolbarActionButton, ToolbarIcon } from './ToolbarPrimitives'; +import { ToolbarActionButton } from './ToolbarPrimitives'; import { CanvasNameField } from './CanvasNameField'; import { joinClassNames, modKey } from './toolbarUtils'; -import { FileMenu } from './ToolbarMenus'; +import { ExportMenu, FileMenu } from './ToolbarMenus'; interface ToolbarProps { canDelete: boolean; @@ -19,8 +19,11 @@ interface ToolbarProps { onCanvasFocusToggle: () => void; favoriteStatusFading?: boolean; favoriteStatusMessage?: string | null; + clipboardStatusFading?: boolean; + clipboardStatusMessage?: string | null; onDelete: () => void; onExport: () => void; + onExportToClipboard: () => void; onExportIntentChange?: (active: boolean) => void; onGroup: () => void; onLoad: () => void; @@ -51,8 +54,11 @@ export function Toolbar({ onCanvasFocusToggle, favoriteStatusFading = false, favoriteStatusMessage = null, + clipboardStatusFading = false, + clipboardStatusMessage = null, onDelete, onExport, + onExportToClipboard, onExportIntentChange, onGroup, onLoad, @@ -71,14 +77,17 @@ export function Toolbar({ }: ToolbarProps) { const rootRef = useRef(null); const fileTriggerRef = useRef(null); - const [openMenu, setOpenMenu] = useState<'file' | null>(null); + const exportTriggerRef = useRef(null); + const [openMenu, setOpenMenu] = useState<'file' | 'export' | null>(null); const [isExportHovered, setIsExportHovered] = useState(false); const [isExportFocused, setIsExportFocused] = useState(false); const fileMenuId = useId(); + const exportMenuId = useId(); + const exportMenuOpen = openMenu === 'export'; useEffect(() => { - onExportIntentChange?.(isExportHovered || isExportFocused); - }, [isExportFocused, isExportHovered, onExportIntentChange]); + onExportIntentChange?.(isExportHovered || isExportFocused || exportMenuOpen); + }, [exportMenuOpen, isExportFocused, isExportHovered, onExportIntentChange]); useEffect(() => () => onExportIntentChange?.(false), [onExportIntentChange]); @@ -106,8 +115,9 @@ export function Toolbar({ function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { event.preventDefault(); + const triggerToRefocus = openMenu === 'export' ? exportTriggerRef : fileTriggerRef; setOpenMenu(null); - window.requestAnimationFrame(() => fileTriggerRef.current?.focus()); + window.requestAnimationFrame(() => triggerToRefocus.current?.focus()); return; } @@ -120,8 +130,8 @@ export function Toolbar({ return () => document.removeEventListener('keydown', handleKeyDown, true); }, [openMenu]); - function toggleMenu() { - setOpenMenu((current) => (current === 'file' ? null : 'file')); + function toggleMenu(menu: 'file' | 'export') { + setOpenMenu((current) => (current === menu ? null : menu)); } function closeMenu() { @@ -138,22 +148,54 @@ export function Toolbar({ return (
- +
+
+ + {exportMenuOpen ? ( + + ) : null} +
+ {clipboardStatusMessage ? ( +
+ {clipboardStatusMessage} +
+ ) : null} +
); } + +interface ExportMenuProps { + menuId: string; + onExportPng: () => void; + onExportToClipboard: () => void; + createMenuActionHandler: (action: () => void) => () => void; +} + +export function ExportMenu({ + menuId, + onExportPng, + onExportToClipboard, + createMenuActionHandler, +}: ExportMenuProps) { + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/src/styles/toolbar.css b/src/styles/toolbar.css index 128e4f1..716edf1 100644 --- a/src/styles/toolbar.css +++ b/src/styles/toolbar.css @@ -78,6 +78,20 @@ inset 0 1px 0 rgba(255, 255, 255, 0.12); } +.top-toolbar-popover.open > .top-toolbar-button-export { + border-color: transparent; + background: linear-gradient(135deg, rgba(59, 130, 246, 1), rgba(8, 145, 178, 0.98)); + box-shadow: + 0 12px 24px rgba(6, 182, 212, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.12); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.top-toolbar .top-toolbar-button-export .top-toolbar-menu-caret { + color: rgba(255, 255, 255, 0.85); +} + .top-toolbar-svg-icon { display: inline-flex; align-items: center;