Merge pull request #548 from 0xemc/fix/fig-component-property-roundtrip
fix(fig): component properties silently dropped on save
This commit is contained in:
commit
4b8bf021f6
|
|
@ -59,6 +59,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles. (#548)
|
||||
- Reconnect desktop automation to an already-running MCP server by allowing access to its discovery file. (#546)
|
||||
- Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539)
|
||||
- Match AI chat code-block syntax colors and backgrounds to the active light or dark theme. (#537)
|
||||
|
|
|
|||
|
|
@ -163,6 +163,51 @@ function assignVariableGuids(
|
|||
}
|
||||
}
|
||||
|
||||
interface ComponentPropertyGuidState {
|
||||
ids: string[]
|
||||
maxLocalId0: number
|
||||
maxLocalId1: number
|
||||
}
|
||||
|
||||
function collectComponentPropertyGuidState(graph: SceneGraph): ComponentPropertyGuidState {
|
||||
const ids = new Set<string>()
|
||||
let maxLocalId0 = 0
|
||||
let maxLocalId1 = 0
|
||||
for (const node of graph.getAllNodes()) {
|
||||
for (const definition of node.componentPropertyDefinitions) ids.add(definition.id)
|
||||
for (const reference of node.componentPropertyReferences) ids.add(reference.propertyId)
|
||||
for (const propertyId of Object.keys(node.componentPropertyAssignments)) ids.add(propertyId)
|
||||
for (const spec of node.variantPropSpecs) ids.add(spec.propDefId)
|
||||
}
|
||||
for (const propertyId of ids) {
|
||||
const match = /^(\d+):(\d+)$/.exec(propertyId)
|
||||
if (!match) continue
|
||||
const sessionID = Number.parseInt(match[1], 10)
|
||||
const localID = Number.parseInt(match[2], 10)
|
||||
if (sessionID === 0) maxLocalId0 = Math.max(maxLocalId0, localID)
|
||||
if (sessionID === 1) maxLocalId1 = Math.max(maxLocalId1, localID)
|
||||
}
|
||||
return { ids: [...ids], maxLocalId0, maxLocalId1 }
|
||||
}
|
||||
|
||||
function assignComponentPropertyGuids(
|
||||
propertyIds: readonly string[],
|
||||
localIdCounter: { value: number },
|
||||
propertyIdToGuid: Map<string, GUID>,
|
||||
assignedGuidValues: Set<string>,
|
||||
nodeSourceGuidValues: Set<string>
|
||||
): void {
|
||||
for (const propertyId of propertyIds) {
|
||||
const guid = assignVariableGuid(
|
||||
propertyId,
|
||||
localIdCounter,
|
||||
assignedGuidValues,
|
||||
nodeSourceGuidValues
|
||||
)
|
||||
propertyIdToGuid.set(propertyId, guid)
|
||||
}
|
||||
}
|
||||
|
||||
function appendVariableNodeChanges(
|
||||
graph: SceneGraph,
|
||||
nodeChanges: KiwiNodeChange[],
|
||||
|
|
@ -353,6 +398,7 @@ interface InternalResourceContext {
|
|||
blobIndexByHex: Map<string, number>
|
||||
assignedGuidValues: Set<string>
|
||||
componentPropertyDefinitionsById: ReturnType<typeof buildComponentPropIndex>
|
||||
propertyIdToGuid: Map<string, GUID>
|
||||
}
|
||||
|
||||
function appendInternalResources(context: InternalResourceContext): void {
|
||||
|
|
@ -375,7 +421,8 @@ function appendInternalResources(context: InternalResourceContext): void {
|
|||
context.blobIndexByHex,
|
||||
context.assignedGuidValues,
|
||||
context.componentPropertyDefinitionsById,
|
||||
context.modeIdToGuid
|
||||
context.modeIdToGuid,
|
||||
context.propertyIdToGuid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -439,6 +486,7 @@ export async function exportFigFile(
|
|||
assignedGuidValues.add(`${docGuid.sessionID}:${docGuid.localID}`)
|
||||
const varIdToGuid = new Map<string, GUID>()
|
||||
const modeIdToGuid = new Map<string, GUID>()
|
||||
const propertyIdToGuid = new Map<string, GUID>()
|
||||
const fontDigestMap = await buildFontDigestMap(graph)
|
||||
const glyphBlobMap = new Map<string, number>()
|
||||
const blobIndexByHex = new Map<string, number>()
|
||||
|
|
@ -463,6 +511,9 @@ export async function exportFigFile(
|
|||
}
|
||||
}
|
||||
}
|
||||
const propertyGuidState = collectComponentPropertyGuidState(graph)
|
||||
maxLocalId0 = Math.max(maxLocalId0, propertyGuidState.maxLocalId0)
|
||||
maxLocalId1 = Math.max(maxLocalId1, propertyGuidState.maxLocalId1)
|
||||
localIdCounter.value = Math.max(localIdCounter.value, maxLocalId0 + 1, maxLocalId1 + 1)
|
||||
|
||||
const { canvasEntries, internalCanvasGuid } = buildCanvasEntries(
|
||||
|
|
@ -485,6 +536,14 @@ export async function exportFigFile(
|
|||
nodeSourceGuidValues
|
||||
)
|
||||
|
||||
assignComponentPropertyGuids(
|
||||
propertyGuidState.ids,
|
||||
localIdCounter,
|
||||
propertyIdToGuid,
|
||||
assignedGuidValues,
|
||||
nodeSourceGuidValues
|
||||
)
|
||||
|
||||
for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc)
|
||||
|
||||
const orderedCanvasEntries = [
|
||||
|
|
@ -509,7 +568,8 @@ export async function exportFigFile(
|
|||
blobIndexByHex,
|
||||
assignedGuidValues,
|
||||
componentPropertyDefinitionsById,
|
||||
modeIdToGuid
|
||||
modeIdToGuid,
|
||||
propertyIdToGuid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -528,7 +588,8 @@ export async function exportFigFile(
|
|||
glyphBlobMap,
|
||||
blobIndexByHex,
|
||||
assignedGuidValues,
|
||||
componentPropertyDefinitionsById
|
||||
componentPropertyDefinitionsById,
|
||||
propertyIdToGuid
|
||||
})
|
||||
|
||||
const msg: Record<string, unknown> = {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import {
|
|||
} from '@open-pencil/fig/node-change'
|
||||
import type { NodeChange, VariableDataValuesEntry, Color, GUID } from '@open-pencil/kiwi/fig/codec'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import type { VariableType, VariableValue } from '@open-pencil/scene-graph'
|
||||
import type {
|
||||
ComponentPropertyDefinition,
|
||||
VariableType,
|
||||
VariableValue
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import { BLACK } from '#core/constants'
|
||||
import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
|
||||
|
|
@ -398,6 +402,52 @@ function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>)
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* INSTANCE_SWAP definitions/assignments store a target node's GUID (matching
|
||||
* how it was exported), not this import's freshly-assigned node ID — remap
|
||||
* them the same way remapComponentIds fixes up instance.componentId.
|
||||
*/
|
||||
function remapInstanceSwapPropertyValues(
|
||||
graph: SceneGraph,
|
||||
guidToNodeId: Map<string, string>
|
||||
): void {
|
||||
const defsById = new Map<string, ComponentPropertyDefinition>()
|
||||
for (const node of graph.getAllNodes()) {
|
||||
for (const def of node.componentPropertyDefinitions) {
|
||||
if (!defsById.has(def.id)) defsById.set(def.id, def)
|
||||
}
|
||||
}
|
||||
|
||||
graph.preserveSourceMetadataDuring(() => {
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.componentPropertyDefinitions.length > 0) {
|
||||
const defs = node.componentPropertyDefinitions.map((def) => {
|
||||
if (def.type !== 'INSTANCE_SWAP') return def
|
||||
const remappedDefault = def.defaultValue ? guidToNodeId.get(def.defaultValue) : undefined
|
||||
if (!remappedDefault) return def
|
||||
return { ...def, defaultValue: remappedDefault }
|
||||
})
|
||||
const changed = defs.some((def, i) => def !== node.componentPropertyDefinitions[i])
|
||||
if (changed) graph.updateNode(node.id, { componentPropertyDefinitions: defs })
|
||||
}
|
||||
|
||||
if (Object.keys(node.componentPropertyAssignments).length > 0) {
|
||||
let changed = false
|
||||
const assignments = { ...node.componentPropertyAssignments }
|
||||
for (const [propId, value] of Object.entries(assignments)) {
|
||||
if (defsById.get(propId)?.type !== 'INSTANCE_SWAP') continue
|
||||
const remapped = guidToNodeId.get(value)
|
||||
if (remapped) {
|
||||
assignments[propId] = remapped
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) graph.updateNode(node.id, { componentPropertyAssignments: assignments })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyVariantPropSpecs(graph: SceneGraph): void {
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type !== 'COMPONENT' || node.variantPropSpecs.length === 0 || !node.parentId) continue
|
||||
|
|
@ -513,6 +563,7 @@ export function importNodeChanges(
|
|||
importVariableEntries(changeMap, parentMap, graph, assetRefs)
|
||||
importVariableBindings(changeMap, guidToNodeId, graph)
|
||||
remapComponentIds(graph, guidToNodeId)
|
||||
remapInstanceSwapPropertyValues(graph, guidToNodeId)
|
||||
applyVariantPropSpecs(graph)
|
||||
|
||||
const firstPageId = graph.getPages()[0]?.id
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ export function sceneNodeToKiwi(
|
|||
blobIndexByHex?: Map<string, number>,
|
||||
assignedGuidValues?: Set<string>,
|
||||
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>,
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
modeIdToGuid?: Map<string, GUID>,
|
||||
propertyIdToGuid?: Map<string, GUID>
|
||||
): KiwiNodeChange[] {
|
||||
return sceneNodeToKiwiWithRuntime(
|
||||
node,
|
||||
|
|
@ -55,6 +56,7 @@ export function sceneNodeToKiwi(
|
|||
assignedGuidValues,
|
||||
coreFigExportRuntime,
|
||||
componentPropertyDefinitionsById,
|
||||
modeIdToGuid
|
||||
modeIdToGuid,
|
||||
propertyIdToGuid
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ interface SceneNodeToKiwiContext {
|
|||
modeIdToGuid?: Map<string, GUID>
|
||||
/** Variable GUIDs used only where raw effect aliases cannot retain asset refs. */
|
||||
assetRefToVarGuid?: Map<string, GUID>
|
||||
/** GUIDs minted for component property IDs (e.g. "prop:abc123") that aren't
|
||||
* already Figma-GUID-shaped, keyed by the original ID so refs/assignments/
|
||||
* variantPropSpecs pointing at the same property reuse the same GUID. */
|
||||
propertyIdToGuid: Map<string, GUID>
|
||||
componentPropertyDefinitionsById: ReadonlyMap<string, ComponentPropertyDefinition>
|
||||
fractionalPosition: (index: number) => string
|
||||
mapToFigmaType: (type: SceneNode['type']) => string
|
||||
|
|
@ -135,11 +139,18 @@ function componentPropertyTypeForKiwi(type: string) {
|
|||
return type
|
||||
}
|
||||
|
||||
function componentPropertyValue(type: string, value: string, graph: SceneGraph) {
|
||||
function componentPropertyValue(
|
||||
type: string,
|
||||
value: string,
|
||||
context: SceneNodeToKiwiContext,
|
||||
localIdCounter: { value: number }
|
||||
) {
|
||||
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
|
||||
if (type === 'INSTANCE_SWAP') {
|
||||
const target = graph.getNode(value)
|
||||
const guid = parseGuidOrNull(target?.source.id ?? value)
|
||||
const target = context.graph.getNode(value)
|
||||
const guid = target
|
||||
? getOrCreateNodeGuid(context, target.id, localIdCounter)
|
||||
: parseGuidOrNull(value)
|
||||
return guid ? { guidValue: guid } : { textValue: { characters: value } }
|
||||
}
|
||||
return { textValue: { characters: value } }
|
||||
|
|
@ -351,6 +362,27 @@ function getOrCreateNodeGuid(
|
|||
return guid
|
||||
}
|
||||
|
||||
/**
|
||||
* Component property IDs ("prop:abc123") never match the Figma GUID shape,
|
||||
* so parseGuidOrNull always rejects them — mint a stable synthetic GUID from
|
||||
* the shared node-id counter instead, memoized so every def/ref/assignment/
|
||||
* variantPropSpec pointing at the same property ID round-trips consistently.
|
||||
*/
|
||||
function getOrCreatePropertyGuid(
|
||||
context: SceneNodeToKiwiContext,
|
||||
propertyId: string,
|
||||
localIdCounter: { value: number }
|
||||
): GUID {
|
||||
const existing = context.propertyIdToGuid.get(propertyId)
|
||||
if (existing) return existing
|
||||
const parsed = parseGuidOrNull(propertyId)
|
||||
if (parsed) return parsed
|
||||
const guid = { sessionID: 1, localID: localIdCounter.value++ }
|
||||
context.propertyIdToGuid.set(propertyId, guid)
|
||||
context.assignedGuidValues?.add(`${guid.sessionID}:${guid.localID}`)
|
||||
return guid
|
||||
}
|
||||
|
||||
function isDescendantOf(context: SceneNodeToKiwiContext, nodeId: string, ancestorId: string) {
|
||||
let current = context.graph.getNode(nodeId)
|
||||
while (current?.parentId) {
|
||||
|
|
@ -596,7 +628,10 @@ function applyInstancePayload(
|
|||
}
|
||||
nc.symbolData = symbolData as KiwiNodeChange['symbolData']
|
||||
}
|
||||
if (node.source.fig.componentPropAssignments.length > 0) {
|
||||
if (
|
||||
node.source.fig.componentPropAssignments.length > 0 &&
|
||||
!node.source.editedFields.includes('componentPropertyAssignments')
|
||||
) {
|
||||
nc.componentPropAssignments = materializeFigmaPayload(
|
||||
node.source.fig.componentPropAssignments,
|
||||
context.blobs,
|
||||
|
|
@ -623,10 +658,17 @@ function applyInstancePayload(
|
|||
}
|
||||
}
|
||||
|
||||
function componentPropertyPreferredValues(definition: ComponentPropertyDefinition) {
|
||||
function componentPropertyPreferredValues(
|
||||
definition: ComponentPropertyDefinition,
|
||||
context: SceneNodeToKiwiContext
|
||||
) {
|
||||
if (definition.type === 'INSTANCE_SWAP' && definition.preferredValues?.length) {
|
||||
return {
|
||||
instanceSwapValues: definition.preferredValues.map((key) => ({ type: 'COMPONENT', key }))
|
||||
instanceSwapValues: definition.preferredValues.map((value) => {
|
||||
const target = context.graph.getNode(value)
|
||||
const key = target?.componentKey || target?.sourceLibraryKey || value
|
||||
return { type: 'COMPONENT', key }
|
||||
})
|
||||
}
|
||||
}
|
||||
if (definition.type === 'VARIANT' && definition.variantOptions?.length) {
|
||||
|
|
@ -665,7 +707,8 @@ function shouldSerializeRawBackedField(
|
|||
function applyComponentMetadata(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
nc: KiwiNodeChange,
|
||||
localIdCounter: { value: number }
|
||||
): void {
|
||||
if (node.componentKey) nc.componentKey = node.componentKey
|
||||
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
|
||||
|
|
@ -681,45 +724,33 @@ function applyComponentMetadata(
|
|||
}
|
||||
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription
|
||||
if (node.symbolLinks.length > 0) nc.symbolLinks = structuredClone(node.symbolLinks)
|
||||
const componentPropDefs = node.componentPropertyDefinitions
|
||||
.map((def) => {
|
||||
const id = parseGuidOrNull(def.id)
|
||||
return id
|
||||
? {
|
||||
id,
|
||||
const componentPropDefs = node.componentPropertyDefinitions.map((def) => ({
|
||||
id: getOrCreatePropertyGuid(context, def.id, localIdCounter),
|
||||
name: def.name,
|
||||
type: componentPropertyTypeForKiwi(def.type),
|
||||
initialValue: componentPropertyValue(def.type, def.defaultValue, context.graph),
|
||||
preferredValues: componentPropertyPreferredValues(def)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((def): def is NonNullable<typeof def> => def !== null)
|
||||
initialValue: componentPropertyValue(def.type, def.defaultValue, context, localIdCounter),
|
||||
preferredValues: componentPropertyPreferredValues(def, context)
|
||||
}))
|
||||
if (shouldSerializeRawBackedField(node, 'componentPropDefs', componentPropDefs.length > 0)) {
|
||||
nc.componentPropDefs = componentPropDefs
|
||||
}
|
||||
|
||||
const componentPropRefs = node.componentPropertyReferences
|
||||
.map((ref) => {
|
||||
const defID = parseGuidOrNull(ref.propertyId)
|
||||
if (!defID) return null
|
||||
return { defID, componentPropNodeField: componentPropertyNodeField(ref.field) }
|
||||
})
|
||||
.filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
||||
const componentPropRefs = node.componentPropertyReferences.map((ref) => ({
|
||||
defID: getOrCreatePropertyGuid(context, ref.propertyId, localIdCounter),
|
||||
componentPropNodeField: componentPropertyNodeField(ref.field)
|
||||
}))
|
||||
if (shouldSerializeRawBackedField(node, 'componentPropRefs', componentPropRefs.length > 0)) {
|
||||
nc.componentPropRefs = componentPropRefs
|
||||
}
|
||||
|
||||
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
||||
.map(([propertyId, value]) => {
|
||||
const defID = parseGuidOrNull(propertyId)
|
||||
const definition = context.componentPropertyDefinitionsById.get(propertyId)
|
||||
return defID && definition
|
||||
? {
|
||||
defID,
|
||||
value: componentPropertyValue(definition.type, value, context.graph)
|
||||
if (!definition) return null
|
||||
return {
|
||||
defID: getOrCreatePropertyGuid(context, propertyId, localIdCounter),
|
||||
value: componentPropertyValue(definition.type, value, context, localIdCounter)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
|
||||
if (
|
||||
|
|
@ -733,12 +764,10 @@ function applyComponentMetadata(
|
|||
nc.componentPropAssignments = componentPropAssignments
|
||||
}
|
||||
|
||||
const variantPropSpecs = node.variantPropSpecs
|
||||
.map((spec) => {
|
||||
const propDefId = parseGuidOrNull(spec.propDefId)
|
||||
return propDefId ? { propDefId, value: spec.value } : null
|
||||
})
|
||||
.filter((spec): spec is NonNullable<typeof spec> => spec !== null)
|
||||
const variantPropSpecs = node.variantPropSpecs.map((spec) => ({
|
||||
propDefId: getOrCreatePropertyGuid(context, spec.propDefId, localIdCounter),
|
||||
value: spec.value
|
||||
}))
|
||||
if (shouldSerializeRawBackedField(node, 'variantPropSpecs', variantPropSpecs.length > 0)) {
|
||||
nc.variantPropSpecs = variantPropSpecs
|
||||
}
|
||||
|
|
@ -941,7 +970,7 @@ export function sceneNodeToKiwiWithContext(
|
|||
if (node.locked) nc.locked = true
|
||||
|
||||
applyNodeVisualProps(context, node, nc)
|
||||
applyComponentMetadata(context, node, nc)
|
||||
applyComponentMetadata(context, node, nc, localIdCounter)
|
||||
applyInstancePayload(context, node, nc, localIdCounter)
|
||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
||||
|
|
|
|||
|
|
@ -493,7 +493,8 @@ export function sceneNodeToKiwi(
|
|||
assignedGuidValues?: Set<string>,
|
||||
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME,
|
||||
componentPropertyDefinitionsById = buildComponentPropIndex(graph),
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
modeIdToGuid?: Map<string, GUID>,
|
||||
propertyIdToGuid = new Map<string, GUID>()
|
||||
): KiwiNodeChange[] {
|
||||
// Raw paints retain library asset refs; effects use this map because their
|
||||
// Kiwi schema accepts only GUID-backed aliases.
|
||||
|
|
@ -510,6 +511,7 @@ export function sceneNodeToKiwi(
|
|||
modeIdToGuid,
|
||||
assetRefToVarGuid,
|
||||
componentPropertyDefinitionsById,
|
||||
propertyIdToGuid,
|
||||
fractionalPosition,
|
||||
mapToFigmaType,
|
||||
fillToKiwiPaint,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import {
|
||||
buildComponentPropIndex,
|
||||
|
|
@ -135,4 +136,230 @@ describe('@open-pencil/fig SceneGraph export policy', () => {
|
|||
expect(change.derivedTextData?.glyphs).toHaveLength(1)
|
||||
expect(blobs).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('mints a synthetic GUID for app-created (non-Figma-shaped) component property IDs', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const componentSet = graph.createNode('COMPONENT_SET', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{
|
||||
id: 'prop:abc12345',
|
||||
name: 'Style',
|
||||
type: 'VARIANT',
|
||||
defaultValue: 'Primary',
|
||||
variantOptions: ['Primary', 'Secondary']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [change] = sceneNodeToKiwi(
|
||||
componentSet,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
{ value: 2 },
|
||||
graph,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(change.componentPropDefs).toHaveLength(1)
|
||||
expect(change.componentPropDefs?.[0].id).toEqual(
|
||||
expect.objectContaining({ sessionID: expect.any(Number), localID: expect.any(Number) })
|
||||
)
|
||||
expect(change.componentPropDefs?.[0].name).toBe('Style')
|
||||
})
|
||||
|
||||
test('reuses the same synthetic GUID for a def and the ref that points at it', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const component = graph.createNode('COMPONENT', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{ id: 'prop:icon1234', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: '' }
|
||||
]
|
||||
})
|
||||
const slot = graph.createNode('INSTANCE', component.id, {
|
||||
componentPropertyReferences: [{ propertyId: 'prop:icon1234', field: 'INSTANCE_SWAP' }]
|
||||
})
|
||||
|
||||
const nodeIdToGuid = new Map<string, GUID>()
|
||||
const propertyIdToGuid = new Map<string, GUID>()
|
||||
const localIdCounter = { value: 2 }
|
||||
const [componentChange] = sceneNodeToKiwi(
|
||||
component,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
localIdCounter,
|
||||
graph,
|
||||
[],
|
||||
nodeIdToGuid,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
propertyIdToGuid
|
||||
)
|
||||
const slotChange = sceneNodeToKiwi(
|
||||
slot,
|
||||
componentChange.guid,
|
||||
0,
|
||||
localIdCounter,
|
||||
graph,
|
||||
[],
|
||||
nodeIdToGuid,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
propertyIdToGuid
|
||||
)[0]
|
||||
|
||||
expect(componentChange.componentPropDefs?.[0].id).toEqual(
|
||||
slotChange.componentPropRefs?.[0].defID
|
||||
)
|
||||
})
|
||||
|
||||
test('points an INSTANCE_SWAP default value at the same GUID the target component is exported with', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const icon = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Icon/Tune',
|
||||
componentKey: 'icon-tune-key'
|
||||
})
|
||||
const button = graph.createNode('COMPONENT', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{ id: 'prop:iconswap1', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: icon.id }
|
||||
]
|
||||
})
|
||||
|
||||
const nodeIdToGuid = new Map<string, GUID>()
|
||||
const propertyIdToGuid = new Map<string, GUID>()
|
||||
const localIdCounter = { value: 2 }
|
||||
const [iconChange] = sceneNodeToKiwi(
|
||||
icon,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
localIdCounter,
|
||||
graph,
|
||||
[],
|
||||
nodeIdToGuid,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
propertyIdToGuid
|
||||
)
|
||||
const [buttonChange] = sceneNodeToKiwi(
|
||||
button,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
1,
|
||||
localIdCounter,
|
||||
graph,
|
||||
[],
|
||||
nodeIdToGuid,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
propertyIdToGuid
|
||||
)
|
||||
|
||||
expect(buttonChange.componentPropDefs?.[0].initialValue).toEqual({ guidValue: iconChange.guid })
|
||||
expect(buttonChange.componentPropDefs?.[0].preferredValues).toBeUndefined()
|
||||
})
|
||||
|
||||
test('exports INSTANCE_SWAP preferred values as component keys', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const icon = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Icon/Tune',
|
||||
componentKey: 'icon-tune-key'
|
||||
})
|
||||
const button = graph.createNode('COMPONENT', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{
|
||||
id: 'prop:iconswap2',
|
||||
name: 'Icon',
|
||||
type: 'INSTANCE_SWAP',
|
||||
defaultValue: icon.id,
|
||||
preferredValues: [icon.id, 'external-library-key']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const [buttonChange] = sceneNodeToKiwi(
|
||||
button,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
{ value: 2 },
|
||||
graph,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(buttonChange.componentPropDefs?.[0].preferredValues?.instanceSwapValues).toEqual([
|
||||
{ type: 'COMPONENT', key: 'icon-tune-key' },
|
||||
{ type: 'COMPONENT', key: 'external-library-key' }
|
||||
])
|
||||
})
|
||||
|
||||
test('preserves unresolved GUID-shaped INSTANCE_SWAP values as GUIDs', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const component = graph.createNode('COMPONENT', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{ id: 'prop:iconswap3', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: '70:1' }
|
||||
]
|
||||
})
|
||||
|
||||
const [change] = sceneNodeToKiwi(
|
||||
component,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
{ value: 2 },
|
||||
graph,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(change.componentPropDefs?.[0].initialValue).toEqual({
|
||||
guidValue: { sessionID: 70, localID: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
test('shares synthetic property GUIDs across recursive serialization without a supplied map', () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const component = graph.createNode('COMPONENT', page.id, {
|
||||
componentPropertyDefinitions: [
|
||||
{ id: 'prop:recursive', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
|
||||
]
|
||||
})
|
||||
graph.createNode('TEXT', component.id, {
|
||||
componentPropertyReferences: [{ propertyId: 'prop:recursive', field: 'TEXT' }]
|
||||
})
|
||||
|
||||
const changes = sceneNodeToKiwi(
|
||||
component,
|
||||
{ sessionID: 1, localID: 1 },
|
||||
0,
|
||||
{ value: 2 },
|
||||
graph,
|
||||
[]
|
||||
)
|
||||
|
||||
expect(changes[0].componentPropDefs?.[0].id).toEqual(changes[1].componentPropRefs?.[0].defID)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,4 +171,47 @@ describe('Figma component property roundtrip', () => {
|
|||
'30:2': 'false'
|
||||
})
|
||||
})
|
||||
|
||||
test('exports edited imported assignments instead of stale raw values', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const component = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Card',
|
||||
componentPropertyDefinitions: [
|
||||
{ id: '30:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
|
||||
]
|
||||
})
|
||||
component.source.id = '10:1'
|
||||
const instance = graph.createInstance(component.id, page.id, {
|
||||
name: 'Card instance',
|
||||
componentPropertyAssignments: { '30:1': 'Original' }
|
||||
})
|
||||
if (!instance) throw new Error('Expected instance')
|
||||
instance.source.id = '20:1'
|
||||
|
||||
const imported = importNodeChanges(
|
||||
parseFigBuffer((await exportFigFile(graph)).buffer as ArrayBuffer).nodeChanges,
|
||||
[],
|
||||
undefined,
|
||||
{ populate: 'all' }
|
||||
)
|
||||
const importedInstance = [...imported.getAllNodes()].find(
|
||||
(node) => node.name === 'Card instance'
|
||||
)
|
||||
if (!importedInstance) throw new Error('Expected imported instance')
|
||||
imported.updateNode(importedInstance.id, {
|
||||
componentPropertyAssignments: { '30:1': 'Edited' }
|
||||
})
|
||||
|
||||
const reloaded = importNodeChanges(
|
||||
parseFigBuffer((await exportFigFile(imported)).buffer as ArrayBuffer).nodeChanges,
|
||||
[],
|
||||
undefined,
|
||||
{ populate: 'all' }
|
||||
)
|
||||
const reloadedInstance = [...reloaded.getAllNodes()].find(
|
||||
(node) => node.name === 'Card instance'
|
||||
)
|
||||
expect(reloadedInstance?.componentPropertyAssignments).toEqual({ '30:1': 'Edited' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -338,14 +338,20 @@ describe('Figma component property import', () => {
|
|||
id: '3:2',
|
||||
name: 'icon',
|
||||
type: 'INSTANCE_SWAP',
|
||||
defaultValue: '4:1',
|
||||
defaultValue: expect.any(String),
|
||||
preferredValues: ['icon/user-key']
|
||||
}
|
||||
])
|
||||
const defaultTarget = component?.componentPropertyDefinitions[0]?.defaultValue
|
||||
expect(defaultTarget ? graph.getNode(defaultTarget)?.name : undefined).toBe('icon/mail')
|
||||
const sourceInstance = Array.from(graph.getAllNodes()).find(
|
||||
(node) => node.name === 'Menu item source'
|
||||
)
|
||||
expect(sourceInstance?.componentPropertyAssignments).toEqual({ '3:2': '4:3' })
|
||||
expect(sourceInstance?.componentPropertyAssignments).toEqual({
|
||||
'3:2': expect.any(String)
|
||||
})
|
||||
const assignedTarget = sourceInstance?.componentPropertyAssignments['3:2']
|
||||
expect(assignedTarget ? graph.getNode(assignedTarget)?.name : undefined).toBe('icon/user')
|
||||
const clone = Array.from(graph.getAllNodes()).find((node) => node.name === 'Menu item clone')
|
||||
const icon = clone?.childIds
|
||||
.map((id) => graph.getNode(id))
|
||||
|
|
|
|||
68
tests/engine/io/fig/instance-swap-roundtrip.test.ts
Normal file
68
tests/engine/io/fig/instance-swap-roundtrip.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { exportFigFile, parseFigFile } from '@open-pencil/core/io'
|
||||
import { initCodec } from '@open-pencil/core/kiwi'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
describe('INSTANCE_SWAP component property round trip', () => {
|
||||
test('defaultValue, preferredValues, and componentId survive a save/reload cycle', async () => {
|
||||
await initCodec()
|
||||
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const iconA = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Icon/A',
|
||||
componentKey: 'icon-a-key'
|
||||
})
|
||||
const iconB = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Icon/B',
|
||||
componentKey: 'icon-b-key'
|
||||
})
|
||||
const button = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Button',
|
||||
componentPropertyDefinitions: [
|
||||
{
|
||||
id: 'prop:iconswap01',
|
||||
name: 'Icon',
|
||||
type: 'INSTANCE_SWAP',
|
||||
defaultValue: iconA.id,
|
||||
preferredValues: [iconA.id, iconB.id]
|
||||
}
|
||||
]
|
||||
})
|
||||
const slot = graph.createInstance(iconA.id, button.id)
|
||||
if (!slot) throw new Error('failed to create slot instance')
|
||||
graph.updateNode(slot.id, {
|
||||
componentPropertyReferences: [{ propertyId: 'prop:iconswap01', field: 'INSTANCE_SWAP' }],
|
||||
componentPropertyAssignments: { 'prop:iconswap01': iconB.id }
|
||||
})
|
||||
|
||||
const exported = await exportFigFile(graph)
|
||||
const reloaded = await parseFigFile(exported.buffer as ArrayBuffer)
|
||||
|
||||
const reloadedButton = reloaded
|
||||
.getAllNodes()
|
||||
.find((n) => n.type === 'COMPONENT' && n.name === 'Button')
|
||||
expect(reloadedButton).toBeDefined()
|
||||
|
||||
const def = reloadedButton?.componentPropertyDefinitions[0]
|
||||
expect(def?.type).toBe('INSTANCE_SWAP')
|
||||
|
||||
const defaultTarget = def?.defaultValue ? reloaded.getNode(def.defaultValue) : undefined
|
||||
expect(defaultTarget?.name).toBe('Icon/A')
|
||||
|
||||
const preferredValues = new Set(def?.preferredValues)
|
||||
expect(preferredValues).toEqual(new Set(['icon-a-key', 'icon-b-key']))
|
||||
|
||||
const reloadedSlot = reloadedButton ? reloaded.getChildren(reloadedButton.id)[0] : undefined
|
||||
expect(reloadedSlot?.type).toBe('INSTANCE')
|
||||
const slotComponent = reloadedSlot?.componentId
|
||||
? reloaded.getNode(reloadedSlot.componentId)
|
||||
: undefined
|
||||
expect(slotComponent?.name).toBe('Icon/A')
|
||||
|
||||
const assignment = reloadedSlot?.componentPropertyAssignments[def?.id ?? '']
|
||||
const assignmentTarget = assignment ? reloaded.getNode(assignment) : undefined
|
||||
expect(assignmentTarget?.name).toBe('Icon/B')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue