* 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>
281 lines
9.1 KiB
TypeScript
281 lines
9.1 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
|
|
import { expectInViewport } from '#tests/e2e/fixtures'
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
|
|
let page: Page
|
|
let canvas: CanvasHelper
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
page = await browser.newPage()
|
|
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 page.getByTestId('export-item')
|
|
}
|
|
|
|
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 page.getByTestId('export-section-add').click()
|
|
await canvas.waitForRender()
|
|
|
|
const after = await exportItems().count()
|
|
expect(after).toBe(before + 1)
|
|
await expect(exportButton()).toContainText('Export rect 1')
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('remove export row decreases row count', async () => {
|
|
await page.getByTestId('export-section-add').click()
|
|
await canvas.waitForRender()
|
|
|
|
const before = await exportItems().count()
|
|
await exportItems().first().locator('button').last().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().getByTestId('app-select-trigger').last()
|
|
await formatTrigger.hover()
|
|
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toBeVisible()
|
|
await formatTrigger.click()
|
|
await expect(page.locator('[role=tooltip]').filter({ hasText: 'Export format' })).toHaveCount(0)
|
|
|
|
const jpgOption = page.locator('[role="option"]').filter({ hasText: 'JPG' })
|
|
await expect(jpgOption).toBeVisible()
|
|
await expectInViewport(page, jpgOption)
|
|
await jpgOption.click()
|
|
await canvas.waitForRender()
|
|
|
|
await expect(formatTrigger).toHaveText('JPG')
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('format selector does not offer FIG as an export format', async () => {
|
|
const formatTrigger = exportItems().first().getByTestId('app-select-trigger').last()
|
|
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().getByTestId('app-select-trigger').last()
|
|
await formatTrigger.click()
|
|
|
|
await page.locator('[role="option"]').filter({ hasText: 'SVG' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
const selects = exportItems().first().getByTestId('app-select-trigger')
|
|
await expect(selects).toHaveCount(1)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
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).getByTestId('app-select-trigger').last()
|
|
await firstRowFormat.click()
|
|
await page.locator('[role="option"]').filter({ hasText: 'JPG' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
const secondRowFormat = exportItems().nth(1).getByTestId('app-select-trigger').last()
|
|
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().getByTestId('app-select-trigger').last()
|
|
await formatTrigger.click()
|
|
await page.locator('[role="option"]').filter({ hasText: 'PNG' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
await page.getByTestId('export-preview-toggle').click()
|
|
|
|
const img = page.getByTestId('export-section').locator('img')
|
|
await expect(img).toBeVisible({ timeout: 10000 })
|
|
|
|
const src = await img.getAttribute('src')
|
|
expect(src).toMatch(/^blob:/)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('multi-select add and edit applies to all selected layers', async () => {
|
|
await createRectangles(2)
|
|
|
|
await page.getByTestId('export-section-add').click()
|
|
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().getByTestId('export-scale-input')
|
|
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(page.getByTestId('export-section')).toContainText('Mixed')
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('undo reverts export setting edits', async () => {
|
|
await createRectangles(2)
|
|
|
|
await page.getByTestId('export-section-add').click()
|
|
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()
|
|
})
|