From 0001802dda95749df2be80e4256bdac3072c5bd3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Mar 2026 01:58:05 +0300 Subject: [PATCH] Add shared IO format registry --- CHANGELOG.md | 4 + packages/cli/src/commands/convert.ts | 56 ++++ packages/cli/src/commands/eval.ts | 9 +- packages/cli/src/commands/export.ts | 135 +++++----- packages/cli/src/commands/find.ts | 2 +- packages/cli/src/commands/formats.ts | 80 ++++++ packages/cli/src/commands/info.ts | 2 +- packages/cli/src/commands/node.ts | 2 +- packages/cli/src/commands/pages.ts | 4 +- packages/cli/src/commands/query.ts | 4 +- packages/cli/src/commands/tree.ts | 2 +- packages/cli/src/commands/variables.ts | 2 +- packages/cli/src/headless.ts | 34 +-- packages/cli/src/index.ts | 6 +- packages/core/package.json | 10 + packages/core/src/bezier-math.ts | 14 +- packages/core/src/figma-api.ts | 3 +- packages/core/src/index.ts | 3 + packages/core/src/io/formats.ts | 276 ++++++++++++++++++++ packages/core/src/io/index.ts | 32 +++ packages/core/src/io/registry.ts | 79 ++++++ packages/core/src/io/subgraph.ts | 202 ++++++++++++++ packages/core/src/io/types.ts | 123 +++++++++ packages/core/src/render-image.ts | 3 +- packages/core/src/tools/vector.ts | 3 +- packages/vue/src/controls/useExport.ts | 22 +- src/automation/figma-factory.ts | 3 +- src/automation/server.ts | 4 +- src/components/MobileHud.vue | 2 +- src/components/properties/ExportSection.vue | 29 +- src/composables/use-app-menu.ts | 24 +- src/composables/use-keyboard.ts | 2 +- src/composables/use-menu.ts | 2 +- src/stores/editor.ts | 116 ++++---- 34 files changed, 1099 insertions(+), 195 deletions(-) create mode 100644 packages/cli/src/commands/convert.ts create mode 100644 packages/cli/src/commands/formats.ts create mode 100644 packages/core/src/io/formats.ts create mode 100644 packages/core/src/io/index.ts create mode 100644 packages/core/src/io/registry.ts create mode 100644 packages/core/src/io/subgraph.ts create mode 100644 packages/core/src/io/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9655924d8..e7804caee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ - Resume pen drawing from existing open path endpoints — click an endpoint to continue the curve - Close open paths by dragging one endpoint to the other - Align selected anchor points relative to each other in vector edit mode — the standard alignment buttons in the position panel now operate on selected vertices when 2 or more are selected +- Unified core IO format registry — `.fig` is now modeled as the native document format alongside shared export adapters for PNG, JPG, WEBP, SVG, and JSX +- Export selection or current page as `.fig` from the app export UI and app menu +- New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats +- CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer ### Fixes diff --git a/packages/cli/src/commands/convert.ts b/packages/cli/src/commands/convert.ts new file mode 100644 index 000000000..04673dcf9 --- /dev/null +++ b/packages/cli/src/commands/convert.ts @@ -0,0 +1,56 @@ +import { basename, extname, resolve } from 'node:path' + +import { defineCommand } from 'citty' + +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' + +import { requireFile } from '../app-client' +import { ok, printError } from '../format' +import { loadDocument } from '../headless' + +const io = new IORegistry(BUILTIN_IO_FORMATS) +const WRITABLE_FORMATS = ['FIG'] as const + +type WritableFormat = (typeof WRITABLE_FORMATS)[number] + +function defaultOutput(file: string, format: WritableFormat): string { + const base = basename(file, extname(file)) + return resolve(`${base}.${format.toLowerCase()}`) +} + +export default defineCommand({ + meta: { description: 'Convert a document to another writable format' }, + args: { + file: { + type: 'positional', + description: 'Input document file path', + required: true + }, + output: { + type: 'string', + alias: 'o', + description: 'Output file path (default: .)', + required: false + }, + format: { + type: 'string', + alias: 'f', + description: 'Output format: fig (default: fig)', + default: 'fig' + } + }, + async run({ args }) { + const format = args.format.toUpperCase() as WritableFormat + if (!WRITABLE_FORMATS.includes(format)) { + printError(`Invalid format "${args.format}". Use fig.`) + process.exit(1) + } + + const file = requireFile(args.file) + const graph = await loadDocument(file) + const result = await io.writeDocument(format.toLowerCase(), graph) + const output = args.output ? resolve(args.output) : defaultOutput(file, format) + await Bun.write(output, result.data as Uint8Array) + console.log(ok(`Converted ${file} → ${output}`)) + } +}) diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index c6a91c530..bccf2dc0c 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -28,7 +28,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' }, @@ -89,10 +89,11 @@ export default defineCommand({ } if (args.write || args.output) { - const { exportFigFile } = await import('@open-pencil/core') + const { BUILTIN_IO_FORMATS, IORegistry } = await import('@open-pencil/core') + const io = new IORegistry(BUILTIN_IO_FORMATS) const outPath = args.output ? args.output : file - const data = await exportFigFile(graph) - await Bun.write(outPath, new Uint8Array(data)) + const result = await io.writeDocument('fig', graph) + await Bun.write(outPath, result.data as Uint8Array) if (!args.quiet) { console.error(`Written to ${outPath}`) } diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index cd2dd33d0..dd317c3a1 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -2,16 +2,17 @@ import { basename, extname, resolve } from 'node:path' import { defineCommand } from 'citty' -import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core' +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' import { isAppMode, requireFile, rpc } from '../app-client' import { ok, printError } from '../format' -import { loadDocument, exportNodes, exportThumbnail } from '../headless' +import { loadDocument } from '../headless' -import type { ExportFormat, JSXFormat } from '@open-pencil/core' +import type { RasterExportFormat } from '@open-pencil/core' +const io = new IORegistry(BUILTIN_IO_FORMATS) const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP'] -const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX'] +const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX', 'FIG'] const JSX_STYLES = ['openpencil', 'tailwind'] interface ExportArgs { @@ -48,17 +49,9 @@ async function exportViaApp(format: string, args: ExportArgs) { return } - if (format === 'JSX') { - const result = await rpc<{ jsx: string }>('export_jsx', { - nodeIds: args.node ? [args.node] : undefined, - style: args.style - }) - if (!result.jsx) { - printError('Nothing to export.') - process.exit(1) - } - await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx) - return + if (format === 'JSX' || format === 'FIG') { + printError(`${format} export is only available in file mode right now.`) + process.exit(1) } const result = await rpc<{ base64: string }>('export', { @@ -71,6 +64,15 @@ async function exportViaApp(format: string, args: ExportArgs) { await writeAndLog(resolve(args.output ?? `export.${ext}`), data) } +function exportFileName(defaultName: string, extension: string, scale?: number): string { + return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}` +} + +function targetLabel(pageName?: string, nodeId?: string): string { + if (nodeId) return `node ${nodeId}` + return pageName ? `page "${pageName}"` : 'first page' +} + async function exportFromFile(format: string, args: ExportArgs) { const file = requireFile(args.file) const graph = await loadDocument(file) @@ -78,65 +80,62 @@ async function exportFromFile(format: string, args: ExportArgs) { const pages = graph.getPages() const page = args.page ? pages.find((p) => p.name === args.page) : pages[0] if (!page) { - printError(`Page "${args.page}" not found.`) + const available = pages.map((p) => `"${p.name}"`).join(', ') + printError( + args.page + ? `Page "${args.page}" not found. Available pages: ${available || 'none'}.` + : 'Document has no pages.' + ) process.exit(1) } const defaultName = basename(file, extname(file)) - if (format === 'JSX') { - const nodeIds = args.node ? [args.node] : page.childIds - const jsxStr = - nodeIds.length === 1 - ? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat) - : selectionToJSX(nodeIds, graph, args.style as JSXFormat) - if (!jsxStr) { - printError('Nothing to export (empty page or no visible nodes).') - process.exit(1) - } - await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr) - return - } - - const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase() - const output = resolve(args.output ?? `${defaultName}.${ext}`) - - if (format === 'SVG') { - const nodeIds = args.node ? [args.node] : page.childIds - const svgStr = renderNodesToSVG(graph, page.id, nodeIds) - if (!svgStr) { - printError('Nothing to export (empty page or no visible nodes).') - process.exit(1) - } - await writeAndLog(output, svgStr) - return - } - - let data: Uint8Array | null - if (args.thumbnail) { - data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height)) - } else { - const nodeIds = args.node ? [args.node] : page.childIds - data = await exportNodes(graph, page.id, nodeIds, { - scale: Number(args.scale), - format: format as ExportFormat, - quality: args.quality ? Number(args.quality) : undefined - }) - } - - if (!data) { - printError('Nothing to export (empty page or no visible nodes).') + if (args.page && args.node) { + printError('--page and --node cannot be used together.') process.exit(1) } - await writeAndLog(output, data) + + const target = args.node + ? { scope: 'node' as const, nodeId: args.node } + : { scope: 'page' as const, pageId: page.id } + + if (args.thumbnail) { + printError('Thumbnail export is not supported by the shared file export path yet.') + process.exit(1) + } + + const formatId = format.toLowerCase() + let options: { format?: string; scale?: number; quality?: number } | undefined + if (format === 'JSX') { + options = { format: args.style } + } else if (format === 'PNG' || format === 'JPG' || format === 'WEBP') { + options = { + format, + scale: Number(args.scale), + quality: args.quality ? Number(args.quality) : undefined + } + } + + const result = await io.exportContent(formatId, { graph, target }, options) + const output = resolve( + args.output ?? + exportFileName( + defaultName, + result.extension, + format === 'PNG' || format === 'JPG' || format === 'WEBP' ? Number(args.scale) : undefined + ) + ) + await writeAndLog(output, result.data as string | Uint8Array) + console.log(ok(`Target: ${targetLabel(args.page, args.node)}`)) } export default defineCommand({ - meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' }, + meta: { description: 'Export a document to PNG, JPG, WEBP, SVG, JSX, or .fig' }, args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, output: { @@ -148,7 +147,7 @@ export default defineCommand({ format: { type: 'string', alias: 'f', - description: 'Export format: png, jpg, webp, svg, jsx (default: png)', + description: 'Export format: png, jpg, webp, svg, jsx, fig (default: png)', default: 'png' }, scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' }, @@ -158,10 +157,14 @@ export default defineCommand({ description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false }, - page: { type: 'string', description: 'Page name (default: first page)', required: false }, + page: { + type: 'string', + description: 'Export a specific page by name (default: first page)', + required: false + }, node: { type: 'string', - description: 'Node ID to export (default: all top-level nodes)', + description: 'Export a specific node by ID (cannot be combined with --page)', required: false }, style: { @@ -174,9 +177,9 @@ export default defineCommand({ height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' } }, async run({ args }) { - const format = args.format.toUpperCase() as ExportFormat | 'JSX' + const format = args.format.toUpperCase() as RasterExportFormat | 'SVG' | 'JSX' | 'FIG' if (!ALL_FORMATS.includes(format)) { - printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`) + printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, jsx, or fig.`) process.exit(1) } diff --git a/packages/cli/src/commands/find.ts b/packages/cli/src/commands/find.ts index be47a0753..edfe9082d 100644 --- a/packages/cli/src/commands/find.ts +++ b/packages/cli/src/commands/find.ts @@ -28,7 +28,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, name: { type: 'string', description: 'Node name (partial match, case-insensitive)' }, diff --git a/packages/cli/src/commands/formats.ts b/packages/cli/src/commands/formats.ts new file mode 100644 index 000000000..a6fb374ba --- /dev/null +++ b/packages/cli/src/commands/formats.ts @@ -0,0 +1,80 @@ +import { defineCommand } from 'citty' + +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' + +import { bold, fmtList, kv } from '../format' + +const io = new IORegistry(BUILTIN_IO_FORMATS) + +function supportLabels(format: ReturnType[number]): string[] { + const labels: string[] = [] + if (format.support.readDocument) labels.push('read') + if (format.support.writeDocument) labels.push('write') + if (format.support.exportDocument) labels.push('export-document') + if (format.support.exportPage) labels.push('export-page') + if (format.support.exportSelection) labels.push('export-selection') + if (format.support.exportNode) labels.push('export-node') + return labels +} + +export default defineCommand({ + meta: { description: 'List supported document and export formats' }, + args: { + json: { type: 'boolean', description: 'Output as JSON' } + }, + async run({ args }) { + const formats = io.listFormats().map((format) => ({ + id: format.id, + label: format.label, + role: format.role, + category: format.category, + extensions: format.extensions, + mimeTypes: format.mimeTypes, + support: supportLabels(format) + })) + + if (args.json) { + console.log(JSON.stringify(formats, null, 2)) + return + } + + console.log('') + console.log(bold(` ${formats.length} format${formats.length !== 1 ? 's' : ''}`)) + console.log('') + console.log( + fmtList( + formats.map((format) => ({ + header: `${format.label} (${format.id})`, + details: { + role: format.role, + category: format.category, + ext: format.extensions.map((ext) => `.${ext}`).join(', '), + support: format.support.join(', '), + mime: format.mimeTypes.join(', ') + } + })), + { compact: true } + ) + ) + console.log('') + console.log( + kv( + 'Readable', + io + .listReadableFormats() + .map((f) => f.id) + .join(', ') || 'none' + ) + ) + console.log( + kv( + 'Writable', + io + .listWritableFormats() + .map((f) => f.id) + .join(', ') || 'none' + ) + ) + console.log('') + } +}) diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index d10534b7a..b676a2400 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -19,7 +19,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, json: { type: 'boolean', description: 'Output as JSON' } diff --git a/packages/cli/src/commands/node.ts b/packages/cli/src/commands/node.ts index 6ae52c41d..538cb51b8 100644 --- a/packages/cli/src/commands/node.ts +++ b/packages/cli/src/commands/node.ts @@ -22,7 +22,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, id: { type: 'string', description: 'Node ID', required: true }, diff --git a/packages/cli/src/commands/pages.ts b/packages/cli/src/commands/pages.ts index 2a23c045d..e457c3a46 100644 --- a/packages/cli/src/commands/pages.ts +++ b/packages/cli/src/commands/pages.ts @@ -15,11 +15,11 @@ async function getData(file?: string): Promise { } export default defineCommand({ - meta: { description: 'List pages in a .fig file' }, + meta: { description: 'List pages in a document' }, args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, json: { type: 'boolean', description: 'Output as JSON' } diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts index c22c52d98..8ba468366 100644 --- a/packages/cli/src/commands/query.ts +++ b/packages/cli/src/commands/query.ts @@ -31,13 +31,13 @@ Examples: open-pencil query file.fig "//FRAME[@width < 300]" # Frames narrower than 300px open-pencil query file.fig "//COMPONENT[starts-with(@name, 'Button')]" # Components starting with Button open-pencil query file.fig "//SECTION/FRAME" # Direct frame children of sections - open-pencil query file.fig "//SECTION//TEXT" # All text inside sections + open-pencil query file.fig "//SECTION//TEXT" # All text inside sections open-pencil query file.fig "//*[@cornerRadius > 0]" # Any node with corner radius` }, args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, selector: { diff --git a/packages/cli/src/commands/tree.ts b/packages/cli/src/commands/tree.ts index c7ca8175b..957010893 100644 --- a/packages/cli/src/commands/tree.ts +++ b/packages/cli/src/commands/tree.ts @@ -34,7 +34,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, page: { type: 'string', description: 'Page name (default: first page)' }, diff --git a/packages/cli/src/commands/variables.ts b/packages/cli/src/commands/variables.ts index 24e513579..c50b30936 100644 --- a/packages/cli/src/commands/variables.ts +++ b/packages/cli/src/commands/variables.ts @@ -23,7 +23,7 @@ export default defineCommand({ args: { file: { type: 'positional', - description: '.fig file path (omit to connect to running app)', + description: 'Document file path (omit to connect to running app)', required: false }, collection: { type: 'string', description: 'Filter by collection name' }, diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index f5f86ea7e..0c93c6ba3 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -1,36 +1,18 @@ import { - parseFigFile, - initCanvasKit, - type SceneGraph, - type ExportFormat, + BUILTIN_IO_FORMATS, + IORegistry, computeAllLayouts, - headlessRenderNodes, - headlessRenderThumbnail + initCanvasKit, + type SceneGraph } from '@open-pencil/core' export { initCanvasKit } +const io = new IORegistry(BUILTIN_IO_FORMATS) + export async function loadDocument(filePath: string): Promise { - const data = await Bun.file(filePath).arrayBuffer() - const graph = await parseFigFile(data) + const bytes = new Uint8Array(await Bun.file(filePath).arrayBuffer()) + const { graph } = await io.readDocument({ name: filePath, data: bytes }) computeAllLayouts(graph) return graph } - -export async function exportNodes( - graph: SceneGraph, - pageId: string, - nodeIds: string[], - options: { scale?: number; format?: ExportFormat; quality?: number } -): Promise { - return headlessRenderNodes(graph, pageId, nodeIds, options) -} - -export async function exportThumbnail( - graph: SceneGraph, - pageId: string, - width: number, - height: number -): Promise { - return headlessRenderThumbnail(graph, pageId, width, height) -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d9ad253f8..03c0e0eb2 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,9 +2,11 @@ import { defineCommand, runMain } from 'citty' import analyze from './commands/analyze' +import convert from './commands/convert' import evalCmd from './commands/eval' import exportCmd from './commands/export' import find from './commands/find' +import formats from './commands/formats' import info from './commands/info' import node from './commands/node' import pages from './commands/pages' @@ -17,14 +19,16 @@ const { version } = await import('../package.json') const main = defineCommand({ meta: { name: 'open-pencil', - description: 'OpenPencil CLI — inspect, export, and lint .fig design files', + description: 'OpenPencil CLI — inspect, export, and lint OpenPencil design documents', version }, subCommands: { analyze, + convert, eval: evalCmd, export: exportCmd, find, + formats, info, query, node, diff --git a/packages/core/package.json b/packages/core/package.json index 4f74a0dbc..a93562131 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -74,6 +74,11 @@ "types": "./src/editor/index.ts", "bun": "./src/editor/index.ts", "default": "./src/editor/index.ts" + }, + "./io": { + "types": "./src/io/index.ts", + "bun": "./src/io/index.ts", + "default": "./src/io/index.ts" } }, "main": "./src/index.ts", @@ -161,6 +166,11 @@ "types": "./dist/editor/index.d.ts", "import": "./dist/editor/index.js", "default": "./dist/editor/index.js" + }, + "./io": { + "types": "./dist/io/index.d.ts", + "import": "./dist/io/index.js", + "default": "./dist/io/index.js" } }, "main": "./dist/index.js", diff --git a/packages/core/src/bezier-math.ts b/packages/core/src/bezier-math.ts index ff288e37a..3541722e4 100644 --- a/packages/core/src/bezier-math.ts +++ b/packages/core/src/bezier-math.ts @@ -520,14 +520,12 @@ function solveMergedTangents( const toRA = { x: vR.x - vA.x, y: vR.y - vA.y } const toRB = { x: vR.x - vB.x, y: vR.y - vB.y } const inner = { x: b1 * toRA.x + b2 * toRB.x, y: b1 * toRA.y + b2 * toRB.y } - const c = - Math.abs(inner.x) > Math.abs(inner.y) - ? (inner.x !== 0 - ? rhs.x / inner.x - : 1) - : (inner.y !== 0 - ? rhs.y / inner.y - : 1) + let c = 1 + if (Math.abs(inner.x) > Math.abs(inner.y)) { + if (inner.x !== 0) c = rhs.x / inner.x + } else if (inner.y !== 0) { + c = rhs.y / inner.y + } return { tangentStart: { x: c * toRA.x, y: c * toRA.y }, tangentEnd: { x: c * toRB.x, y: c * toRB.y } diff --git a/packages/core/src/figma-api.ts b/packages/core/src/figma-api.ts index 460284c1c..400d82b8a 100644 --- a/packages/core/src/figma-api.ts +++ b/packages/core/src/figma-api.ts @@ -9,6 +9,7 @@ import { } from './figma-api-proxy' import { computeBounds } from './geometry' +import type { RasterExportFormat } from './render-image' import type { SceneGraph, NodeType, @@ -385,6 +386,6 @@ export class FigmaAPI implements NodeProxyHost { exportImage?: ( nodeIds: string[], - options: { scale?: number; format?: 'PNG' | 'JPG' | 'WEBP'; quality?: number } + options: { scale?: number; format?: RasterExportFormat; quality?: number } ) => Promise } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f74138d83..dcf924e07 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -217,6 +217,7 @@ export { renderNodesToImage, renderThumbnail, computeContentBounds, + type RasterExportFormat, type ExportFormat } from './render-image' export { initCanvasKit, headlessRenderNodes, headlessRenderThumbnail } from './headless-render' @@ -341,6 +342,8 @@ export { FIG_WIRE_MAGIC } from './kiwi' +export * from './io' + export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt' export { setPexelsApiKey, diff --git a/packages/core/src/io/formats.ts b/packages/core/src/io/formats.ts new file mode 100644 index 000000000..bc8e19588 --- /dev/null +++ b/packages/core/src/io/formats.ts @@ -0,0 +1,276 @@ +import { exportFigFile } from '../fig-export' +import { headlessRenderNodes } from '../headless-render' +import { parseFigFile } from '../kiwi' +import { sceneNodeToJSX, selectionToJSX } from '../render' +import { renderNodesToImage } from '../render-image' +import { renderNodesToSVG } from '../svg-export' +import { extractExportGraph } from './subgraph' + +import type { RasterExportFormat } from '../render-image' +import type { + ExportRequest, + ExportResult, + FigWriteOptions, + IOContext, + IOFormatAdapter, + JSXExportOptions, + RasterExportOptions, + SVGExportOptions +} from './types' + +function lowerExt(name: string): string { + const match = /\.([^.]+)$/.exec(name.toLowerCase()) + return match?.[1] ?? '' +} + +function ensureSingleNode(target: ExportRequest['target']): string | null { + if (target.scope === 'node') return target.nodeId + if (target.scope === 'selection' && target.nodeIds.length === 1) return target.nodeIds[0] + return null +} + +function resolveExportNodes(request: ExportRequest): { pageId: string; nodeIds: string[] } | null { + switch (request.target.scope) { + case 'document': { + const page = request.graph.getPages()[0] + return { pageId: page.id, nodeIds: page.childIds } + } + case 'page': { + const page = request.graph.getNode(request.target.pageId) + if (!page) return null + return { pageId: page.id, nodeIds: page.childIds } + } + case 'selection': { + const first = request.target.nodeIds[0] + if (!first) return null + const node = request.graph.getNode(first) + if (!node) return null + let current = node.parentId ? request.graph.getNode(node.parentId) : undefined + while (current && current.type !== 'CANVAS') { + current = current.parentId ? request.graph.getNode(current.parentId) : undefined + } + if (!current) return null + return { pageId: current.id, nodeIds: request.target.nodeIds } + } + case 'node': + return resolveExportNodes({ + ...request, + target: { scope: 'selection', nodeIds: [request.target.nodeId] } + }) + default: + return null + } +} + +async function renderRaster( + request: ExportRequest, + options: RasterExportOptions, + context?: IOContext +): Promise { + const target = resolveExportNodes(request) + if (!target) return null + const scale = options.scale ?? 1 + + if (context?.canvasKit && context.renderer) { + return renderNodesToImage( + context.canvasKit, + context.renderer, + request.graph, + target.pageId, + target.nodeIds, + { + scale, + format: options.format, + quality: options.quality + } + ) + } + + return headlessRenderNodes(request.graph, target.pageId, target.nodeIds, { + scale, + format: options.format, + quality: options.quality + }) +} + +function rasterFormat(format: RasterExportFormat): IOFormatAdapter { + const extension = format === 'JPG' ? 'jpg' : format.toLowerCase() + let mimeType = 'image/png' + if (format === 'JPG') mimeType = 'image/jpeg' + else if (format === 'WEBP') mimeType = 'image/webp' + + return { + id: extension, + label: format, + role: 'derived-export', + category: 'raster', + extensions: [extension], + mimeTypes: [mimeType], + support: { + exportDocument: true, + exportPage: true, + exportSelection: true, + exportNode: true + }, + exportOptions: { + scale: true, + quality: format !== 'PNG' + }, + async exportContent(request, options?: RasterExportOptions, context?: IOContext) { + const data = await renderRaster( + request, + { + format, + scale: options?.scale, + quality: options?.quality + }, + context + ) + if (!data) throw new Error('Nothing to export') + return { + format: extension, + mimeType, + extension, + data + } + } + } +} + +export const figFormat: IOFormatAdapter = { + id: 'fig', + label: 'OpenPencil Document', + role: 'native-document', + category: 'document', + extensions: ['fig'], + mimeTypes: ['application/octet-stream'], + support: { + readDocument: true, + writeDocument: true, + exportDocument: true, + exportPage: true, + exportSelection: true, + exportNode: true + }, + exportOptions: { + scale: false, + quality: false + }, + matchesFile(fileName) { + return lowerExt(fileName) === 'fig' + }, + async readDocument(input) { + const data = input.data.slice().buffer + const graph = await parseFigFile(data) + return { graph, sourceFormat: 'fig' } + }, + async writeDocument(graph, options?: FigWriteOptions, context?: IOContext) { + const data = await exportFigFile( + graph, + context?.canvasKit, + context?.renderer, + options?.thumbnailPageId + ) + return { + format: 'fig', + mimeType: 'application/octet-stream', + extension: 'fig', + data + } + }, + async exportContent(request, options?: FigWriteOptions, context?: IOContext) { + const extracted = extractExportGraph(request.graph, request.target) + const data = await exportFigFile( + extracted.graph, + context?.canvasKit, + context?.renderer, + options?.thumbnailPageId ?? extracted.pageId ?? undefined + ) + return { + format: 'fig', + mimeType: 'application/octet-stream', + extension: 'fig', + data + } + } +} + +export const pngFormat = rasterFormat('PNG') +export const jpgFormat = rasterFormat('JPG') +export const webpFormat = rasterFormat('WEBP') + +export const svgFormat: IOFormatAdapter = { + id: 'svg', + label: 'SVG', + role: 'derived-export', + category: 'vector', + extensions: ['svg'], + mimeTypes: ['image/svg+xml'], + support: { + exportDocument: true, + exportPage: true, + exportSelection: true, + exportNode: true + }, + exportOptions: { + scale: false, + quality: false + }, + async exportContent(request, options?: SVGExportOptions) { + const target = resolveExportNodes(request) + if (!target) throw new Error('Nothing to export') + const data = renderNodesToSVG(request.graph, target.pageId, target.nodeIds, options) + if (!data) throw new Error('Nothing to export') + return { + format: 'svg', + mimeType: 'image/svg+xml', + extension: 'svg', + data, + encoding: 'utf8' + } + } +} + +export const jsxFormat: IOFormatAdapter = { + id: 'jsx', + label: 'JSX', + role: 'derived-export', + category: 'code', + extensions: ['jsx'], + mimeTypes: ['text/plain', 'text/jsx'], + support: { + exportSelection: true, + exportNode: true + }, + exportOptions: { + scale: false, + quality: false + }, + async exportContent(request, options?: JSXExportOptions): Promise { + const format = options?.format ?? 'openpencil' + const nodeId = ensureSingleNode(request.target) + let data = '' + if (nodeId) { + data = sceneNodeToJSX(nodeId, request.graph, format) + } else if (request.target.scope === 'selection') { + data = selectionToJSX(request.target.nodeIds, request.graph, format) + } + if (!data) throw new Error('Nothing to export') + return { + format: 'jsx', + mimeType: 'text/plain', + extension: 'jsx', + data, + encoding: 'utf8' + } + } +} + +export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [ + figFormat, + pngFormat, + jpgFormat, + webpFormat, + svgFormat, + jsxFormat +] diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts new file mode 100644 index 000000000..acdc6eaa9 --- /dev/null +++ b/packages/core/src/io/index.ts @@ -0,0 +1,32 @@ +export { IORegistry } from './registry' +export { extractExportGraph } from './subgraph' +export { + BUILTIN_IO_FORMATS, + figFormat, + pngFormat, + jpgFormat, + webpFormat, + svgFormat, + jsxFormat +} from './formats' +export type { + IOFormatRole, + IOFormatCategory, + IOTextEncoding, + IOBinaryData, + IOTextData, + IOData, + ReadDocumentInput, + ReadDocumentResult, + ExportTarget, + ExportRequest, + ExportResult, + IOContext, + FigWriteOptions, + RasterExportOptions, + SVGExportOptions, + JSXExportOptions, + IOFormatSupport, + IOFormatExportOptions, + IOFormatAdapter +} from './types' diff --git a/packages/core/src/io/registry.ts b/packages/core/src/io/registry.ts new file mode 100644 index 000000000..edc9464f9 --- /dev/null +++ b/packages/core/src/io/registry.ts @@ -0,0 +1,79 @@ +import type { SceneGraph } from '../scene-graph' +import type { ExportRequest, IOContext, IOFormatAdapter, ReadDocumentInput } from './types' + +export class IORegistry { + constructor(private readonly adapters: IOFormatAdapter[]) {} + + listFormats(): IOFormatAdapter[] { + return this.adapters + } + + getFormat(id: string): IOFormatAdapter | null { + return this.adapters.find((adapter) => adapter.id === id) ?? null + } + + listReadableFormats(): IOFormatAdapter[] { + return this.adapters.filter((adapter) => adapter.support.readDocument) + } + + listWritableFormats(): IOFormatAdapter[] { + return this.adapters.filter((adapter) => adapter.support.writeDocument) + } + + listExportFormats(scope: ExportRequest['target']['scope']): IOFormatAdapter[] { + return this.adapters.filter((adapter) => { + switch (scope) { + case 'document': + return !!adapter.support.exportDocument + case 'page': + return !!adapter.support.exportPage + case 'selection': + return !!adapter.support.exportSelection + case 'node': + return !!adapter.support.exportNode + default: + return false + } + }) + } + + findReader(fileName: string, mimeType?: string): IOFormatAdapter | null { + return ( + this.adapters.find((adapter) => { + if (!adapter.support.readDocument) return false + if (adapter.matchesFile) return adapter.matchesFile(fileName, mimeType) + const lower = fileName.toLowerCase() + return adapter.extensions.some((ext) => lower.endsWith(`.${ext}`)) + }) ?? null + ) + } + + async readDocument(input: ReadDocumentInput, context?: IOContext) { + const reader = this.findReader(input.name ?? '', input.mimeType) + if (!reader?.readDocument) { + throw new Error(`Unsupported document format: ${input.name ?? 'unknown'}`) + } + return reader.readDocument(input, context) + } + + async writeDocument(formatId: string, graph: SceneGraph, options?: unknown, context?: IOContext) { + const adapter = this.getFormat(formatId) + if (!adapter?.writeDocument) { + throw new Error(`Format does not support writeDocument: ${formatId}`) + } + return adapter.writeDocument(graph, options, context) + } + + async exportContent( + formatId: string, + request: ExportRequest, + options?: unknown, + context?: IOContext + ) { + const adapter = this.getFormat(formatId) + if (!adapter?.exportContent) { + throw new Error(`Format does not support exportContent: ${formatId}`) + } + return adapter.exportContent(request, options, context) + } +} diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts new file mode 100644 index 000000000..9b06367ad --- /dev/null +++ b/packages/core/src/io/subgraph.ts @@ -0,0 +1,202 @@ +import { SceneGraph } from '../scene-graph' + +import type { ExportTarget } from './types' + +export interface ExtractedGraph { + graph: SceneGraph + pageId: string | null + nodeIds: string[] +} + +function cloneIntoGraph(source: SceneGraph, ids: Set): SceneGraph { + const graph = new SceneGraph() + const root = graph.getNode(graph.rootId) + if (root) { + root.childIds = [] + root.width = 0 + root.height = 0 + } + graph.nodes = new Map() + if (root) graph.nodes.set(root.id, root) + graph.images = new Map(source.images) + graph.variables = new Map() + graph.variableCollections = new Map() + graph.activeMode = new Map(source.activeMode) + graph.figKiwiVersion = source.figKiwiVersion + + const sortedIds = [...ids].sort((a, b) => { + if (a === source.rootId) return -1 + if (b === source.rootId) return 1 + const aNode = source.getNode(a) + const bNode = source.getNode(b) + const aDepth = depthOf(source, aNode) + const bDepth = depthOf(source, bNode) + return aDepth - bDepth + }) + + for (const id of sortedIds) { + const node = source.getNode(id) + if (!node) continue + graph.nodes.set(id, structuredClone(node)) + } + + const rootClone = graph.getNode(source.rootId) + if (rootClone) { + rootClone.parentId = null + rootClone.childIds = rootClone.childIds.filter((id) => ids.has(id)) + } + + for (const id of sortedIds) { + if (id === source.rootId) continue + const node = graph.getNode(id) + if (!node) continue + if (!node.parentId || !ids.has(node.parentId)) { + node.parentId = source.rootId + } + node.childIds = node.childIds.filter((childId) => ids.has(childId)) + } + + const variableIds = new Set() + for (const node of graph.nodes.values()) { + for (const variableId of Object.values(node.boundVariables)) { + collectVariableClosure(source, variableId, variableIds) + } + } + + for (const variableId of variableIds) { + const variable = source.variables.get(variableId) + if (!variable) continue + graph.variables.set(variableId, structuredClone(variable)) + const collection = source.variableCollections.get(variable.collectionId) + if (!collection) continue + const existing = graph.variableCollections.get(collection.id) + if (!existing) { + graph.variableCollections.set(collection.id, { + ...structuredClone(collection), + variableIds: [] + }) + } + graph.variableCollections.get(collection.id)?.variableIds.push(variableId) + } + + graph.clearAbsPosCache() + return graph +} + +function collectVariableClosure(source: SceneGraph, variableId: string, out: Set) { + if (out.has(variableId)) return + const variable = source.variables.get(variableId) + if (!variable) return + out.add(variableId) + for (const value of Object.values(variable.valuesByMode)) { + if (typeof value === 'object' && 'aliasId' in value) { + collectVariableClosure(source, value.aliasId, out) + } + } +} + +function depthOf(source: SceneGraph, node: ReturnType): number { + let depth = 0 + let current = node + while (current?.parentId) { + depth += 1 + current = source.getNode(current.parentId) + } + return depth +} + +function collectDescendants(source: SceneGraph, id: string, out: Set) { + if (out.has(id)) return + out.add(id) + const node = source.getNode(id) + if (!node) return + for (const childId of node.childIds) { + collectDescendants(source, childId, out) + } +} + +function ancestorChain(source: SceneGraph, id: string): string[] { + const chain: string[] = [] + let current = source.getNode(id) + while (current?.parentId) { + chain.push(current.parentId) + current = source.getNode(current.parentId) + } + return chain.reverse() +} + +function collectSelectionIds(source: SceneGraph, nodeIds: string[]): Set { + const ids = new Set([source.rootId]) + const pageIds = new Set() + + for (const nodeId of nodeIds) { + const node = source.getNode(nodeId) + if (!node) continue + for (const ancestorId of ancestorChain(source, nodeId)) { + ids.add(ancestorId) + const ancestor = source.getNode(ancestorId) + if (ancestor?.type === 'CANVAS') pageIds.add(ancestorId) + } + collectDescendants(source, nodeId, ids) + } + + for (const pageId of pageIds) { + ids.add(pageId) + } + + return ids +} + +function pageNodeIds(source: SceneGraph, pageId: string): Set { + const ids = new Set([source.rootId, pageId]) + collectDescendants(source, pageId, ids) + return ids +} + +function rootNodeIds(source: SceneGraph): Set { + const ids = new Set() + for (const node of source.nodes.values()) { + ids.add(node.id) + } + return ids +} + +export function extractExportGraph(source: SceneGraph, target: ExportTarget): ExtractedGraph { + switch (target.scope) { + case 'document': { + const graph = cloneIntoGraph(source, rootNodeIds(source)) + return { + graph, + pageId: graph.getPages()[0]?.id ?? null, + nodeIds: graph.getPages()[0]?.childIds ?? [] + } + } + case 'page': { + const graph = cloneIntoGraph(source, pageNodeIds(source, target.pageId)) + const page = graph.getNode(target.pageId) + return { + graph, + pageId: page?.id ?? null, + nodeIds: page?.childIds ?? [] + } + } + case 'selection': { + const graph = cloneIntoGraph(source, collectSelectionIds(source, target.nodeIds)) + const firstId = target.nodeIds[0] + const first = firstId ? source.getNode(firstId) : undefined + const pageId = first + ? (ancestorChain(source, first.id).find((id) => source.getNode(id)?.type === 'CANVAS') ?? + null) + : null + return { + graph, + pageId, + nodeIds: target.nodeIds.filter((id) => graph.getNode(id) !== undefined) + } + } + case 'node': + return extractExportGraph(source, { scope: 'selection', nodeIds: [target.nodeId] }) + default: + return extractExportGraph(source, { scope: 'document' }) + } +} diff --git a/packages/core/src/io/types.ts b/packages/core/src/io/types.ts new file mode 100644 index 000000000..a706513a8 --- /dev/null +++ b/packages/core/src/io/types.ts @@ -0,0 +1,123 @@ +import type { JSXFormat } from '../render' +import type { RasterExportFormat } from '../render-image' +import type { SkiaRenderer } from '../renderer' +import type { SceneGraph } from '../scene-graph' +import type { CanvasKit } from 'canvaskit-wasm' + +export type IOFormatRole = 'native-document' | 'interchange-document' | 'derived-export' + +export type IOFormatCategory = 'document' | 'raster' | 'vector' | 'code' | 'print' + +export type IOTextEncoding = 'utf8' + +export type IOBinaryData = Uint8Array +export type IOTextData = string +export type IOData = IOBinaryData | IOTextData + +export interface ReadDocumentInput { + name?: string + mimeType?: string + data: Uint8Array +} + +export interface ReadDocumentResult { + graph: SceneGraph + sourceFormat: string +} + +export interface ExportTargetDocument { + scope: 'document' +} + +export interface ExportTargetPage { + scope: 'page' + pageId: string +} + +export interface ExportTargetSelection { + scope: 'selection' + nodeIds: string[] +} + +export interface ExportTargetNode { + scope: 'node' + nodeId: string +} + +export type ExportTarget = + | ExportTargetDocument + | ExportTargetPage + | ExportTargetSelection + | ExportTargetNode + +export interface ExportRequest { + graph: SceneGraph + target: ExportTarget + fileName?: string +} + +export interface IOContext { + canvasKit?: CanvasKit + renderer?: SkiaRenderer +} + +export interface FigWriteOptions { + thumbnailPageId?: string +} + +export interface RasterExportOptions { + scale?: number + quality?: number + format: RasterExportFormat +} + +export interface SVGExportOptions { + xmlDeclaration?: boolean +} + +export interface JSXExportOptions { + format?: JSXFormat +} + +export interface ExportResult { + format: string + mimeType: string + extension: string + data: IOData + encoding?: IOTextEncoding +} + +export interface IOFormatSupport { + readDocument?: boolean + writeDocument?: boolean + exportDocument?: boolean + exportPage?: boolean + exportSelection?: boolean + exportNode?: boolean +} + +export interface IOFormatExportOptions { + scale?: boolean + quality?: boolean +} + +export interface IOFormatAdapter { + id: string + label: string + role: IOFormatRole + category: IOFormatCategory + extensions: string[] + mimeTypes: string[] + support: IOFormatSupport + exportOptions?: IOFormatExportOptions + + matchesFile?(fileName: string, mimeType?: string): boolean + + readDocument?(input: ReadDocumentInput, context?: IOContext): Promise + writeDocument?(graph: SceneGraph, options?: unknown, context?: IOContext): Promise + exportContent?( + request: ExportRequest, + options?: unknown, + context?: IOContext + ): Promise +} diff --git a/packages/core/src/render-image.ts b/packages/core/src/render-image.ts index 9fc896b98..dd6229a5b 100644 --- a/packages/core/src/render-image.ts +++ b/packages/core/src/render-image.ts @@ -2,7 +2,8 @@ import type { SkiaRenderer } from './renderer' import type { SceneGraph } from './scene-graph' import type { CanvasKit, Canvas } from 'canvaskit-wasm' -export type ExportFormat = 'PNG' | 'JPG' | 'WEBP' | 'SVG' +export type RasterExportFormat = 'PNG' | 'JPG' | 'WEBP' +export type ExportFormat = RasterExportFormat | 'SVG' interface RenderOptions { scale: number diff --git a/packages/core/src/tools/vector.ts b/packages/core/src/tools/vector.ts index 622df0acc..02a151b58 100644 --- a/packages/core/src/tools/vector.ts +++ b/packages/core/src/tools/vector.ts @@ -2,6 +2,7 @@ import { cloneVectorNetwork } from '../scene-graph' import { defineTool, nodeSummary } from './schema' import type { FigmaAPI } from '../figma-api' +import type { RasterExportFormat } from '../render-image' import type { SceneNode, VectorNetwork } from '../scene-graph' function getVectorNode( @@ -304,7 +305,7 @@ export const exportImage = defineTool({ } const ids = args.ids && args.ids.length > 0 ? args.ids : figma.currentPage.children.map((n) => n.id) - const format = (args.format ?? 'PNG').toUpperCase() as 'PNG' | 'JPG' | 'WEBP' + const format = (args.format ?? 'PNG').toUpperCase() as RasterExportFormat const data = await figma.exportImage(ids, { scale: args.scale ?? 1, format diff --git a/packages/vue/src/controls/useExport.ts b/packages/vue/src/controls/useExport.ts index 980a6be3f..bf7fefb39 100644 --- a/packages/vue/src/controls/useExport.ts +++ b/packages/vue/src/controls/useExport.ts @@ -1,22 +1,22 @@ import { ref } from 'vue' +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' import { useEditor } from '@open-pencil/vue/context/editorContext' import { useSceneComputed } from '@open-pencil/vue/internal/useSceneComputed' -import type { ExportFormat } from '@open-pencil/core' +export type ExportFormatId = 'png' | 'jpg' | 'webp' | 'svg' | 'fig' /** * Single export preset row managed by {@link useExport}. */ interface ExportSetting { - /** Export scale multiplier. */ scale: number - /** Output file format. */ - format: ExportFormat + format: ExportFormatId } const SCALES = [0.5, 0.75, 1, 1.5, 2, 3, 4] as const -const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG'] +const FORMATS: ExportFormatId[] = ['png', 'jpg', 'webp', 'svg', 'fig'] +const io = new IORegistry(BUILTIN_IO_FORMATS) /** * Returns selection-aware export settings for export panel UIs. @@ -27,10 +27,13 @@ const FORMATS: ExportFormat[] = ['PNG', 'JPG', 'WEBP', 'SVG'] export function useExport() { const editor = useEditor() - const settings = ref([{ scale: 1, format: 'PNG' }]) + const settings = ref([{ scale: 1, format: 'png' }]) const selectedIds = useSceneComputed(() => [...editor.state.selectedIds]) + const formatSupportsScale = (format: ExportFormatId) => + io.getFormat(format)?.exportOptions?.scale ?? false + const nodeName = useSceneComputed(() => { const ids = editor.state.selectedIds if (ids.size === 1) { @@ -43,7 +46,7 @@ export function useExport() { function addSetting() { const last = settings.value[settings.value.length - 1] const nextScale = SCALES.find((s) => s > (last?.scale ?? 1)) ?? 2 - settings.value.push({ scale: nextScale, format: last?.format ?? 'PNG' }) + settings.value.push({ scale: nextScale, format: last?.format ?? 'png' }) } function removeSetting(index: number) { @@ -54,7 +57,7 @@ export function useExport() { settings.value[index] = { ...settings.value[index], scale } } - function updateFormat(index: number, format: ExportFormat) { + function updateFormat(index: number, format: ExportFormatId) { settings.value[index] = { ...settings.value[index], format } } @@ -68,6 +71,7 @@ export function useExport() { addSetting, removeSetting, updateScale, - updateFormat + updateFormat, + formatSupportsScale } } diff --git a/src/automation/figma-factory.ts b/src/automation/figma-factory.ts index aff4f1ede..b08f5063f 100644 --- a/src/automation/figma-factory.ts +++ b/src/automation/figma-factory.ts @@ -1,7 +1,6 @@ import { FigmaAPI } from '@open-pencil/core' import type { EditorStore } from '@/stores/editor' -import type { ExportFormat } from '@open-pencil/core' export function makeFigmaFromStore(store: EditorStore): FigmaAPI { const api = new FigmaAPI(store.graph) @@ -17,6 +16,6 @@ export function makeFigmaFromStore(store: EditorStore): FigmaAPI { zoom: store.state.zoom } api.exportImage = (nodeIds, opts) => - store.renderExportImage(nodeIds, opts.scale ?? 1, (opts.format ?? 'PNG') as ExportFormat) + store.renderExportImage(nodeIds, opts.scale ?? 1, opts.format ?? 'PNG') return api } diff --git a/src/automation/server.ts b/src/automation/server.ts index 4bb6c92d0..06a5d8da7 100644 --- a/src/automation/server.ts +++ b/src/automation/server.ts @@ -17,7 +17,7 @@ import { } from '@open-pencil/core' import type { EditorStore } from '@/stores/editor' -import type { ExportFormat } from '@open-pencil/core' +import type { RasterExportFormat } from '@open-pencil/core' export function connectAutomation(getStore: () => EditorStore) { const token = randomHex(32) @@ -96,7 +96,7 @@ export function connectAutomation(getStore: () => EditorStore) { const data = await store.renderExportImage( nodeIds, exportArgs?.scale ?? 1, - (exportArgs?.format ?? 'PNG') as ExportFormat + (exportArgs?.format ?? 'PNG') as RasterExportFormat ) if (!data) throw new Error('Export failed') let binary = '' diff --git a/src/components/MobileHud.vue b/src/components/MobileHud.vue index 599735c79..46cb5abd9 100644 --- a/src/components/MobileHud.vue +++ b/src/components/MobileHud.vue @@ -81,7 +81,7 @@ const menuItems: MenuAction[] = [ { icon: IconImageDown, label: 'Export…', - action: () => store.exportSelection(1, 'PNG') + action: () => store.exportSelection(1, 'png') }, { icon: IconZoomIn, label: 'Zoom to fit', action: () => getCommand('view.zoomFit').run() } ] diff --git a/src/components/properties/ExportSection.vue b/src/components/properties/ExportSection.vue index e54b6c8f8..f3d4f46fb 100644 --- a/src/components/properties/ExportSection.vue +++ b/src/components/properties/ExportSection.vue @@ -7,18 +7,27 @@ import { sectionLabel, sectionWrapper } from '@/components/ui/section' import { useEditorStore } from '@/stores/editor' import { useExport, useI18n } from '@open-pencil/vue' -import type { ExportFormat } from '@open-pencil/core' +import type { ExportFormatId } from '@open-pencil/vue/controls/useExport' const editorStore = useEditorStore() const { panels } = useI18n() -const { settings, nodeName, addSetting, removeSetting, updateScale, updateFormat } = useExport() +const { + settings, + nodeName, + addSetting, + removeSetting, + updateScale, + updateFormat, + formatSupportsScale +} = useExport() const SCALE_OPTIONS = [0.5, 0.75, 1, 1.5, 2, 3, 4].map((s) => ({ value: s, label: `${s}x` })) -const FORMAT_OPTIONS: { value: ExportFormat; label: string }[] = [ - { value: 'PNG', label: 'PNG' }, - { value: 'JPG', label: 'JPG' }, - { value: 'WEBP', label: 'WEBP' }, - { value: 'SVG', label: 'SVG' } +const FORMAT_OPTIONS: { value: ExportFormatId; label: string }[] = [ + { value: 'png', label: 'PNG' }, + { value: 'jpg', label: 'JPG' }, + { value: 'webp', label: 'WEBP' }, + { value: 'svg', label: 'SVG' }, + { value: 'fig', label: '.fig' } ] const previewUrl = ref(null) @@ -27,7 +36,7 @@ const exporting = ref(false) const PREVIEW_WIDTH = 480 -async function doExport(exportSettings: Array<{ scale: number; format: ExportFormat }>) { +async function doExport(exportSettings: Array<{ scale: number; format: ExportFormatId }>) { exporting.value = true try { for (const s of exportSettings) await editorStore.exportSelection(s.scale, s.format) @@ -85,15 +94,15 @@ onScopeDispose(() => { class="flex items-center gap-1.5 py-0.5" > diff --git a/src/composables/use-app-menu.ts b/src/composables/use-app-menu.ts index 98599353e..d01ea792d 100644 --- a/src/composables/use-app-menu.ts +++ b/src/composables/use-app-menu.ts @@ -49,10 +49,26 @@ export function useAppMenu(mod: string) { { label: t.value.exportSelection, shortcut: `${mod}⇧E`, - action: () => { - void store.exportSelection(1, 'PNG') - }, - disabled: store.state.selectedIds.size === 0 + sub: [ + { + label: 'PNG', + action: () => { + void store.exportSelection(1, 'png') + } + }, + { + label: 'SVG', + action: () => { + void store.exportSelection(1, 'svg') + } + }, + { + label: '.fig', + action: () => { + void store.exportSelection(1, 'fig') + } + } + ] }, { separator: true as const }, { diff --git a/src/composables/use-keyboard.ts b/src/composables/use-keyboard.ts index 8406be226..fe78b21be 100644 --- a/src/composables/use-keyboard.ts +++ b/src/composables/use-keyboard.ts @@ -167,7 +167,7 @@ export function useKeyboard() { whenever(mod('shift+keyh'), () => runCommand('selection.toggleVisibility')) whenever(mod('shift+keyl'), () => runCommand('selection.toggleLock')) whenever(mod('shift+keye'), () => { - if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG') + if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png') }) whenever(mod('shift+keys'), () => store.saveFigFileAs()) whenever(mod('shift+keyg'), () => runCommand('selection.ungroup')) diff --git a/src/composables/use-menu.ts b/src/composables/use-menu.ts index dd7597847..86565acfa 100644 --- a/src/composables/use-menu.ts +++ b/src/composables/use-menu.ts @@ -80,7 +80,7 @@ const MENU_ACTIONS: Partial void>> = { 'zoom-fit': () => store.zoomToFit(), 'zoom-selection': () => store.zoomToSelection(), export: () => { - if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'PNG') + if (store.state.selectedIds.size > 0) void store.exportSelection(1, 'png') } } diff --git a/src/stores/editor.ts b/src/stores/editor.ts index 487777d79..e9f64c740 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -6,6 +6,7 @@ import { loadFont } from '@/engine/fonts' import { toast } from '@/utils/toast' import { breakAtVertex, + BUILTIN_IO_FORMATS, cloneVectorNetwork, computeAccurateBounds, createDefaultEditorState, @@ -14,12 +15,12 @@ import { exportFigFile, findAllHandles, findOppositeHandle, + IORegistry, mirrorHandle, nearestPointOnNetwork, readFigFile, removeVertex, renderNodesToImage, - renderNodesToSVG, SceneGraph, splitSegmentAt, prefetchFigmaSchema @@ -27,8 +28,10 @@ import { import type { EditorState, - ExportFormat, + ExportRequest, Fill, + IOFormatAdapter, + RasterExportFormat, Rect, SceneNode, Vector, @@ -104,6 +107,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { }) const editor = createEditor({ graph, state, loadFont, skipInitialGraphSetup: !!initialGraph }) + const io = new IORegistry(BUILTIN_IO_FORMATS) if (initialGraph) { editor.subscribeToGraph() @@ -790,7 +794,9 @@ export function createEditorStore(initialGraph?: SceneGraph) { if (v > hi) hi = v } - const target = align === 'min' ? lo : (align === 'max' ? hi : (lo + hi) / 2) + let target = (lo + hi) / 2 + if (align === 'min') target = lo + else if (align === 'max') target = hi for (const i of indices) { es.vertices[i] = { ...es.vertices[i], [prop]: target } } @@ -1034,7 +1040,7 @@ export function createEditorStore(initialGraph?: SceneGraph) { async function renderExportImage( nodeIds: string[], scale: number, - format: ExportFormat + format: RasterExportFormat ): Promise { const renderer = editor.renderer if (!renderer) return null @@ -1047,59 +1053,71 @@ export function createEditorStore(initialGraph?: SceneGraph) { }) } - function exportImageExtension(format: ExportFormat): string { - switch (format) { - case 'JPG': - return '.jpg' - case 'WEBP': - return '.webp' - default: - return '.png' + function getExportBaseName(target: ExportRequest['target']): string { + if (target.scope === 'node') { + return editor.graph.getNode(target.nodeId)?.name ?? 'Export' } + if (target.scope === 'selection' && target.nodeIds.length === 1) { + return editor.graph.getNode(target.nodeIds[0])?.name ?? 'Export' + } + if (target.scope === 'page') { + return editor.graph.getNode(target.pageId)?.name ?? 'Page' + } + return 'Export' } - function exportImageMime(format: ExportFormat): string { - switch (format) { - case 'JPG': - return 'image/jpeg' - case 'WEBP': - return 'image/webp' - default: - return 'image/png' - } - } - - async function exportSelection(scale: number, format: ExportFormat) { + function getSelectionExportTarget(): ExportRequest['target'] { const ids = [...state.selectedIds] + if (ids.length > 0) return { scope: 'selection', nodeIds: ids } + return { scope: 'page', pageId: state.currentPageId } + } - if (format === 'SVG') { - const nodeIds = - ids.length > 0 ? ids : editor.graph.getChildren(state.currentPageId).map((n) => n.id) - const svgStr = renderNodesToSVG(editor.graph, state.currentPageId, nodeIds) - if (!svgStr) { - console.error('Export failed: renderNodesToSVG returned null') - return + function listSelectionExportFormats(): IOFormatAdapter[] { + return io.listExportFormats(state.selectedIds.size > 0 ? 'selection' : 'page') + } + + async function exportTarget( + target: ExportRequest['target'], + formatId: string, + options?: { scale?: number; quality?: number; jsxFormat?: 'openpencil' | 'tailwind' } + ) { + const format = io.getFormat(formatId) + if (!format) throw new Error(`Unknown export format: ${formatId}`) + + let exportOptions: unknown + if (formatId === 'png' || formatId === 'jpg' || formatId === 'webp') { + exportOptions = { + format: formatId.toUpperCase(), + scale: options?.scale ?? 1, + quality: options?.quality } - const svgData = new TextEncoder().encode(svgStr) - const node = ids.length === 1 ? editor.graph.getNode(ids[0]) : undefined - const fileName = `${node?.name ?? 'Export'}.svg` - await saveExportedFile(svgData, fileName, 'SVG', '.svg', 'image/svg+xml') - return + } else if (formatId === 'jsx') { + exportOptions = { format: options?.jsxFormat ?? 'openpencil' } } - const data = await renderExportImage(ids, scale, format) - if (!data) { - console.error( - `Export failed: renderExportImage returned null for format=${format} scale=${scale}` - ) - return - } + const result = await io.exportContent( + formatId, + { graph: editor.graph, target }, + exportOptions, + editor.renderer ? { canvasKit: editor.renderer.ck, renderer: editor.renderer } : undefined + ) - const node = ids.length === 1 ? editor.graph.getNode(ids[0]) : undefined - const baseName = node?.name ?? 'Export' - const ext = exportImageExtension(format) - const fileName = `${baseName}@${scale}x${ext}` - await saveExportedFile(new Uint8Array(data), fileName, format, ext, exportImageMime(format)) + const baseName = getExportBaseName(target) + const fileName = + formatId === 'png' || formatId === 'jpg' || formatId === 'webp' + ? `${baseName}@${options?.scale ?? 1}x.${result.extension}` + : `${baseName}.${result.extension}` + + const bytes = + typeof result.data === 'string' + ? new TextEncoder().encode(result.data) + : new Uint8Array(result.data) + + await saveExportedFile(bytes, fileName, format.label, `.${result.extension}`, result.mimeType) + } + + async function exportSelection(scale: number, formatId: 'png' | 'jpg' | 'webp' | 'svg' | 'fig') { + await exportTarget(getSelectionExportTarget(), formatId, { scale }) } async function saveExportedFile( @@ -1205,6 +1223,8 @@ export function createEditorStore(initialGraph?: SceneGraph) { saveFigFile, saveFigFileAs, renderExportImage, + listSelectionExportFormats, + exportTarget, exportSelection, mobileCopy, mobileCut,