openpencil/tests/engine/io/fig/roundtrip/export-settings.test.ts

154 lines
4.9 KiB
TypeScript
Raw Normal View History

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
import { beforeAll, describe, expect, test } from 'bun:test'
import {
exportFigFile,
importNodeChanges,
initCodec,
parseFigFile,
SceneGraph,
type NodeChange
} from '@open-pencil/core'
2026-06-11 21:09:54 +00:00
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { MAX_EXPORT_SCALE } from '@open-pencil/scene-graph'
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 decodeExport(bytes: Uint8Array) {
return parseFigBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
}
function doc(): NodeChange {
return {
guid: { sessionID: 0, localID: 0 },
type: 'DOCUMENT',
name: 'Document',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function canvas(): NodeChange {
return {
guid: { sessionID: 0, localID: 1 },
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
type: 'CANVAS',
name: 'Page 1',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
} as NodeChange
}
function frame(overrides: Partial<NodeChange> = {}): NodeChange {
return {
guid: { sessionID: 1, localID: 10 },
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
type: 'FRAME',
name: 'Export settings frame',
visible: true,
opacity: 1,
phase: 'CREATED',
size: { x: 100, y: 100 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
...overrides
} as NodeChange
}
describe('fig roundtrip export settings', () => {
beforeAll(async () => {
await initCodec()
})
test('persists per-node export settings losslessly through plugin data', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, {
name: 'Exportable rect',
exportSettings: [
{ scale: 2, format: 'png' },
{ scale: 3, format: 'webp' }
]
})
const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer)
const reimportedRect = [...reimported.getAllNodes()].find((node) => node.name === rect.name)
expect(reimportedRect?.exportSettings).toEqual(rect.exportSettings)
})
test('maps native Figma PNG content-scale settings when plugin data is absent', () => {
const graph = importNodeChanges([
doc(),
canvas(),
frame({
exportSettings: [
{
imageType: 'PNG',
constraint: { type: 'CONTENT_SCALE', value: 2 }
},
{
imageType: 'GIF',
constraint: { type: 'CONTENT_SCALE', value: 4 }
}
]
})
])
const importedFrame = [...graph.getAllNodes()].find(
(node) => node.name === 'Export settings frame'
)
expect(importedFrame?.exportSettings).toEqual([{ scale: 2, format: 'png' }])
})
test('does not serialize export settings plugin data for a default node', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, { name: 'Default export settings rect' })
const decoded = decodeExport(await exportFigFile(graph))
const exportedRect = decoded.nodeChanges.find((nodeChange) => nodeChange.name === rect.name)
expect(
exportedRect?.pluginData?.some(
(entry) => entry.pluginID === 'open-pencil' && entry.key === 'exportSettings'
) ?? false
).toBe(false)
})
test('does not resurrect native export settings after the user clears all rows', async () => {
// Imported node carries NATIVE export settings (no plugin override).
const graph = importNodeChanges([
doc(),
canvas(),
frame({
exportSettings: [{ imageType: 'PNG', constraint: { type: 'CONTENT_SCALE', value: 2 } }]
})
])
const node = [...graph.getAllNodes()].find((n) => n.name === 'Export settings frame')
if (!node) throw new Error('imported frame not found')
expect(node.exportSettings).toEqual([{ scale: 2, format: 'png' }])
// User removes every export row; the raw native settings must be dropped so they
// don't come back via the import fallback on reopen.
graph.updateNode(node.id, { exportSettings: [] })
expect(node.source.fig.rawNodeFields?.exportSettings).toBeUndefined()
const reimported = await parseFigFile((await exportFigFile(graph)).buffer as ArrayBuffer)
const reNode = [...reimported.getAllNodes()].find((n) => n.name === 'Export settings frame')
expect(reNode?.exportSettings).toEqual([])
})
test('clamps an out-of-range native export scale at the import boundary', () => {
const graph = importNodeChanges([
doc(),
canvas(),
frame({
exportSettings: [{ imageType: 'PNG', constraint: { type: 'CONTENT_SCALE', value: 999999 } }]
})
])
const node = [...graph.getAllNodes()].find((n) => n.name === 'Export settings frame')
expect(node?.exportSettings).toEqual([{ scale: MAX_EXPORT_SCALE, format: 'png' }])
})
})