import { effectiveFigmaRawNodeFields } from '#fig/source-metadata' import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec' import { stringToGuid } from '@open-pencil/kiwi/fig/guid' import type { ComponentPropertyDefinition, SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { Color, GUID, Matrix } from '@open-pencil/scene-graph/primitives' export type KiwiNodeChange = NodeChange & Record export interface KiwiSymbolOverridePayload { guidPath?: { guids?: GUID[] } textData?: { characters?: string } fillPaints?: Paint[] [key: string]: unknown } /** How a record names a shared style: a local GUID, or a published library asset. */ export interface StyleReference { guid?: GUID assetRef?: { key: string; version?: string } } /** Published text styles by source id, built once with the rest of the context. */ export function buildStyleReferences(graph: SceneGraph): ReadonlyMap { const references = new Map() for (const node of graph.getAllNodes()) { if (node.sharedStyleType !== 'TEXT' || !node.source.id) continue const raw = effectiveFigmaRawNodeFields(node) if (typeof raw.key !== 'string') continue references.set(node.source.id, { assetRef: { key: raw.key, ...(typeof raw.version === 'string' ? { version: raw.version } : {}) } }) } return references } export interface SceneNodeToKiwiContext { styleReferences?: ReadonlyMap graph: SceneGraph blobs: Uint8Array[] blobIndexByHex?: Map nodeIdToGuid?: Map /** Reverse index of assigned GUID values ("sessionID:localID") for O(1) * collision detection. Populated alongside every nodeIdToGuid.set() call. */ assignedGuidValues?: Set fontDigestMap?: Map glyphBlobMap?: Map varIdToGuid?: Map modeIdToGuid?: Map /** Variable GUIDs used only where raw effect aliases cannot retain asset refs. */ assetRefToVarGuid?: Map /** 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 componentPropertyDefinitionsById: ReadonlyMap /** * Receives the content frames of instance slots. Figma stores them on the internal canvas, * so the caller re-parents the `isSlotContent` roots there after serializing its nodes. */ slotContentRecords?: KiwiNodeChange[] fractionalPosition: (index: number) => string mapToFigmaType: (type: SceneNode['type']) => string fillToKiwiPaint: (fill: SceneNode['fills'][number]) => Paint safeColor: (color: Color) => Color computeExportTransform: (node: SceneNode) => Matrix serializeCornerRadii: (node: SceneNode, nc: KiwiNodeChange) => void serializeTextProps: ( node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph, fontDigestMap: Map | undefined, blobs: Uint8Array[], glyphBlobMap: Map | undefined ) => void serializeLayoutProps: (node: SceneNode, nc: KiwiNodeChange) => void serializeGeometry: (node: SceneNode, nc: KiwiNodeChange, blobs: Uint8Array[]) => void serializeVariableBindings: ( node: SceneNode, nc: KiwiNodeChange, graph: SceneGraph, varIdToGuid?: Map ) => void sceneNodeToKiwi: ( node: SceneNode, parentGuid: GUID, childIndex: number, localIdCounter: { value: number }, context: SceneNodeToKiwiContext ) => KiwiNodeChange[] } export function parseGuidOrNull(value: string) { return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null } export function resolveInstanceComponentId( context: SceneNodeToKiwiContext, componentId: string ): string { const seen = new Set() let currentId = componentId while (!seen.has(currentId)) { seen.add(currentId) const node = context.graph.getNode(currentId) if (node?.type !== 'INSTANCE' || !node.componentId) return currentId currentId = node.componentId } return componentId } export function getOrCreateNodeGuid( context: SceneNodeToKiwiContext, nodeId: string, localIdCounter: { value: number } ): GUID | undefined { const node = context.graph.getNode(nodeId) if (!node) return undefined const existing = context.nodeIdToGuid?.get(nodeId) if (existing) return existing const importedGuid = node.source.id ? parseGuidOrNull(node.source.id) : null // When source.id maps to a GUID value that is already assigned to a // different node (e.g. two nodes from different canvases with the same // source.id "1:94"), fall back to the counter to avoid collisions. if (importedGuid && context.assignedGuidValues) { const key = `${importedGuid.sessionID}:${importedGuid.localID}` if (context.assignedGuidValues.has(key)) { const guid: GUID = { sessionID: 1, localID: localIdCounter.value++ } context.nodeIdToGuid?.set(nodeId, guid) context.assignedGuidValues.add(`${guid.sessionID}:${guid.localID}`) return guid } } const guid = importedGuid ?? { sessionID: 1, localID: localIdCounter.value++ } context.nodeIdToGuid?.set(nodeId, guid) context.assignedGuidValues?.add(`${guid.sessionID}:${guid.localID}`) 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. */ export 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 } export function isDescendantOf( context: SceneNodeToKiwiContext, nodeId: string, ancestorId: string ) { let current = context.graph.getNode(nodeId) while (current?.parentId) { if (current.parentId === ancestorId) return true current = context.graph.getNode(current.parentId) } return false } export function applyColorVariableBinding( context: SceneNodeToKiwiContext, node: SceneNode, paint: Paint, field: string ): Paint { const variableId = node.boundVariables[field] // An imported paint carries the binding it arrived with. Unbinding the field has to clear // it, or the export hands Figma back a variable the document no longer references. if (!variableId) { if (!paint.colorVar) return paint const { colorVar: _cleared, ...unbound } = paint return unbound } return { ...paint, colorVar: { dataType: 'ALIAS', resolvedDataType: 'COLOR', value: { alias: { guid: context.varIdToGuid?.get(variableId) ?? stringToGuid(variableId) } } } } } /** A node's fills with their colour variable aliases, for the record and for override claims alike. */ export function createFillPaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] { return node.fills.map((fill, index) => applyColorVariableBinding(context, node, context.fillToKiwiPaint(fill), `fills/${index}/color`) ) } /** A stroke is a paint, so it writes the same way a fill does, gradients and images included. */ export function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): Paint[] { return node.strokes.map((stroke, index) => applyColorVariableBinding( context, node, context.fillToKiwiPaint(stroke), `strokes/${index}/color` ) ) } /** Instances address descendants by override key when one exists, else by node GUID. */ export function instanceGuidResolver(context: SceneNodeToKiwiContext, counter: { value: number }) { return (id: string): GUID | undefined => { const source = context.graph.getNode(id) return ( (source?.overrideKey ? parseGuidOrNull(source.overrideKey) : null) ?? getOrCreateNodeGuid(context, id, counter) ) } }