Add shared IO format registry
This commit is contained in:
parent
d52d38cb3b
commit
0001802dda
|
|
@ -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
|
||||
|
||||
|
|
|
|||
56
packages/cli/src/commands/convert.ts
Normal file
56
packages/cli/src/commands/convert.ts
Normal file
|
|
@ -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: <name>.<format>)',
|
||||
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}`))
|
||||
}
|
||||
})
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
|
|
|
|||
80
packages/cli/src/commands/formats.ts
Normal file
80
packages/cli/src/commands/formats.ts
Normal file
|
|
@ -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<IORegistry['listFormats']>[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('')
|
||||
}
|
||||
})
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ async function getData(file?: string): Promise<PageItem[]> {
|
|||
}
|
||||
|
||||
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' }
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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<SceneGraph> {
|
||||
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<Uint8Array | null> {
|
||||
return headlessRenderNodes(graph, pageId, nodeIds, options)
|
||||
}
|
||||
|
||||
export async function exportThumbnail(
|
||||
graph: SceneGraph,
|
||||
pageId: string,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<Uint8Array | null> {
|
||||
return headlessRenderThumbnail(graph, pageId, width, height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array | null>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
276
packages/core/src/io/formats.ts
Normal file
276
packages/core/src/io/formats.ts
Normal file
|
|
@ -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<Uint8Array | null> {
|
||||
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<ExportResult> {
|
||||
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
|
||||
]
|
||||
32
packages/core/src/io/index.ts
Normal file
32
packages/core/src/io/index.ts
Normal file
|
|
@ -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'
|
||||
79
packages/core/src/io/registry.ts
Normal file
79
packages/core/src/io/registry.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
202
packages/core/src/io/subgraph.ts
Normal file
202
packages/core/src/io/subgraph.ts
Normal file
|
|
@ -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<string>): 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<string>()
|
||||
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<string>) {
|
||||
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<SceneGraph['getNode']>): 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<string>) {
|
||||
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<string> {
|
||||
const ids = new Set<string>([source.rootId])
|
||||
const pageIds = new Set<string>()
|
||||
|
||||
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<string> {
|
||||
const ids = new Set<string>([source.rootId, pageId])
|
||||
collectDescendants(source, pageId, ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
function rootNodeIds(source: SceneGraph): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
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' })
|
||||
}
|
||||
}
|
||||
123
packages/core/src/io/types.ts
Normal file
123
packages/core/src/io/types.ts
Normal file
|
|
@ -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<ReadDocumentResult>
|
||||
writeDocument?(graph: SceneGraph, options?: unknown, context?: IOContext): Promise<ExportResult>
|
||||
exportContent?(
|
||||
request: ExportRequest,
|
||||
options?: unknown,
|
||||
context?: IOContext
|
||||
): Promise<ExportResult>
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ExportSetting[]>([{ scale: 1, format: 'PNG' }])
|
||||
const settings = ref<ExportSetting[]>([{ 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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"
|
||||
>
|
||||
<AppSelect
|
||||
v-if="setting.format !== 'SVG'"
|
||||
:model-value="setting.scale"
|
||||
:options="SCALE_OPTIONS"
|
||||
:disabled="!formatSupportsScale(setting.format)"
|
||||
@update:model-value="updateScale(i, Number($event))"
|
||||
/>
|
||||
<AppSelect
|
||||
:model-value="setting.format"
|
||||
:options="FORMAT_OPTIONS"
|
||||
@update:model-value="updateFormat(i, $event as ExportFormat)"
|
||||
@update:model-value="updateFormat(i, $event as ExportFormatId)"
|
||||
/>
|
||||
<button :class="iconButton({ ui: { base: 'shrink-0' } })" @click="removeSetting(i)">−</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ const MENU_ACTIONS: Partial<Record<string, () => 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')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Uint8Array | null> {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue