perf(fig): remove large fixture bottlenecks
- Index component property definitions and variable asset references once per export or override context\n- Skip converged instance fill and text propagation and use native binary hex encoding\n- Add a non-populating parse mode for raw fixture validation\n- Reduce the Material 3 round trip from minutes to about twenty seconds
This commit is contained in:
parent
6c9ef9d103
commit
8b9be17a56
|
|
@ -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`.
|
- 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.
|
- 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.
|
- 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 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 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.
|
- Add Tailwind class serialization for DOM/CSS HTML export in the SDK and CLI.
|
||||||
|
|
|
||||||
1
bun.lock
1
bun.lock
|
|
@ -207,6 +207,7 @@
|
||||||
"name": "@open-pencil/fig",
|
"name": "@open-pencil/fig",
|
||||||
"version": "0.13.2",
|
"version": "0.13.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"es-toolkit": "^1.46.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { CanvasKit } from 'canvaskit-wasm'
|
||||||
import { deflateSync, inflateSync } from 'fflate'
|
import { deflateSync, inflateSync } from 'fflate'
|
||||||
|
|
||||||
import { compressFigDataSync } from '@open-pencil/fig'
|
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 { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec'
|
||||||
import type { NodeChange } 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'
|
import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime'
|
||||||
|
|
@ -312,6 +312,7 @@ interface InternalResourceContext {
|
||||||
glyphBlobMap: Map<string, number>
|
glyphBlobMap: Map<string, number>
|
||||||
blobIndexByHex: Map<string, number>
|
blobIndexByHex: Map<string, number>
|
||||||
assignedGuidValues: Set<string>
|
assignedGuidValues: Set<string>
|
||||||
|
componentPropertyDefinitionsById: ReturnType<typeof buildComponentPropIndex>
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendInternalResources(context: InternalResourceContext): void {
|
function appendInternalResources(context: InternalResourceContext): void {
|
||||||
|
|
@ -332,7 +333,8 @@ function appendInternalResources(context: InternalResourceContext): void {
|
||||||
context.varIdToGuid,
|
context.varIdToGuid,
|
||||||
context.glyphBlobMap,
|
context.glyphBlobMap,
|
||||||
context.blobIndexByHex,
|
context.blobIndexByHex,
|
||||||
context.assignedGuidValues
|
context.assignedGuidValues,
|
||||||
|
context.componentPropertyDefinitionsById
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -395,6 +397,7 @@ export async function exportFigFile(
|
||||||
const fontDigestMap = await buildFontDigestMap(graph)
|
const fontDigestMap = await buildFontDigestMap(graph)
|
||||||
const glyphBlobMap = new Map<string, number>()
|
const glyphBlobMap = new Map<string, number>()
|
||||||
const blobIndexByHex = new Map<string, number>()
|
const blobIndexByHex = new Map<string, number>()
|
||||||
|
const componentPropertyDefinitionsById = buildComponentPropIndex(graph)
|
||||||
|
|
||||||
// Scan ALL imported source.ids BEFORE any new GUID assignment to find
|
// Scan ALL imported source.ids BEFORE any new GUID assignment to find
|
||||||
// max sessionID:0 and sessionID:1 localID values. This guarantees the
|
// max sessionID:0 and sessionID:1 localID values. This guarantees the
|
||||||
|
|
@ -450,7 +453,8 @@ export async function exportFigFile(
|
||||||
varIdToGuid,
|
varIdToGuid,
|
||||||
glyphBlobMap,
|
glyphBlobMap,
|
||||||
blobIndexByHex,
|
blobIndexByHex,
|
||||||
assignedGuidValues
|
assignedGuidValues,
|
||||||
|
componentPropertyDefinitionsById
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -468,7 +472,8 @@ export async function exportFigFile(
|
||||||
modeIdToGuid,
|
modeIdToGuid,
|
||||||
glyphBlobMap,
|
glyphBlobMap,
|
||||||
blobIndexByHex,
|
blobIndexByHex,
|
||||||
assignedGuidValues
|
assignedGuidValues,
|
||||||
|
componentPropertyDefinitionsById
|
||||||
})
|
})
|
||||||
|
|
||||||
const msg: Record<string, unknown> = {
|
const msg: Record<string, unknown> = {
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
export { readFigFile, parseFigFile } from './read'
|
export { readFigFile, parseFigFile, type ParseFigFileOptions } from './read'
|
||||||
export { exportFigFile, compressFigData, compressFigDataSync } from './write'
|
export { exportFigFile, compressFigData, compressFigDataSync } from './write'
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { deserializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||||
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
import type { SerializedSceneGraph } from '#core/kiwi/fig/parse/transfer'
|
||||||
|
|
||||||
export interface ParseFigFileOptions {
|
export interface ParseFigFileOptions {
|
||||||
populate?: 'all' | 'first-page'
|
populate?: 'all' | 'first-page' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph {
|
function parseFigFileSync(buffer: ArrayBuffer, options: ParseFigFileOptions = {}): SceneGraph {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export {
|
||||||
svgFormat,
|
svgFormat,
|
||||||
jsxFormat
|
jsxFormat
|
||||||
} from './formats'
|
} 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 { parsePenFile, readPenFile } from '@open-pencil/pen'
|
||||||
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx'
|
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './formats/jsx'
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,7 @@ function applyStyleRefs(changeMap: Map<string, NodeChange>): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FigImportOptions {
|
export interface FigImportOptions {
|
||||||
populate?: 'all' | 'first-page'
|
populate?: 'all' | 'first-page' | 'none'
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberLazyFigImportContext(
|
function rememberLazyFigImportContext(
|
||||||
|
|
@ -408,6 +408,19 @@ function rememberLazyFigImportContext(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function componentPageIdsForLazyPopulation(graph: SceneGraph): Set<string> {
|
||||||
|
const pageIds = new Set<string>()
|
||||||
|
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(
|
export function importNodeChanges(
|
||||||
nodeChanges: NodeChange[],
|
nodeChanges: NodeChange[],
|
||||||
blobs: Uint8Array[] = [],
|
blobs: Uint8Array[] = [],
|
||||||
|
|
@ -469,18 +482,14 @@ export function importNodeChanges(
|
||||||
applyVariantPropSpecs(graph)
|
applyVariantPropSpecs(graph)
|
||||||
|
|
||||||
const firstPageId = graph.getPages()[0]?.id
|
const firstPageId = graph.getPages()[0]?.id
|
||||||
const componentPageIds = new Set<string>()
|
const componentPageIds =
|
||||||
for (const node of graph.getAllNodes()) {
|
options.populate === 'first-page' ? componentPageIdsForLazyPopulation(graph) : new Set<string>()
|
||||||
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 activeRootIds =
|
const activeRootIds =
|
||||||
options.populate === 'first-page'
|
options.populate === 'first-page'
|
||||||
? [firstPageId, ...componentPageIds].filter(isNotNil)
|
? [firstPageId, ...componentPageIds].filter(isNotNil)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
if (options.populate !== 'none') {
|
||||||
graph.preserveSourceMetadataDuring(() => {
|
graph.preserveSourceMetadataDuring(() => {
|
||||||
populateAndApplyOverrides(
|
populateAndApplyOverrides(
|
||||||
graph,
|
graph,
|
||||||
|
|
@ -490,6 +499,7 @@ export function importNodeChanges(
|
||||||
activeRootIds
|
activeRootIds
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (activeRootIds)
|
if (activeRootIds)
|
||||||
rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)
|
rememberLazyFigImportContext(graph, changeMap, guidToNodeId, blobs, activeRootIds)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import {
|
||||||
sceneNodeToKiwi as sceneNodeToKiwiWithRuntime,
|
sceneNodeToKiwi as sceneNodeToKiwiWithRuntime,
|
||||||
type KiwiNodeChange
|
type KiwiNodeChange
|
||||||
} from '@open-pencil/fig/node-change'
|
} 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 type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||||
|
|
||||||
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
|
import { getGlyphOutlineMetricsSync } from '#core/text/opentype'
|
||||||
|
|
@ -36,7 +36,8 @@ export function sceneNodeToKiwi(
|
||||||
varIdToGuid?: Map<string, GUID>,
|
varIdToGuid?: Map<string, GUID>,
|
||||||
glyphBlobMap = new Map<string, number>(),
|
glyphBlobMap = new Map<string, number>(),
|
||||||
blobIndexByHex?: Map<string, number>,
|
blobIndexByHex?: Map<string, number>,
|
||||||
assignedGuidValues?: Set<string>
|
assignedGuidValues?: Set<string>,
|
||||||
|
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>
|
||||||
): KiwiNodeChange[] {
|
): KiwiNodeChange[] {
|
||||||
return sceneNodeToKiwiWithRuntime(
|
return sceneNodeToKiwiWithRuntime(
|
||||||
node,
|
node,
|
||||||
|
|
@ -51,6 +52,7 @@ export function sceneNodeToKiwi(
|
||||||
glyphBlobMap,
|
glyphBlobMap,
|
||||||
blobIndexByHex,
|
blobIndexByHex,
|
||||||
assignedGuidValues,
|
assignedGuidValues,
|
||||||
coreFigExportRuntime
|
coreFigExportRuntime,
|
||||||
|
componentPropertyDefinitionsById
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
"@open-pencil/scene-graph": "workspace:*"
|
"@open-pencil/scene-graph": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"es-toolkit": "^1.46.1",
|
||||||
"fflate": "^0.8.2"
|
"fflate": "^0.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export type {
|
||||||
SymbolOverride
|
SymbolOverride
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
|
import { isEqual } from 'es-toolkit/predicate'
|
||||||
|
|
||||||
import { guidToString } from '@open-pencil/fig/node-change'
|
import { guidToString } from '@open-pencil/fig/node-change'
|
||||||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||||
import { copyFills, copyStyleRuns } from '@open-pencil/scene-graph/copy'
|
import { copyFills, copyStyleRuns } from '@open-pencil/scene-graph/copy'
|
||||||
|
|
@ -85,7 +87,7 @@ function propagateResolvedFills(
|
||||||
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
|
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
|
||||||
if (!node.componentId) continue
|
if (!node.componentId) continue
|
||||||
const source = graph.getNode(node.componentId)
|
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
|
if (protectedNodes.has(node.id) && !protectedNodes.has(source.id)) continue
|
||||||
graph.updateNode(node.id, { fills: copyFills(source.fills) })
|
graph.updateNode(node.id, { fills: copyFills(source.fills) })
|
||||||
changed = true
|
changed = true
|
||||||
|
|
@ -132,6 +134,15 @@ function propagateResolvedTextClones(graph: SceneGraph): void {
|
||||||
if (node.type !== 'TEXT' || !node.componentId) continue
|
if (node.type !== 'TEXT' || !node.componentId) continue
|
||||||
const source = graph.getNode(node.componentId)
|
const source = graph.getNode(node.componentId)
|
||||||
if (source?.type !== 'TEXT' || source.text !== node.text) continue
|
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, {
|
graph.updateNode(node.id, {
|
||||||
width: source.width,
|
width: source.width,
|
||||||
height: source.height,
|
height: source.height,
|
||||||
|
|
@ -155,8 +166,12 @@ function buildOverrideContext(
|
||||||
activeNodeIds?: Set<string>
|
activeNodeIds?: Set<string>
|
||||||
): OverrideContext {
|
): OverrideContext {
|
||||||
const overrideKeyToGuid = new Map<string, string>()
|
const overrideKeyToGuid = new Map<string, string>()
|
||||||
|
const assetRefToGuid = new Map<string, string>()
|
||||||
for (const [id, nc] of changeMap) {
|
for (const [id, nc] of changeMap) {
|
||||||
if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id)
|
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<string, ComponentPropValue>()
|
const propDefaults = new Map<string, ComponentPropValue>()
|
||||||
|
|
@ -185,6 +200,7 @@ function buildOverrideContext(
|
||||||
guidToNodeId,
|
guidToNodeId,
|
||||||
blobs,
|
blobs,
|
||||||
overrideKeyToGuid,
|
overrideKeyToGuid,
|
||||||
|
assetRefToGuid,
|
||||||
nodeIdToGuid,
|
nodeIdToGuid,
|
||||||
propDefaults,
|
propDefaults,
|
||||||
propNames,
|
propNames,
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,6 @@ function assetRefKey(assetRef: { key: string; version?: string }): string {
|
||||||
return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
|
return assetRef.version ? `${assetRef.key}@${assetRef.version}` : assetRef.key
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAssetRefMap(ctx: OverrideContext): Map<string, string> {
|
|
||||||
const refs = new Map<string, string>()
|
|
||||||
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, string>): string | undefined {
|
function resolveAliasId(alias: AliasRef, assetRefs: Map<string, string>): string | undefined {
|
||||||
if (alias.guid) return guidToString(alias.guid)
|
if (alias.guid) return guidToString(alias.guid)
|
||||||
const assetRef = alias.assetRef
|
const assetRef = alias.assetRef
|
||||||
|
|
@ -69,7 +57,7 @@ function applyVariableRadiusOverrides(
|
||||||
): void {
|
): void {
|
||||||
const entries = fields.variableConsumptionMap?.entries
|
const entries = fields.variableConsumptionMap?.entries
|
||||||
if (!entries?.length) return
|
if (!entries?.length) return
|
||||||
const assetRefs = buildAssetRefMap(ctx)
|
const assetRefs = ctx.assetRefToGuid
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const variableField = entry.variableField
|
const variableField = entry.variableField
|
||||||
if (!variableField || !VARIABLE_RADIUS_FIELDS.has(variableField)) continue
|
if (!variableField || !VARIABLE_RADIUS_FIELDS.has(variableField)) continue
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export interface OverrideContext {
|
||||||
blobs: Uint8Array[]
|
blobs: Uint8Array[]
|
||||||
|
|
||||||
overrideKeyToGuid: Map<string, string>
|
overrideKeyToGuid: Map<string, string>
|
||||||
|
assetRefToGuid: Map<string, string>
|
||||||
nodeIdToGuid: Map<string, string>
|
nodeIdToGuid: Map<string, string>
|
||||||
propDefaults: Map<string, ComponentPropValue>
|
propDefaults: Map<string, ComponentPropValue>
|
||||||
propNames: Map<string, string>
|
propNames: Map<string, string>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ export function hexToBytes(hex: string): Uint8Array {
|
||||||
return bytes
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEX_BYTES = Array.from({ length: 256 }, (_, byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
|
||||||
export function bytesToHex(bytes: Uint8Array): string {
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
let hex = ''
|
if (typeof bytes.toHex === 'function') return bytes.toHex()
|
||||||
for (const byte of bytes) {
|
|
||||||
hex += byte.toString(16).padStart(2, '0')
|
const chunks = Array.from({ length: bytes.length }, () => '')
|
||||||
}
|
for (let index = 0; index < bytes.length; index++) chunks[index] = HEX_BYTES[bytes[index]]
|
||||||
return hex
|
return chunks.join('')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ interface SceneNodeToKiwiContext {
|
||||||
/** Maps "key@version" or "key" (from variable.key/version) → variable GUID.
|
/** Maps "key@version" or "key" (from variable.key/version) → variable GUID.
|
||||||
* Used to convert colorVar.assetRef references in raw paints to guid references. */
|
* Used to convert colorVar.assetRef references in raw paints to guid references. */
|
||||||
assetRefToVarGuid?: Map<string, GUID>
|
assetRefToVarGuid?: Map<string, GUID>
|
||||||
|
componentPropertyDefinitionsById: ReadonlyMap<string, ComponentPropertyDefinition>
|
||||||
fractionalPosition: (index: number) => string
|
fractionalPosition: (index: number) => string
|
||||||
mapToFigmaType: (type: SceneNode['type']) => string
|
mapToFigmaType: (type: SceneNode['type']) => string
|
||||||
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
|
fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint
|
||||||
|
|
@ -530,12 +531,16 @@ function componentPropertyNodeField(field: ComponentPropertyReferenceField): str
|
||||||
return 'VISIBLE'
|
return 'VISIBLE'
|
||||||
}
|
}
|
||||||
|
|
||||||
function findComponentPropertyDefinition(graph: SceneGraph, id: string) {
|
export function buildComponentPropIndex(
|
||||||
|
graph: SceneGraph
|
||||||
|
): ReadonlyMap<string, ComponentPropertyDefinition> {
|
||||||
|
const definitions = new Map<string, ComponentPropertyDefinition>()
|
||||||
for (const candidate of graph.getAllNodes()) {
|
for (const candidate of graph.getAllNodes()) {
|
||||||
const definition = candidate.componentPropertyDefinitions.find((item) => item.id === id)
|
for (const definition of candidate.componentPropertyDefinitions) {
|
||||||
if (definition) return definition
|
if (!definitions.has(definition.id)) definitions.set(definition.id, definition)
|
||||||
}
|
}
|
||||||
return undefined
|
}
|
||||||
|
return definitions
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldSerializeRawBackedField(
|
function shouldSerializeRawBackedField(
|
||||||
|
|
@ -598,7 +603,7 @@ function applyComponentMetadata(
|
||||||
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
||||||
.map(([propertyId, value]) => {
|
.map(([propertyId, value]) => {
|
||||||
const defID = parseGuidOrNull(propertyId)
|
const defID = parseGuidOrNull(propertyId)
|
||||||
const definition = findComponentPropertyDefinition(context.graph, propertyId)
|
const definition = context.componentPropertyDefinitionsById.get(propertyId)
|
||||||
return defID && definition
|
return defID && definition
|
||||||
? {
|
? {
|
||||||
defID,
|
defID,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type { Color, GUID, JsonObject, Matrix } from '@open-pencil/scene-graph/p
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildAssetRefToVarGuidMap,
|
buildAssetRefToVarGuidMap,
|
||||||
|
buildComponentPropIndex,
|
||||||
sceneNodeToKiwiWithContext,
|
sceneNodeToKiwiWithContext,
|
||||||
type KiwiNodeChange
|
type KiwiNodeChange
|
||||||
} from './export-node'
|
} from './export-node'
|
||||||
|
|
@ -470,7 +471,8 @@ export function sceneNodeToKiwi(
|
||||||
glyphBlobMap = new Map<string, number>(),
|
glyphBlobMap = new Map<string, number>(),
|
||||||
blobIndexByHex?: Map<string, number>,
|
blobIndexByHex?: Map<string, number>,
|
||||||
assignedGuidValues?: Set<string>,
|
assignedGuidValues?: Set<string>,
|
||||||
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME
|
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME,
|
||||||
|
componentPropertyDefinitionsById = buildComponentPropIndex(graph)
|
||||||
): KiwiNodeChange[] {
|
): KiwiNodeChange[] {
|
||||||
// Build assetRef to guid mapping for converting colorVar references in raw paints
|
// Build assetRef to guid mapping for converting colorVar references in raw paints
|
||||||
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
|
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
|
||||||
|
|
@ -484,6 +486,7 @@ export function sceneNodeToKiwi(
|
||||||
glyphBlobMap,
|
glyphBlobMap,
|
||||||
varIdToGuid,
|
varIdToGuid,
|
||||||
assetRefToVarGuid,
|
assetRefToVarGuid,
|
||||||
|
componentPropertyDefinitionsById,
|
||||||
fractionalPosition,
|
fractionalPosition,
|
||||||
mapToFigmaType,
|
mapToFigmaType,
|
||||||
fillToKiwiPaint,
|
fillToKiwiPaint,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test'
|
||||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildComponentPropIndex,
|
||||||
fractionalPosition,
|
fractionalPosition,
|
||||||
mapToFigmaType,
|
mapToFigmaType,
|
||||||
sceneNodeToKiwi,
|
sceneNodeToKiwi,
|
||||||
|
|
@ -15,6 +16,41 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
|
||||||
expect([0, 93, 94, 188].map(fractionalPosition)).toEqual(['!', '~', '~!', '~~!'])
|
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<typeof buildComponentPropIndex>) =>
|
||||||
|
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', () => {
|
test('injects runtime glyph outlines into derived text data', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const text = graph.createNode('TEXT', graph.getPages()[0].id, {
|
const text = graph.createNode('TEXT', graph.getPages()[0].id, {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,31 @@ describe('@open-pencil/fig instance interpretation', () => {
|
||||||
expect(graph.getNode(populated?.childIds[0] ?? '')?.text).toBe('Label')
|
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', () => {
|
test('preserves protected text while synchronizing other fields', () => {
|
||||||
const graph = new SceneGraph()
|
const graph = new SceneGraph()
|
||||||
const pageId = graph.getPages()[0].id
|
const pageId = graph.getPages()[0].id
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ heavy('parse heavy .fig files', () => {
|
||||||
let nuxtUiNodes: SceneNode[]
|
let nuxtUiNodes: SceneNode[]
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
material3 = await parseFixture('material3.fig')
|
material3 = await parseFixture('material3.fig', { populate: 'none' })
|
||||||
nuxtui = await parseFixture('nuxtui.fig')
|
nuxtui = await parseFixture('nuxtui.fig', { populate: 'none' })
|
||||||
material3Nodes = collectAllNodes(material3)
|
material3Nodes = collectAllNodes(material3)
|
||||||
nuxtUiNodes = collectAllNodes(nuxtui)
|
nuxtUiNodes = collectAllNodes(nuxtui)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,12 @@ describe('Figma component property import', () => {
|
||||||
(node) => node.name === 'Menu item instance'
|
(node) => node.name === 'Menu item instance'
|
||||||
)
|
)
|
||||||
expect(instance?.componentPropertyAssignments).toEqual({ '3:1': 'Profile Item' })
|
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', () => {
|
test('propagates nested instance swaps through clone chains', () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
import { parseFigFile } from '@open-pencil/core'
|
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'
|
import { collectAllNodes } from './fig-traversal'
|
||||||
|
|
||||||
|
|
@ -33,9 +33,12 @@ export function readFixtureBytes(name: string): Uint8Array {
|
||||||
return readFileSync(resolve(FIXTURES, name))
|
return readFileSync(resolve(FIXTURES, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseFixture(name: string): Promise<SceneGraph> {
|
export async function parseFixture(
|
||||||
|
name: string,
|
||||||
|
options?: ParseFigFileOptions
|
||||||
|
): Promise<SceneGraph> {
|
||||||
const bytes = readFixtureBytes(name)
|
const bytes = readFixtureBytes(name)
|
||||||
return parseFigFile(bytes.buffer as ArrayBuffer)
|
return parseFigFile(bytes.buffer as ArrayBuffer, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseGoldPreviewFixture(): Promise<{
|
export async function parseGoldPreviewFixture(): Promise<{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue