openpencil/tests/e2e/export/basic.spec.ts

284 lines
9.4 KiB
TypeScript
Raw Normal View History

import { test, expect, type Page } from '@playwright/test'
import { expectInViewport } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertyItems, propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await page.setViewportSize({ width: 1440, height: 1100 })
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
await canvas.clearCanvas()
await canvas.drawRect(200, 200, 100, 100)
})
test.afterAll(async () => {
await page.close()
})
function exportItems() {
return propertyItems(page, 'exportSettings')
}
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
function exportButton() {
return page.getByTestId('export-button')
}
async function createRectangles(count: number, settings: unknown[][] = []) {
const ids = await page.evaluate(
({ count: nodeCount, settingsByNode }) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
for (const node of store.graph.getChildren(store.state.currentPageId)) {
store.graph.deleteNode(node.id)
}
const ids: string[] = []
for (let i = 0; i < nodeCount; i++) {
const node = store.graph.createNode('RECTANGLE', store.state.currentPageId, {
name: `Export rect ${i + 1}`,
x: 100 + i * 140,
y: 100,
width: 100,
height: 100,
exportSettings: settingsByNode[i] ?? []
})
ids.push(node.id)
}
store.select(ids)
store.requestRender()
return ids
},
{ count, settingsByNode: settings }
)
await canvas.waitForRender()
return ids
}
async function selectedExportSettings() {
return page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
return [...store.state.selectedIds].map((id) => store.graph.getNode(id)?.exportSettings ?? [])
})
}
test('new selection starts with empty export settings', async () => {
await createRectangles(1)
await expect(exportItems()).toHaveCount(0)
await expect(exportButton()).toHaveCount(0)
await expect(page.getByTestId('export-preview-toggle')).toHaveCount(0)
canvas.assertNoErrors()
})
test('add export row increases row count', async () => {
const before = await exportItems().count()
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
const after = await exportItems().count()
expect(after).toBe(before + 1)
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await expect(exportButton()).toContainText('Export rect 1')
canvas.assertNoErrors()
})
test('remove export row decreases row count', async () => {
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
await canvas.waitForRender()
const before = await exportItems().count()
await exportItems().first().hover()
await exportItems().first().getByRole('button', { name: 'Remove export' }).click()
await canvas.waitForRender()
const after = await exportItems().count()
expect(after).toBe(before - 1)
canvas.assertNoErrors()
})
test('format selector changes to JPG', async () => {
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await expect(formatTrigger).toHaveAttribute('aria-label', 'Export format')
await formatTrigger.click()
for (const label of ['PNG', 'JPG', 'WEBP', 'SVG', 'PDF']) {
await expect(page.locator('[role="option"]').filter({ hasText: label })).toBeVisible()
}
const jpgOption = page.locator('[role="option"]').filter({ hasText: 'JPG' })
await expectInViewport(page, jpgOption)
await jpgOption.click()
await canvas.waitForRender()
await expect(formatTrigger).toHaveText('JPG')
canvas.assertNoErrors()
})
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
test('format selector does not offer FIG as an export format', async () => {
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await formatTrigger.click()
await expect(page.locator('[role="option"]').filter({ hasText: '.fig' })).toHaveCount(0)
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
canvas.assertNoErrors()
})
test('SVG format hides scale selector', async () => {
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()
await page.locator('[role="option"]').filter({ hasText: 'SVG' }).click()
await canvas.waitForRender()
const selects = exportItems().first().getByRole('combobox', { name: 'Export format' })
await expect(selects).toHaveCount(1)
canvas.assertNoErrors()
})
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
test('format selector works with multiple export rows', async () => {
await createRectangles(1, [
[
{ scale: 1, format: 'png' },
{ scale: 1, format: 'svg' }
]
])
const firstRowFormat = exportItems().nth(0).getByRole('combobox', { name: 'Export format' })
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await firstRowFormat.click()
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
await canvas.waitForRender()
const secondRowFormat = exportItems().nth(1).getByRole('combobox', { name: 'Export format' })
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await secondRowFormat.click()
await page.locator('[role="option"]').filter({ hasText: 'PDF' }).click()
await canvas.waitForRender()
expect(await selectedExportSettings()).toEqual([
[
{ scale: 1, format: 'jpg' },
{ scale: 1, format: 'pdf' }
]
])
canvas.assertNoErrors()
})
// The app prefers the File System Access save dialog when available, which never
// resolves in a headless browser. Unset it so the export falls back to the
// anchor-download path, which Playwright can capture as a `download` event.
async function forceBlobDownload() {
await page.evaluate(() => {
window.showSaveFilePicker = undefined
})
}
// `createRectangles` makes fill-less rectangles, which have no visual bounds and
// export to nothing. Use createShape so the rectangle has a real fill and is
// actually exportable, then attach the export settings under test.
async function createExportableRect(settings: { scale: number; format: string }[]) {
await page.evaluate((nodeSettings) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
for (const node of store.graph.getChildren(store.state.currentPageId)) {
store.graph.deleteNode(node.id)
}
const id = store.createShape('RECTANGLE', 100, 100, 100, 100)
store.graph.updateNode(id, { name: 'Export rect 1', exportSettings: nodeSettings })
store.select([id])
store.requestRender()
}, settings)
await canvas.waitForRender()
}
test('multiple export formats download as a single zip', async () => {
await createExportableRect([
{ scale: 1, format: 'png' },
{ scale: 1, format: 'svg' }
])
await forceBlobDownload()
const [download] = await Promise.all([page.waitForEvent('download'), exportButton().click()])
expect(download.suggestedFilename()).toBe('Export rect 1.zip')
canvas.assertNoErrors()
})
test('a single export format downloads the file directly', async () => {
await createExportableRect([{ scale: 1, format: 'png' }])
await forceBlobDownload()
const [download] = await Promise.all([page.waitForEvent('download'), exportButton().click()])
expect(download.suggestedFilename()).toBe('Export rect 1@1x.png')
canvas.assertNoErrors()
})
test('preview toggle shows image with blob src', async () => {
const formatTrigger = exportItems().first().getByRole('combobox', { name: 'Export format' })
await formatTrigger.click()
await page.locator('[role="option"]').filter({ hasText: 'PNG' }).click()
await canvas.waitForRender()
await page.getByTestId('export-preview-toggle').click()
const img = propertySection(page, 'Export').locator('img')
await expect(img).toBeVisible({ timeout: 10000 })
const src = await img.getAttribute('src')
expect(src).toMatch(/^blob:/)
canvas.assertNoErrors()
})
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
test('multi-select add and edit applies to all selected layers', async () => {
await createRectangles(2)
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await canvas.waitForRender()
await expect(exportButton()).toContainText('Export 2 layers')
expect(await selectedExportSettings()).toEqual([
[{ scale: 1, format: 'png' }],
[{ scale: 1, format: 'png' }]
])
const scaleInput = exportItems().first().getByRole('textbox', { name: 'Export scale' })
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await scaleInput.fill('2.5x')
await scaleInput.press('Enter')
await canvas.waitForRender()
expect(await selectedExportSettings()).toEqual([
[{ scale: 2.5, format: 'png' }],
[{ scale: 2.5, format: 'png' }]
])
canvas.assertNoErrors()
})
test('mixed export settings are indicated', async () => {
await createRectangles(2, [[{ scale: 1, format: 'png' }], [{ scale: 2, format: 'jpg' }]])
await expect(propertySection(page, 'Export')).toContainText('Mixed')
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
canvas.assertNoErrors()
})
test('undo reverts export setting edits', async () => {
await createRectangles(2)
await propertySection(page, 'Export').getByRole('button', { name: 'Add export' }).click()
feat(export): persist per-node export settings * fix(ui): anchor AppSelect dropdown to its trigger AppSelect wraps its SelectTrigger inside <Tip> (a TooltipTrigger as-child). reka-ui's Select popper captures its trigger element via useForwardExpose on mount, but that capture resolves to null when the trigger sits inside another primitive's as-child slot. With no anchor, floating-ui positioned the menu at the viewport origin and flipped it off-screen (~y:-412), so clicking the dropdown appeared to do nothing. Wrap the trigger in a layout-neutral span, keep the styled <Tip> tooltip, and pass that span to SelectContent's `reference` prop so the popper anchors explicitly on every open (PopperContent prefers props.reference over the broken auto-capture, and re-reads it on each open/reopen). Fixes every dropdown built on AppSelect across the inspector (export scale/format, typography, effects, stroke, flex/grid layout, variants, fills, gradients, color format). Verified live: menu opens directly below the trigger, fully on-screen, across repeated open/select/reopen cycles; tooltip still shows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): editable custom scale with 1024x cap The export scale was a preset-only dropdown (0.5x–4x). Make it Figma-like: an editable field where you can type any multiplier (e.g. 9x, 1.5x) in addition to picking a preset from the chevron menu. Custom values are used as-is and are never added to the preset list. Typed input is clamped to [0.01x, 1024x] — an unbounded multiplier would allocate an enormous canvas and crash the renderer. The clamp lives in the export data model (clampExportScale, applied in updateScale) so it defends every caller, and is reused by the input for immediate display feedback (9999999 -> 1024x, 0.0000001 -> 0.01x). Invalid/zero input reverts. New ExportScaleInput.vue pairs a text input with a reka DropdownMenu for presets (mirrors ZoomDropdown; not wrapped in <Tip>, so it positions correctly). The active preset shows a checkmark. Verified live across custom entry, clamping (both bounds), decimals, invalid input, and preset selection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(export): store export settings per node Export settings were global and transient: every selection showed a default 1x PNG row, and nothing was remembered per layer or persisted with the document. Store export settings on each SceneNode (exportSettings: ExportSetting[]), defaulting to empty so the panel shows only its header and add button until the user adds a row. Settings apply across multi-select and target the current page when nothing is selected; add/edit/remove are undoable. Persist settings with the document via open-pencil pluginData in .fig (lossless, including webp). On import, prefer app pluginData; otherwise map native Figma exportSettings (PNG/JPEG/SVG/PDF + content scale) without overwriting raw native fields on re-export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): working format dropdown, JPG/WEBP export, and zip bundling - Anchor AppSelect dropdown via native title so the format selector is clickable in multi-row export panels (was rendering off-screen) - Add a browser-canvas encode fallback on the renderer for JPG/WEBP, which CanvasKit's encodeToBytes returns null for in this build (fixes the "Nothing to export" error) - Bundle multi-format exports into a single zip; a single export still downloads the file directly - Default each added export row to 2x the previous scale (1x -> 2x -> 4x) - Add e2e coverage for the multi-row dropdown, zip bundling, and direct single-file download Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * revert(export): drop AppSelect native-title workaround, superseded by #325 The shared <Tip> path is now handled generally by #325; #321 should not carry the AppSelect change. Reverts src/components/ui/AppSelect.vue to master. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(export): address review feedback (#321) - Clear raw native exportSettings when the user edits/clears export rows so they don't resurrect from the import fallback on reopen (scene-graph/source-metadata). - Clamp export scale at the .fig import boundary (plugin + native CONTENT_SCALE), not just in the UI; centralize the bounds in core/scene-graph/export-scale and reuse them from the vue helpers (single source of truth). - Export the rows the panel shows (activeSettings) for every target so a multi-selection is WYSIWYG — no hidden rows export, and the "mixed" notice shows whenever targets diverge. - Sanitize zip entry names (strip separators, parent refs, control chars) so layer names can't escape or corrupt the archive. - Verify toDataURL() honored the requested MIME in the JPEG/WEBP fallback; reject a silent PNG so we never write PNG bytes under a .jpg/.webp extension. Add regression tests for the native-settings clear and import-scale clamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 17:05:34 +00:00
await canvas.waitForRender()
expect(await selectedExportSettings()).toEqual([
[{ scale: 1, format: 'png' }],
[{ scale: 1, format: 'png' }]
])
await canvas.undo()
expect(await selectedExportSettings()).toEqual([[], []])
await expect(exportItems()).toHaveCount(0)
canvas.assertNoErrors()
})