Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/canvas-interaction-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
65 changes: 63 additions & 2 deletions e2e/editor.files-and-persistence.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }> = [];
const originalClipboardItem = window.ClipboardItem;
const PatchedClipboardItem = function (this: object, data: Record<string, Blob>) {
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<string, Blob> }>) => {
const result: Array<{ types: string[]; payloads: Record<string, string> }> = [];
for (const item of items) {
const types = Object.keys(item.__bbData);
const payloads: Record<string, string> = {};
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<string, string> }> }).__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 }),
Expand Down Expand Up @@ -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');
});
Expand Down
2 changes: 1 addition & 1 deletion e2e/editor.smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
12 changes: 10 additions & 2 deletions e2e/editor.toolbar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 }) => {
Expand Down
3 changes: 1 addition & 2 deletions src/App.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,8 @@ describe('App integration', () => {
render(<App />);
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');
Expand Down
2 changes: 1 addition & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement | null>(null);

Expand All @@ -41,6 +42,7 @@ export default function App() {
dispatch,
groupSelectedNodes,
handleExport,
handleExportToClipboard,
handleFontUpload,
handleImageUpload,
handleNewProject,
Expand Down Expand Up @@ -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()}
Expand Down
22 changes: 22 additions & 0 deletions src/app/useEditorController.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const {
mockCanvasPersistenceService,
mockDownloadProject,
mockDownloadCanvasAsPng,
mockCopyCanvasToClipboard,
mockImportImageFile,
mockReadProjectFile,
mockRegisterFontFile,
Expand All @@ -30,6 +31,7 @@ const {
},
mockDownloadProject: vi.fn(),
mockDownloadCanvasAsPng: vi.fn(),
mockCopyCanvasToClipboard: vi.fn(),
mockImportImageFile: vi.fn(),
mockReadProjectFile: vi.fn(),
mockRegisterFontFile: vi.fn(),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/useEditorController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function useEditorController() {
handleOpenProject,
handleNewProject,
handleExport,
handleExportToClipboard,
handleSave,
} = useFileIOController({
document,
Expand Down Expand Up @@ -193,6 +194,7 @@ export function useEditorController() {
duplicateSelectedNodes: wrappedDuplicateSelectedNodes,
groupSelectedNodes,
handleExport,
handleExportToClipboard,
handleFontUpload,
handleImageUpload,
handleNewProject,
Expand Down
17 changes: 16 additions & 1 deletion src/app/useFileIOController.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<boolean> {
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`);
Expand All @@ -99,6 +113,7 @@ export function useFileIOController({
handleOpenProject,
handleNewProject,
handleExport,
handleExportToClipboard,
handleSave,
};
}
61 changes: 59 additions & 2 deletions src/editor/io/exportPng.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, Blob>) => { types: string[]; data: Record<string, Blob> };
const ClipboardItemMock = vi.fn(function (this: { types: string[]; data: Record<string, Blob> }, data: Record<string, Blob>) {
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<string, Blob> }>;
expect(items).toHaveLength(1);
expect(items[0].types).toEqual(['image/png']);
expect(items[0].data['image/png']).toBe(blob);
});
});
Loading
Loading