diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ca12c74..e7ddab90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Move complete `.fig` archive parsing, bidirectional SceneGraph/NodeChange conversion, and component/instance interpretation into `@open-pencil/fig`, keeping runtime font access and format-neutral IO orchestration in core and Kiwi schema/container mechanics in `@open-pencil/kiwi`. - Remove internal cross-package forwarding modules; import `@open-pencil/fig`, `@open-pencil/pen`, and `@open-pencil/scene-graph` from their owning public exports. - Track normalized source edits in SceneGraph while preserving original `.fig` provenance and filtering stale raw fields through Fig-owned metadata policy. +- Eliminate quadratic component-property scans and redundant instance propagation during large `.fig` round trips, reducing the Material 3 fixture regression from minutes to seconds. - Add Figma-style page management in the Pages panel, including rename/delete actions and drag-and-drop page reordering. - Add DOM/CSS import and authoring support so HTML, CSS, Tailwind, and JSX can be converted into editable OpenPencil documents from the app, CLI, and SDK. - Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI. diff --git a/bun.lock b/bun.lock index 1b7e56567..2404ffe91 100644 --- a/bun.lock +++ b/bun.lock @@ -207,6 +207,7 @@ "name": "@open-pencil/fig", "version": "0.13.2", "dependencies": { + "es-toolkit": "^1.46.1", "fflate": "^0.8.2", }, "devDependencies": { diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 254c31497..0abd83129 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -2,7 +2,7 @@ import type { CanvasKit } from 'canvaskit-wasm' import { deflateSync, inflateSync } from 'fflate' import { compressFigDataSync } from '@open-pencil/fig' -import { stringToGuid } from '@open-pencil/fig/node-change' +import { buildComponentPropIndex, stringToGuid } from '@open-pencil/fig/node-change' import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec' import type { NodeChange } from '@open-pencil/kiwi/fig/codec' import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' @@ -312,6 +312,7 @@ interface InternalResourceContext { glyphBlobMap: Map blobIndexByHex: Map assignedGuidValues: Set + componentPropertyDefinitionsById: ReturnType } function appendInternalResources(context: InternalResourceContext): void { @@ -332,7 +333,8 @@ function appendInternalResources(context: InternalResourceContext): void { context.varIdToGuid, context.glyphBlobMap, context.blobIndexByHex, - context.assignedGuidValues + context.assignedGuidValues, + context.componentPropertyDefinitionsById ) ) } @@ -395,6 +397,7 @@ export async function exportFigFile( const fontDigestMap = await buildFontDigestMap(graph) const glyphBlobMap = new Map() const blobIndexByHex = new Map() + const componentPropertyDefinitionsById = buildComponentPropIndex(graph) // Scan ALL imported source.ids BEFORE any new GUID assignment to find // max sessionID:0 and sessionID:1 localID values. This guarantees the @@ -450,7 +453,8 @@ export async function exportFigFile( varIdToGuid, glyphBlobMap, blobIndexByHex, - assignedGuidValues + assignedGuidValues, + componentPropertyDefinitionsById ) ) } @@ -468,7 +472,8 @@ export async function exportFigFile( modeIdToGuid, glyphBlobMap, blobIndexByHex, - assignedGuidValues + assignedGuidValues, + componentPropertyDefinitionsById }) const msg: Record = { diff --git a/packages/core/src/io/formats/fig/index.ts b/packages/core/src/io/formats/fig/index.ts index 18834cbd4..07bb38a73 100644 --- a/packages/core/src/io/formats/fig/index.ts +++ b/packages/core/src/io/formats/fig/index.ts @@ -1,2 +1,2 @@ -export { readFigFile, parseFigFile } from './read' +export { readFigFile, parseFigFile, type ParseFigFileOptions } from './read' export { exportFigFile, compressFigData, compressFigDataSync } from './write' diff --git a/packages/core/src/io/formats/fig/read.ts b/packages/core/src/io/formats/fig/read.ts index dceef8e57..8d2817bfa 100644 --- a/packages/core/src/io/formats/fig/read.ts +++ b/packages/core/src/io/formats/fig/read.ts @@ -7,7 +7,7 @@ import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer' import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer' export interface ParseFigFileOptions { - populate?: 'all' | 'first-page' + populate?: 'all' | 'first-page' | 'none' } function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph { diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index 43a1206c0..e4119094b 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -10,7 +10,7 @@ export { svgFormat, jsxFormat } from './formats' -export { exportFigFile, parseFigFile, readFigFile } from './formats/fig' +export { exportFigFile, parseFigFile, readFigFile, type ParseFigFileOptions } from './formats/fig' export { parsePenFile, readPenFile } from '@open-pencil/pen' export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx' export { diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index ddad637a8..af858e05b 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -390,7 +390,7 @@ function applyStyleRefs(changeMap: Map): void { } export interface FigImportOptions { - populate?: 'all' | 'first-page' + populate?: 'all' | 'first-page' | 'none' } function rememberLazyFigImportContext( @@ -408,6 +408,19 @@ function rememberLazyFigImportContext( }) } +function componentPageIdsForLazyPopulation(graph: SceneGraph): Set { + const pageIds = new Set() + for (const node of graph.getAllNodes()) { + if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') continue + let current = node.parentId ? graph.getNode(node.parentId) : undefined + while (current?.parentId && current.type !== 'CANVAS') { + current = graph.getNode(current.parentId) + } + if (current?.type === 'CANVAS') pageIds.add(current.id) + } + return pageIds +} + export function importNodeChanges( nodeChanges: NodeChange[], blobs: Uint8Array[] = [], @@ -469,27 +482,24 @@ export function importNodeChanges( applyVariantPropSpecs(graph) const firstPageId = graph.getPages()[0]?.id - const componentPageIds = new Set() - for (const node of graph.getAllNodes()) { - if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') continue - let current = node.parentId ? graph.getNode(node.parentId) : undefined - while (current?.parentId && current.type !== 'CANVAS') current = graph.getNode(current.parentId) - if (current?.type === 'CANVAS') componentPageIds.add(current.id) - } + const componentPageIds = + options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set() const activeRootIds = options.populate === 'first-page' ? [firstPageId, ...componentPageIds].filter(isNotNil) : undefined - graph.preserveSourceMetadataDuring(() => { - populateAndApplyOverrides( - graph, - changeMap as Map, - guidToNodeId, - blobs, - activeRootIds - ) - }) + if (options.populate !== 'none') { + graph.preserveSourceMetadataDuring(() => { + populateAndApplyOverrides( + graph, + changeMap as Map, + guidToNodeId, + blobs, + activeRootIds + ) + }) + } if (activeRootIds) rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds) diff --git a/packages/core/src/kiwi/fig/node-change/serialize.ts b/packages/core/src/kiwi/fig/node-change/serialize.ts index 6fd7e5561..2789d53f5 100644 --- a/packages/core/src/kiwi/fig/node-change/serialize.ts +++ b/packages/core/src/kiwi/fig/node-change/serialize.ts @@ -2,7 +2,7 @@ import { sceneNodeToKiwi as sceneNodeToKiwiWithRuntime, type KiwiNodeChange } from '@open-pencil/fig/node-change' -import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' +import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { GUID } from '@open-pencil/scene-graph/primitives' import { getGlyphOutlineMetricsSync } from '#core/text/opentype' @@ -36,7 +36,8 @@ export function sceneNodeToKiwi( varIdToGuid?: Map, glyphBlobMap = new Map(), blobIndexByHex?: Map, - assignedGuidValues?: Set + assignedGuidValues?: Set, + componentPropertyDefinitionsById?: ReadonlyMap ): KiwiNodeChange[] { return sceneNodeToKiwiWithRuntime( node, @@ -51,6 +52,7 @@ export function sceneNodeToKiwi( glyphBlobMap, blobIndexByHex, assignedGuidValues, - coreFigExportRuntime + coreFigExportRuntime, + componentPropertyDefinitionsById ) } diff --git a/packages/fig/package.json b/packages/fig/package.json index 0b41c77f2..70d280142 100644 --- a/packages/fig/package.json +++ b/packages/fig/package.json @@ -52,6 +52,7 @@ "@open-pencil/scene-graph": "workspace:*" }, "dependencies": { + "es-toolkit": "^1.46.1", "fflate": "^0.8.2" }, "devDependencies": { diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index a41513b96..1f439324e 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -14,6 +14,8 @@ export type { SymbolOverride } from './types' +import { isEqual } from 'es-toolkit/predicate' + import { guidToString } from '@open-pencil/fig/node-change' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import { copyFills, copyStyleRuns } from '@open-pencil/scene-graph/copy' @@ -85,7 +87,7 @@ function propagateResolvedFills( if (activeNodeIds && !activeNodeIds.has(node.id)) continue if (!node.componentId) continue const source = graph.getNode(node.componentId) - if (!source || source.fills === node.fills) continue + if (!source || isEqual(source.fills, node.fills)) continue if (protectedNodes.has(node.id) && !protectedNodes.has(source.id)) continue graph.updateNode(node.id, { fills: copyFills(source.fills) }) changed = true @@ -132,6 +134,15 @@ function propagateResolvedTextClones(graph: SceneGraph): void { if (node.type !== 'TEXT' || !node.componentId) continue const source = graph.getNode(node.componentId) if (source?.type !== 'TEXT' || source.text !== node.text) continue + if ( + source.width === node.width && + source.height === node.height && + isEqual(source.fills, node.fills) && + isEqual(source.styleRuns, node.styleRuns) && + isEqual(source.figmaDerivedTextGlyphs, node.figmaDerivedTextGlyphs) + ) { + continue + } graph.updateNode(node.id, { width: source.width, height: source.height, @@ -155,8 +166,12 @@ function buildOverrideContext( activeNodeIds?: Set ): OverrideContext { const overrideKeyToGuid = new Map() + const assetRefToGuid = new Map() for (const [id, nc] of changeMap) { if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id) + if (typeof nc.key !== 'string') continue + assetRefToGuid.set(nc.key, id) + if (typeof nc.version === 'string') assetRefToGuid.set(`${nc.key}@${nc.version}`, id) } const propDefaults = new Map() @@ -185,6 +200,7 @@ function buildOverrideContext( guidToNodeId, blobs, overrideKeyToGuid, + assetRefToGuid, nodeIdToGuid, propDefaults, propNames, diff --git a/packages/fig/src/instance-overrides/symbol/patches.ts b/packages/fig/src/instance-overrides/symbol/patches.ts index 27d6e4169..ad533b51d 100644 --- a/packages/fig/src/instance-overrides/symbol/patches.ts +++ b/packages/fig/src/instance-overrides/symbol/patches.ts @@ -25,18 +25,6 @@ function assetRefKey(assetRef: { key: string; version?: string }): string { return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key } -function buildAssetRefMap(ctx: OverrideContext): Map { - const refs = new Map() - for (const [id, nc] of ctx.changeMap) { - const key = typeof nc.key === 'string' ? nc.key : undefined - if (!key) continue - refs.set(key, id) - const version = typeof nc.version === 'string' ? nc.version : undefined - if (version) refs.set(assetRefKey({ key, version }), id) - } - return refs -} - function resolveAliasId(alias: AliasRef, assetRefs: Map): string | undefined { if (alias.guid) return guidToString(alias.guid) const assetRef = alias.assetRef @@ -69,7 +57,7 @@ function applyVariableRadiusOverrides( ): void { const entries = fields.variableConsumptionMap?.entries if (!entries?.length) return - const assetRefs = buildAssetRefMap(ctx) + const assetRefs = ctx.assetRefToGuid for (const entry of entries) { const variableField = entry.variableField if (!variableField || !VARIABLE_RADIUS_FIELDS.has(variableField)) continue diff --git a/packages/fig/src/instance-overrides/types.ts b/packages/fig/src/instance-overrides/types.ts index 31addf80d..cd94ebb74 100644 --- a/packages/fig/src/instance-overrides/types.ts +++ b/packages/fig/src/instance-overrides/types.ts @@ -106,6 +106,7 @@ export interface OverrideContext { blobs: Uint8Array[] overrideKeyToGuid: Map + assetRefToGuid: Map nodeIdToGuid: Map propDefaults: Map propNames: Map diff --git a/packages/fig/src/node-change/bytes.ts b/packages/fig/src/node-change/bytes.ts index e594cc38b..60449cbab 100644 --- a/packages/fig/src/node-change/bytes.ts +++ b/packages/fig/src/node-change/bytes.ts @@ -12,10 +12,12 @@ export function hexToBytes(hex: string): Uint8Array { return bytes } +const HEX_BYTES = Array.from({ length: 256 }, (_, byte) => byte.toString(16).padStart(2, '0')) + export function bytesToHex(bytes: Uint8Array): string { - let hex = '' - for (const byte of bytes) { - hex += byte.toString(16).padStart(2, '0') - } - return hex + if (typeof bytes.toHex === 'function') return bytes.toHex() + + const chunks = Array.from({ length: bytes.length }, () => '') + for (let index = 0; index < bytes.length; index++) chunks[index] = HEX_BYTES[bytes[index]] + return chunks.join('') } diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 33ebd9bab..8096fb7e7 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -63,6 +63,7 @@ interface SceneNodeToKiwiContext { /** Maps "key@version" or "key" (from variable.key/version) → variable GUID. * Used to convert colorVar.assetRef references in raw paints to guid references. */ assetRefToVarGuid?: Map + componentPropertyDefinitionsById: ReadonlyMap fractionalPosition: (index: number) => string mapToFigmaType: (type: SceneNode['type']) => string fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint @@ -530,12 +531,16 @@ function componentPropertyNodeField(field: ComponentPropertyReferenceField): str return 'VISIBLE' } -function findComponentPropertyDefinition(graph: SceneGraph, id: string) { +export function buildComponentPropIndex( + graph: SceneGraph +): ReadonlyMap { + const definitions = new Map() for (const candidate of graph.getAllNodes()) { - const definition = candidate.componentPropertyDefinitions.find((item) => item.id === id) - if (definition) return definition + for (const definition of candidate.componentPropertyDefinitions) { + if (!definitions.has(definition.id)) definitions.set(definition.id, definition) + } } - return undefined + return definitions } function shouldSerializeRawBackedField( @@ -598,7 +603,7 @@ function applyComponentMetadata( const componentPropAssignments = Object.entries(node.componentPropertyAssignments) .map(([propertyId, value]) => { const defID = parseGuidOrNull(propertyId) - const definition = findComponentPropertyDefinition(context.graph, propertyId) + const definition = context.componentPropertyDefinitionsById.get(propertyId) return defID && definition ? { defID, diff --git a/packages/fig/src/node-change/serialize.ts b/packages/fig/src/node-change/serialize.ts index 3257a9e13..a165b0d4a 100644 --- a/packages/fig/src/node-change/serialize.ts +++ b/packages/fig/src/node-change/serialize.ts @@ -30,6 +30,7 @@ import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/p import { buildAssetRefToVarGuidMap, + buildComponentPropIndex, sceneNodeToKiwiWithContext, type KiwiNodeChange } from './export-node' @@ -470,7 +471,8 @@ export function sceneNodeToKiwi( glyphBlobMap = new Map(), blobIndexByHex?: Map, assignedGuidValues?: Set, - runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME + runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME, + componentPropertyDefinitionsById = buildComponentPropIndex(graph) ): KiwiNodeChange[] { // Build assetRef to guid mapping for converting colorVar references in raw paints const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined @@ -484,6 +486,7 @@ export function sceneNodeToKiwi( glyphBlobMap, varIdToGuid, assetRefToVarGuid, + componentPropertyDefinitionsById, fractionalPosition, mapToFigmaType, fillToKiwiPaint, diff --git a/packages/fig/tests/export.test.ts b/packages/fig/tests/export.test.ts index fa9c81d75..db7049e88 100644 --- a/packages/fig/tests/export.test.ts +++ b/packages/fig/tests/export.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { SceneGraph } from '@open-pencil/scene-graph' import { + buildComponentPropIndex, fractionalPosition, mapToFigmaType, sceneNodeToKiwi, @@ -15,6 +16,41 @@ describe('@open-pencil/fig SceneGraph export policy', () => { expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!']) }) + test('reuses an export-scoped component property definition index', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const component = graph.createNode('COMPONENT', page.id, { + componentPropertyDefinitions: [ + { id: '1:100', name: 'Label', type: 'TEXT', defaultValue: 'Default' } + ] + }) + const instance = graph.createNode('INSTANCE', page.id, { + componentId: component.id, + componentPropertyAssignments: { '1:100': 'Override' } + }) + const serialize = (definitions?: ReturnType) => + sceneNodeToKiwi( + instance, + { sessionID: 1, localID: 1 }, + 0, + { value: 2 }, + graph, + [], + new Map(), + undefined, + undefined, + undefined, + undefined, + new Set(), + undefined, + definitions + )[0].componentPropAssignments + + const definitions = buildComponentPropIndex(graph) + expect(definitions.get('1:100')).toBe(component.componentPropertyDefinitions[0]) + expect(serialize(definitions)).toEqual(serialize()) + }) + test('injects runtime glyph outlines into derived text data', () => { const graph = new SceneGraph() const text = graph.createNode('TEXT', graph.getPages()[0].id, { diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index e83b089d8..384b47cc1 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -27,6 +27,31 @@ describe('@open-pencil/fig instance interpretation', () => { expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label') }) + test('resolves text clone chains to their source values', () => { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + const source = graph.createNode('TEXT', pageId, { + text: 'Label', + width: 80, + fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + }) + const middle = graph.createNode('TEXT', pageId, { + componentId: source.id, + text: 'Label', + width: 120 + }) + const leaf = graph.createNode('TEXT', pageId, { + componentId: middle.id, + text: 'Label', + width: 160 + }) + + populateAndApplyOverrides(graph, new Map(), new Map()) + + expect(graph.getNode(middle.id)?.width).toBe(80) + expect(graph.getNode(leaf.id)).toMatchObject({ width: 80, fills: source.fills }) + }) + test('preserves protected text while synchronizing other fields', () => { const graph = new SceneGraph() const pageId = graph.getPages()[0].id diff --git a/tests/engine/io/fig/heavy/fixtures.test.ts b/tests/engine/io/fig/heavy/fixtures.test.ts index b504074c1..5166614a1 100644 --- a/tests/engine/io/fig/heavy/fixtures.test.ts +++ b/tests/engine/io/fig/heavy/fixtures.test.ts @@ -15,8 +15,8 @@ heavy('parse heavy .fig files', () => { let nuxtUiNodes: SceneNode[] beforeAll(async () => { - material3 = await parseFixture('material3.fig') - nuxtui = await parseFixture('nuxtui.fig') + material3 = await parseFixture('material3.fig', { populate: 'none' }) + nuxtui = await parseFixture('nuxtui.fig', { populate: 'none' }) material3Nodes = collectAllNodes(material3) nuxtUiNodes = collectAllNodes(nuxtui) }) diff --git a/tests/engine/io/fig/import/component-props.test.ts b/tests/engine/io/fig/import/component-props.test.ts index ff4ea7aa3..288f5762e 100644 --- a/tests/engine/io/fig/import/component-props.test.ts +++ b/tests/engine/io/fig/import/component-props.test.ts @@ -97,6 +97,12 @@ describe('Figma component property import', () => { (node) => node.name === 'Menu item instance' ) expect(instance?.componentPropertyAssignments).toEqual({ '3:1': 'Profile Item' }) + + const unpopulated = importNodeChanges(nodeChanges, [], undefined, { populate: 'none' }) + const unpopulatedInstance = Array.from(unpopulated.getAllNodes()).find( + (node) => node.name === 'Menu item instance' + ) + expect(unpopulatedInstance?.childIds).toEqual([]) }) test('propagates nested instance swaps through clone chains', () => { diff --git a/tests/helpers/fig-fixtures.ts b/tests/helpers/fig-fixtures.ts index df616d243..8823df098 100644 --- a/tests/helpers/fig-fixtures.ts +++ b/tests/helpers/fig-fixtures.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { parseFigFile } from '@open-pencil/core' -import type { SceneGraph, SceneNode } from '@open-pencil/core' +import type { ParseFigFileOptions, SceneGraph, SceneNode } from '@open-pencil/core' import { collectAllNodes } from './fig-traversal' @@ -33,9 +33,12 @@ export function readFixtureBytes(name: string): Uint8Array { return readFileSync(resolve(FIXTURES, name)) } -export async function parseFixture(name: string): Promise { +export async function parseFixture( + name: string, + options?: ParseFigFileOptions +): Promise { const bytes = readFixtureBytes(name) - return parseFigFile(bytes.buffer as ArrayBuffer) + return parseFigFile(bytes.buffer as ArrayBuffer, options) } export async function parseGoldPreviewFixture(): Promise<{