fix(fig): preserve component prop reference maps

- Export component sets with Figma-compatible frame state-group metadata
- Emit internal component definitions before page instances
- Preserve prop-ref variable maps while continuing to strip unsafe alias maps
This commit is contained in:
Danila Poyarkov 2026-05-21 00:09:12 +03:00
parent 2897e0b20f
commit b784882e88
5 changed files with 107 additions and 47 deletions

View file

@ -29,6 +29,13 @@ const THUMBNAIL_1X1 = Uint8Array.from(
)
type KiwiNodeChange = NodeChange & Record<string, unknown>
type FigExportPage = ReturnType<SceneGraph['getPages']>[number]
interface CanvasExportEntry {
page: FigExportPage
canvasGuid: GUID
canvasNc: KiwiNodeChange
}
function variableValueToKiwi(
value: VariableValue,
@ -170,6 +177,50 @@ function appendVariablesForCollection(
}
}
function buildCanvasEntries(
graph: SceneGraph,
pages: FigExportPage[],
docGuid: GUID,
localIdCounter: { value: number },
nodeIdToGuid: Map<string, GUID>
): { canvasEntries: CanvasExportEntry[]; internalCanvasGuid: GUID | null } {
const canvasEntries: CanvasExportEntry[] = []
let internalCanvasGuid: GUID | null = null
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasGuid = page.figmaGuid
? stringToGuid(page.figmaGuid)
: { sessionID: 0, localID: localIdCounter.value++ }
nodeIdToGuid.set(page.id, canvasGuid)
if (page.internalOnly) internalCanvasGuid = canvasGuid
const canvasNc = makeCanvasNodeChange(canvasGuid, docGuid, fractionalPosition(p), page.name, {
backgroundOpacity: 1,
backgroundColor: { ...CANVAS_BG_COLOR },
backgroundEnabled: true
})
if (page.internalOnly) canvasNc.internalOnly = true
canvasEntries.push({ page, canvasGuid, canvasNc })
}
if (graph.variableCollections.size > 0 && internalCanvasGuid === null) {
internalCanvasGuid = { sessionID: 0, localID: localIdCounter.value++ }
canvasEntries.push({
page: { id: '', name: 'Internal Only Canvas', internalOnly: true } as FigExportPage,
canvasGuid: internalCanvasGuid,
canvasNc: makeCanvasNodeChange(
internalCanvasGuid,
docGuid,
fractionalPosition(canvasEntries.length),
'Internal Only Canvas',
{ internalOnly: true }
)
})
}
return { canvasEntries, internalCanvasGuid }
}
export async function exportFigFile(
graph: SceneGraph,
ck?: CanvasKit,
@ -193,25 +244,24 @@ export async function exportFigFile(
const modeIdToGuid = new Map<string, GUID>()
const fontDigestMap = await buildFontDigestMap(graph)
const glyphBlobMap = new Map<string, number>()
let internalCanvasGuid: GUID | null = null
assignVariableGuids(graph, localIdCounter, varIdToGuid, modeIdToGuid)
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasLocalID = localIdCounter.value++
const canvasGuid = { sessionID: 0, localID: canvasLocalID }
const { canvasEntries, internalCanvasGuid } = buildCanvasEntries(
graph,
pages,
docGuid,
localIdCounter,
nodeIdToGuid
)
if (page.internalOnly) internalCanvasGuid = canvasGuid
const canvasNc = makeCanvasNodeChange(canvasGuid, docGuid, fractionalPosition(p), page.name, {
backgroundOpacity: 1,
backgroundColor: { ...CANVAS_BG_COLOR },
backgroundEnabled: true
})
if (page.internalOnly) canvasNc.internalOnly = true
nodeChanges.push(canvasNc)
for (const entry of canvasEntries) nodeChanges.push(entry.canvasNc)
const orderedCanvasEntries = [
...canvasEntries.filter((entry) => entry.page.internalOnly),
...canvasEntries.filter((entry) => !entry.page.internalOnly)
]
for (const { page, canvasGuid } of orderedCanvasEntries) {
const children = graph.getChildren(page.id).filter((child) => !child.internalOnly)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(
@ -231,21 +281,7 @@ export async function exportFigFile(
}
}
if (graph.variableCollections.size > 0) {
if (!internalCanvasGuid) {
const internalLocalID = localIdCounter.value++
internalCanvasGuid = { sessionID: 0, localID: internalLocalID }
nodeChanges.push(
makeCanvasNodeChange(
internalCanvasGuid,
docGuid,
fractionalPosition(pages.length),
'Internal Only Canvas',
{ internalOnly: true }
)
)
}
if (graph.variableCollections.size > 0 && internalCanvasGuid) {
appendVariableNodeChanges(graph, nodeChanges, internalCanvasGuid, varIdToGuid, modeIdToGuid)
}

View file

@ -2147,6 +2147,10 @@ message SymbolId {
GUID guid = 1;
}
message PropRefValue {
GUID defId = 1;
}
message VariableSetID {
GUID guid = 1;
}
@ -2158,6 +2162,7 @@ message VariableAnyValue {
VariableID alias = 4;
Color colorValue = 5;
SymbolId symbolIdValue = 8;
PropRefValue propRefValue = 13;
}
message VariableData {

View file

@ -710,6 +710,8 @@ const FIGMA_RAW_NODE_FIELD_KEYS = [
'componentPropRefs',
'variantPropSpecs',
'stateGroupPropertyValueOrders',
'isStateGroup',
'version',
'sourceLibraryKey',
'userFacingVersion',
'sortPosition',

View file

@ -102,6 +102,19 @@ function parseGuidOrNull(value: string) {
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set(['variableConsumptionMap', 'parameterConsumptionMap'])
const FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS = new Set(['colorVar', 'opacityVar'])
function isPropRefVariableMapEntry(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const entry = value as { variableData?: { dataType?: string; value?: { propRefValue?: unknown } } }
return entry.variableData?.dataType === 'PROP_REF' || !!entry.variableData?.value?.propRefValue
}
function materializeSafeVariableMap(value: unknown, blobs: Uint8Array[]): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
const entries = (value as { entries?: unknown[] }).entries?.filter(isPropRefVariableMapEntry) ?? []
if (entries.length === 0) return undefined
return { entries: entries.map((entry) => materializeFigmaPayload(entry, blobs)) }
}
function materializeFigmaPayload(
value: unknown,
blobs: Uint8Array[],
@ -122,7 +135,11 @@ function materializeFigmaPayload(
const materialized: Record<string, unknown> = {}
for (const [key, child] of Object.entries(value)) {
if (FIGMA_PAYLOAD_PAINT_VARIABLE_FIELDS.has(key)) continue
if (!options.includeVariableMaps && FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) continue
if (!options.includeVariableMaps && FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS.has(key)) {
const variableMap = materializeSafeVariableMap(child, blobs)
if (variableMap !== undefined) materialized[key] = variableMap
continue
}
materialized[key] = materializeFigmaPayload(child, blobs, options)
}
return materialized
@ -213,8 +230,8 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
if (overrideKey) nc.overrideKey = overrideKey
if (node.sharedSymbolVersion) nc.sharedSymbolVersion = node.sharedSymbolVersion
if (node.publishedVersion) nc.publishedVersion = node.publishedVersion
if (node.isPublishable) nc.isPublishable = true
if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET' || node.isSymbolPublishable) {
if (node.type === 'COMPONENT_SET' || node.isPublishable) nc.isPublishable = node.isPublishable
if (node.type === 'COMPONENT' || node.isSymbolPublishable) {
nc.isSymbolPublishable = node.isSymbolPublishable
}
if (node.symbolDescription) nc.symbolDescription = node.symbolDescription

View file

@ -53,7 +53,7 @@ export function mapToFigmaType(type: SceneNode['type']): string {
case 'COMPONENT':
return 'SYMBOL'
case 'COMPONENT_SET':
return 'SYMBOL'
return 'FRAME'
case 'INSTANCE':
return 'INSTANCE'
case 'CONNECTOR':
@ -250,21 +250,21 @@ function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint {
}
function serializeCornerRadii(node: SceneNode, nc: KiwiNodeChange): void {
if (node.cornerRadius > 0 || node.independentCorners) {
const hasCornerRadius = node.independentCorners
? node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomLeftRadius > 0 ||
node.bottomRightRadius > 0
: node.cornerRadius > 0
if (hasCornerRadius) {
nc.cornerRadius = node.cornerRadius
nc.rectangleCornerRadiiIndependent = node.independentCorners
nc.rectangleTopLeftCornerRadius = node.independentCorners
? node.topLeftRadius
: node.cornerRadius
nc.rectangleTopRightCornerRadius = node.independentCorners
? node.topRightRadius
: node.cornerRadius
nc.rectangleBottomLeftCornerRadius = node.independentCorners
? node.bottomLeftRadius
: node.cornerRadius
nc.rectangleBottomRightCornerRadius = node.independentCorners
? node.bottomRightRadius
: node.cornerRadius
if (node.independentCorners) {
nc.rectangleCornerRadiiIndependent = true
nc.rectangleTopLeftCornerRadius = node.topLeftRadius
nc.rectangleTopRightCornerRadius = node.topRightRadius
nc.rectangleBottomLeftCornerRadius = node.bottomLeftRadius
nc.rectangleBottomRightCornerRadius = node.bottomRightRadius
}
}
if (node.cornerSmoothing > 0) {
nc.cornerSmoothing = node.cornerSmoothing