fix(cli): populate lazy FIG pages
- Populate requested pages for scoped reads and all pages for document-wide operations - Export whole FIG documents by default while keeping explicit page exports partial
This commit is contained in:
parent
072c159fdb
commit
7124d0045f
|
|
@ -32,6 +32,7 @@
|
|||
- Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds.
|
||||
- Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files.
|
||||
- Prevent duplicate generated IDs from corrupting `.fig` round trips.
|
||||
- Populate lazy `.fig` pages for file-mode CLI inspection and preserve the whole document when exporting FIG unless a page is explicitly requested.
|
||||
- Match Figma auto-layout reflow after deleting children, hiding optional instance slots, or syncing component changes.
|
||||
- Make group and boolean-operation children scale with their parent during resize.
|
||||
- Restore desktop copy, cut, and paste when browser clipboard events are unavailable.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
|||
|
||||
import { requireFile } from '#cli/app-client'
|
||||
import { ok, printError } from '#cli/format'
|
||||
import { loadDocument } from '#cli/headless'
|
||||
import { loadDocument, populateWholeDocument } from '#cli/headless'
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
|
||||
|
|
@ -51,6 +51,7 @@ export default defineCommand({
|
|||
|
||||
const file = requireFile(args.file)
|
||||
const graph = await loadDocument(file)
|
||||
populateWholeDocument(graph)
|
||||
const result = await io.writeDocument(format, graph)
|
||||
const output = args.output ? resolve(args.output) : defaultOutput(file, format)
|
||||
await writeFile(output, result.data as Uint8Array)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { FigmaAPI } from '@open-pencil/core/figma-api'
|
|||
import { isAppMode, requireFile, rpc } from '#cli/app-client'
|
||||
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
|
||||
import { printError } from '#cli/format'
|
||||
import { loadDocument } from '#cli/headless'
|
||||
import { loadDocument, populateWholeDocument } from '#cli/headless'
|
||||
|
||||
function printResult(value: unknown, json: boolean) {
|
||||
if (json || !process.stdout.isTTY) {
|
||||
|
|
@ -71,6 +71,7 @@ export default defineCommand({
|
|||
|
||||
const file = requireFile(args.file)
|
||||
const graph = await loadDocument(file)
|
||||
populateWholeDocument(graph)
|
||||
const figma = new FigmaAPI(graph)
|
||||
|
||||
type AsyncFunctionConstructor = new (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { isAppMode, requireFile, rpc } from '#cli/app-client'
|
||||
import { appTargetOptions, appTargetRpcArgs } from '#cli/app-target'
|
||||
import { ok, printError } from '#cli/format'
|
||||
import { loadDocument } from '#cli/headless'
|
||||
import { loadDocument, populateDocumentPage, populateWholeDocument } from '#cli/headless'
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
|
||||
|
|
@ -102,7 +102,8 @@ function exportFileName(defaultName: string, extension: string, scale?: number):
|
|||
return scale ? `${defaultName}@${scale}x.${extension}` : `${defaultName}.${extension}`
|
||||
}
|
||||
|
||||
function targetLabel(pageName?: string, nodeId?: string): string {
|
||||
function targetLabel(pageName?: string, nodeId?: string, wholeDocument = false): string {
|
||||
if (wholeDocument) return 'whole document'
|
||||
if (nodeId) return `node ${nodeId}`
|
||||
return pageName ? `page "${pageName}"` : 'first page'
|
||||
}
|
||||
|
|
@ -152,6 +153,31 @@ async function exportHTMLFromFile(
|
|||
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
|
||||
}
|
||||
|
||||
function prepareGraphForExport(
|
||||
graph: Awaited<ReturnType<typeof loadDocument>>,
|
||||
pageId: string,
|
||||
format: string,
|
||||
args: ExportArgs
|
||||
): boolean {
|
||||
const wholeDocument = format === 'FIG' && !args.page && !args.node
|
||||
if (wholeDocument || args.node) populateWholeDocument(graph)
|
||||
else populateDocumentPage(graph, pageId)
|
||||
return wholeDocument
|
||||
}
|
||||
|
||||
async function executeFileExport(
|
||||
formatId: string,
|
||||
graph: Awaited<ReturnType<typeof loadDocument>>,
|
||||
target: FileExportTarget,
|
||||
options:
|
||||
| { format?: string; scale?: number; quality?: number; renderThumbnail?: boolean }
|
||||
| undefined,
|
||||
wholeDocument: boolean
|
||||
) {
|
||||
if (wholeDocument) return io.writeDocument(formatId, graph, options)
|
||||
return io.exportContent(formatId, { graph, target }, options)
|
||||
}
|
||||
|
||||
async function exportFromFile(format: string, args: ExportArgs) {
|
||||
const file = requireFile(args.file)
|
||||
const graph = await loadDocument(file)
|
||||
|
|
@ -175,6 +201,8 @@ async function exportFromFile(format: string, args: ExportArgs) {
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
const wholeDocument = prepareGraphForExport(graph, page.id, format, args)
|
||||
|
||||
const target = args.node
|
||||
? { scope: 'node' as const, nodeId: args.node }
|
||||
: { scope: 'page' as const, pageId: page.id }
|
||||
|
|
@ -205,7 +233,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
const result = await io.exportContent(formatId, { graph, target }, options)
|
||||
const result = await executeFileExport(formatId, graph, target, options, wholeDocument)
|
||||
const output = resolve(
|
||||
args.output ??
|
||||
exportFileName(
|
||||
|
|
@ -215,7 +243,7 @@ async function exportFromFile(format: string, args: ExportArgs) {
|
|||
)
|
||||
)
|
||||
await writeAndLog(output, result.data as string | Uint8Array)
|
||||
console.log(ok(`Target: ${targetLabel(args.page, args.node)}`))
|
||||
console.log(ok(`Target: ${targetLabel(args.page, args.node, wholeDocument)}`))
|
||||
}
|
||||
|
||||
export default defineCommand({
|
||||
|
|
@ -247,7 +275,7 @@ export default defineCommand({
|
|||
},
|
||||
page: {
|
||||
type: 'string',
|
||||
description: 'Export a specific page by name (default: first page)',
|
||||
description: 'Export a specific page by name (FIG defaults to the whole document)',
|
||||
required: false
|
||||
},
|
||||
node: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import { BUILTIN_IO_FORMATS, IORegistry, initCanvasKit } from '@open-pencil/core/io'
|
||||
import { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from '@open-pencil/core/kiwi'
|
||||
import { computeAllLayouts } from '@open-pencil/core/layout'
|
||||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
|
|
@ -14,3 +15,42 @@ export async function loadDocument(filePath: string): Promise<SceneGraph> {
|
|||
computeAllLayouts(graph)
|
||||
return graph
|
||||
}
|
||||
|
||||
export function populateDocumentPage(graph: SceneGraph, pageId: string): boolean {
|
||||
const changed = populateLazyFigImportRoots(graph, [pageId])
|
||||
if (changed) computeAllLayouts(graph, pageId)
|
||||
return changed
|
||||
}
|
||||
|
||||
export function populateWholeDocument(graph: SceneGraph): boolean {
|
||||
const changed = populateAllLazyFigImportRoots(graph)
|
||||
if (changed) computeAllLayouts(graph)
|
||||
return changed
|
||||
}
|
||||
|
||||
function pageNameFromArgs(args: unknown): string | undefined {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
|
||||
const page = (args as { page?: unknown }).page
|
||||
return typeof page === 'string' ? page : undefined
|
||||
}
|
||||
|
||||
function populateRequestedPage(graph: SceneGraph, pageName?: string): void {
|
||||
const pages = graph.getPages()
|
||||
const page = pageName ? pages.find((candidate) => candidate.name === pageName) : pages[0]
|
||||
if (page) populateDocumentPage(graph, page.id)
|
||||
}
|
||||
|
||||
export function prepareDocumentForRpc(graph: SceneGraph, command: string, args?: unknown): void {
|
||||
if (command === 'pages' || command === 'variables') return
|
||||
if (command === 'tree') {
|
||||
populateRequestedPage(graph, pageNameFromArgs(args))
|
||||
return
|
||||
}
|
||||
if (command === 'find' || command === 'query') {
|
||||
const pageName = pageNameFromArgs(args)
|
||||
if (pageName) populateRequestedPage(graph, pageName)
|
||||
else populateWholeDocument(graph)
|
||||
return
|
||||
}
|
||||
populateWholeDocument(graph)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { executeRpcCommand } from '@open-pencil/core/rpc'
|
|||
|
||||
import { isAppMode, requireFile, rpc } from '#cli/app-client'
|
||||
import { appTargetRpcArgs, type AppTargetCliArgs } from '#cli/app-target'
|
||||
import { loadDocument } from '#cli/headless'
|
||||
import { loadDocument, prepareDocumentForRpc } from '#cli/headless'
|
||||
|
||||
type RpcArgs = { [key: string]: unknown }
|
||||
|
||||
|
|
@ -23,5 +23,6 @@ export async function loadRpcData<Result>(
|
|||
})
|
||||
}
|
||||
const graph = await loadDocument(requireFile(file))
|
||||
prepareDocumentForRpc(graph, command, args)
|
||||
return executeRpcCommand(graph, command, args) as Result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,24 @@ export function getLazyFigImportContext(graph: SceneGraph): LazyFigImportContext
|
|||
return lazyFigImportContexts.get(graph)
|
||||
}
|
||||
|
||||
function applyPopulation(
|
||||
graph: SceneGraph,
|
||||
context: LazyFigImportContext,
|
||||
rootIds?: string[]
|
||||
): void {
|
||||
graph.preserveSourceMetadataDuring(() => {
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
context.changeMap,
|
||||
context.guidToNodeId,
|
||||
context.blobs,
|
||||
rootIds
|
||||
)
|
||||
})
|
||||
const populatedRootIds = rootIds ?? graph.getPages(true).map((page) => page.id)
|
||||
for (const id of populatedRootIds) context.populatedRootIds.add(id)
|
||||
}
|
||||
|
||||
function populateRoots(
|
||||
graph: SceneGraph,
|
||||
context: LazyFigImportContext,
|
||||
|
|
@ -26,18 +44,7 @@ function populateRoots(
|
|||
): boolean {
|
||||
const pending = [...rootIds].filter((id) => id && !context.populatedRootIds.has(id))
|
||||
if (pending.length === 0) return false
|
||||
|
||||
graph.preserveSourceMetadataDuring(() => {
|
||||
populateAndApplyOverrides(
|
||||
graph,
|
||||
context.changeMap,
|
||||
context.guidToNodeId,
|
||||
context.blobs,
|
||||
pending
|
||||
)
|
||||
})
|
||||
|
||||
for (const id of pending) context.populatedRootIds.add(id)
|
||||
applyPopulation(graph, context, pending)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -49,9 +56,12 @@ export function populateLazyFigImportRoots(graph: SceneGraph, rootIds: Iterable<
|
|||
export function populateAllLazyFigImportRoots(graph: SceneGraph): boolean {
|
||||
const context = getLazyFigImportContext(graph)
|
||||
if (!context) return false
|
||||
return populateRoots(
|
||||
graph,
|
||||
context,
|
||||
graph.getPages(true).map((page) => page.id)
|
||||
)
|
||||
const rootIds = graph.getPages(true).map((page) => page.id)
|
||||
if (rootIds.every((id) => context.populatedRootIds.has(id))) return false
|
||||
|
||||
// Revisit the initially populated page without an active-root filter.
|
||||
// Cross-page component chains can only stabilize when global override
|
||||
// resolution can see every source and target in the same pass.
|
||||
applyPopulation(graph, context)
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { prefetchFigmaSchema } from '#core/clipboard'
|
||||
export { readFigFile, parseFigFile } from './fig/file'
|
||||
export { importNodeChanges } from './fig/import'
|
||||
export { populateAllLazyFigImportRoots, populateLazyFigImportRoots } from './fig/lazy-import'
|
||||
export { deduplicateNodeChangePluginData } from '@open-pencil/kiwi/fig/parse'
|
||||
export {
|
||||
initCodec,
|
||||
|
|
|
|||
|
|
@ -31,11 +31,70 @@ async function createFigFixture() {
|
|||
rect.paddingBottom = 16
|
||||
rect.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 } }]
|
||||
|
||||
const secondPage = graph.addPage('Second Page')
|
||||
createRect(graph, secondPage.id, {
|
||||
name: 'Second Card',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 60
|
||||
})
|
||||
|
||||
const result = await io.writeDocument('fig', graph)
|
||||
await Bun.write(figPath, result.data as Uint8Array)
|
||||
return { dir, figPath }
|
||||
}
|
||||
|
||||
test('FIG export preserves the whole document by default', async () => {
|
||||
const { dir, figPath } = await createFigFixture()
|
||||
const output = join(dir, 'whole.fig')
|
||||
|
||||
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
|
||||
'export',
|
||||
figPath,
|
||||
'--format',
|
||||
'fig',
|
||||
'--output',
|
||||
output
|
||||
])
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(exitCode).toBe(0)
|
||||
expect(stdout).toContain('Target: whole document')
|
||||
|
||||
const { graph } = await io.readDocument({
|
||||
name: output,
|
||||
data: new Uint8Array(await Bun.file(output).arrayBuffer())
|
||||
})
|
||||
expect(graph.getPages()).toHaveLength(3)
|
||||
expect(graph.getPages().map((page) => page.name)).toContain('Second Page')
|
||||
})
|
||||
|
||||
test('FIG export requires an explicit page for a partial archive', async () => {
|
||||
const { dir, figPath } = await createFigFixture()
|
||||
const output = join(dir, 'page.fig')
|
||||
|
||||
const { stderr, exitCode } = await runOpenPencilCLI([
|
||||
'export',
|
||||
figPath,
|
||||
'--format',
|
||||
'fig',
|
||||
'--page',
|
||||
'Second Page',
|
||||
'--output',
|
||||
output
|
||||
])
|
||||
|
||||
expect(stderr).toBe('')
|
||||
expect(exitCode).toBe(0)
|
||||
|
||||
const { graph } = await io.readDocument({
|
||||
name: output,
|
||||
data: new Uint8Array(await Bun.file(output).arrayBuffer())
|
||||
})
|
||||
expect(graph.getPages().map((page) => page.name)).toEqual(['Second Page'])
|
||||
})
|
||||
|
||||
test('export CLI writes HTML with inline styles by default', async () => {
|
||||
const { dir, figPath } = await createFigFixture()
|
||||
const output = join(dir, 'card.html')
|
||||
|
|
|
|||
86
tests/engine/cli/headless.test.ts
Normal file
86
tests/engine/cli/headless.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { loadDocument, prepareDocumentForRpc } from '#cli/headless'
|
||||
|
||||
const io = new IORegistry(BUILTIN_IO_FORMATS)
|
||||
|
||||
async function createLazyFixture() {
|
||||
const graph = new SceneGraph()
|
||||
const [page1] = graph.getPages()
|
||||
page1.name = 'Page 1'
|
||||
const page2 = graph.addPage('Page 2')
|
||||
const component = graph.createNode('COMPONENT', page1.id, {
|
||||
name: 'Button',
|
||||
width: 100,
|
||||
height: 40
|
||||
})
|
||||
graph.createNode('RECTANGLE', component.id, {
|
||||
name: 'Background',
|
||||
width: 100,
|
||||
height: 40
|
||||
})
|
||||
graph.createNode('INSTANCE', page1.id, {
|
||||
name: 'Button instance 1',
|
||||
componentId: component.id,
|
||||
width: 100,
|
||||
height: 40
|
||||
})
|
||||
graph.createNode('INSTANCE', page2.id, {
|
||||
name: 'Button instance 2',
|
||||
componentId: component.id,
|
||||
width: 100,
|
||||
height: 40
|
||||
})
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-headless-lazy-'))
|
||||
const path = join(dir, 'lazy.fig')
|
||||
const result = await io.writeDocument('fig', graph)
|
||||
await Bun.write(path, result.data as Uint8Array)
|
||||
return loadDocument(path)
|
||||
}
|
||||
|
||||
function pageInstance(graph: SceneGraph, pageName: string) {
|
||||
const page = graph.getPages().find((candidate) => candidate.name === pageName)
|
||||
return page
|
||||
? graph.getChildren(page.id).find((candidate) => candidate.type === 'INSTANCE')
|
||||
: undefined
|
||||
}
|
||||
|
||||
describe('headless CLI lazy .fig preparation', () => {
|
||||
test('populates only the requested tree page', async () => {
|
||||
const graph = await createLazyFixture()
|
||||
const page1Instance = pageInstance(graph, 'Page 1')
|
||||
const page2Instance = pageInstance(graph, 'Page 2')
|
||||
|
||||
expect(page1Instance ? graph.getChildren(page1Instance.id) : []).toHaveLength(1)
|
||||
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0)
|
||||
|
||||
prepareDocumentForRpc(graph, 'tree', { page: 'Page 2' })
|
||||
|
||||
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('populates all pages for document-wide searches', async () => {
|
||||
const graph = await createLazyFixture()
|
||||
const page2Instance = pageInstance(graph, 'Page 2')
|
||||
|
||||
prepareDocumentForRpc(graph, 'find', {})
|
||||
|
||||
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('keeps page listings lazy', async () => {
|
||||
const graph = await createLazyFixture()
|
||||
const page2Instance = pageInstance(graph, 'Page 2')
|
||||
|
||||
prepareDocumentForRpc(graph, 'pages')
|
||||
|
||||
expect(page2Instance ? graph.getChildren(page2Instance.id) : []).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -64,7 +64,7 @@ describe('lazy .fig page population', () => {
|
|||
const { graph, page1Instance, page2Instance } = createLazyGraph()
|
||||
|
||||
expect(populateAllLazyFigImportRoots(graph)).toBe(true)
|
||||
expect(graph.getChildren(page1Instance.id)).toHaveLength(0)
|
||||
expect(graph.getChildren(page1Instance.id)).toHaveLength(1)
|
||||
expect(graph.getChildren(page2Instance.id)).toHaveLength(1)
|
||||
expect(populateAllLazyFigImportRoots(graph)).toBe(false)
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue