Split instance-overrides.ts into focused modules

Break the 781-line single-function file into a kiwi/instance-overrides/
folder with domain files:

- types.ts — shared types and OverrideContext
- index.ts — thin orchestrator (99 lines)
- populate.ts — populate empty instances from components
- resolve.ts — componentId root, override target, repopulateInstance
- symbol-overrides.ts — apply kiwi symbolOverrides
- sync.ts — transitive sync through clone chains
- props.ts — component property assignments
- dsd.ts — derived symbol data (pre-computed sizes)

The shared closure state (6 maps) is now bundled in OverrideContext,
passed explicitly to all functions. No behavior change.

Fixes: complexity lint error (was 25, max 20), prefer-optional-chain,
nested ternary parentheses, import() type annotation.
This commit is contained in:
Danila Poyarkov 2026-03-14 10:33:05 +03:00
parent 2cecdff876
commit 204b225c64
9 changed files with 952 additions and 781 deletions

View file

@ -1,781 +0,0 @@
/* eslint-disable max-lines -- override resolution is tightly coupled, splitting would hurt readability */
import type { SceneGraph, SceneNode, GeometryPath } from '../scene-graph'
import { guidToString, resolveGeometryPaths } from './kiwi-convert'
import { convertOverrideToProps } from './kiwi-convert-overrides'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns, copyGeometryPaths } from '../copy'
import type { GUID } from './codec'
import type { Matrix, Vector } from '../types'
interface SymbolOverride {
guidPath?: { guids?: GUID[] }
overriddenSymbolID?: GUID
componentPropAssignments?: ComponentPropAssignment[]
[key: string]: unknown
}
interface SymbolData {
symbolID?: GUID
symbolOverrides?: SymbolOverride[]
}
interface ComponentPropRef {
defID?: GUID
componentPropNodeField: string
}
interface ComponentPropAssignment {
defID?: GUID
value: { boolValue?: boolean; textValue?: string; guidValue?: GUID }
}
interface DerivedSymbolOverride {
guidPath?: { guids?: GUID[] }
size?: Vector
transform?: Matrix
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
}
interface ComponentPropDef {
id?: GUID
name?: string
initialValue?: ComponentPropAssignment['value']
type?: number
}
export interface InstanceNodeChange {
type?: string
guid?: GUID
overrideKey?: GUID
symbolData?: SymbolData
componentPropRefs?: ComponentPropRef[]
componentPropAssignments?: ComponentPropAssignment[]
componentPropDefs?: ComponentPropDef[]
derivedSymbolData?: DerivedSymbolOverride[]
}
/**
* Populate empty instances from their components and apply symbol overrides.
*
* Shared between .fig file import and clipboard paste. Both paths produce
* a SceneGraph with INSTANCE nodes whose componentId references have been
* remapped to graph node IDs but whose children may be missing and whose
* overrides have not yet been applied.
*
* @param graph the SceneGraph (mutated in place)
* @param changeMap figmaGuid raw kiwi node change (for overrideKey + symbolData)
* @param guidToNodeId figmaGuid graph node ID
*/
export function populateAndApplyOverrides(
graph: SceneGraph,
changeMap: Map<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>,
blobs: Uint8Array[] = []
): void {
// Populate empty INSTANCE nodes from their source components. Instances
// must be populated bottom-up: if an instance's source is itself an
// unpopulated instance, populate the source first so cloned children
// are complete.
function ensurePopulated(nodeId: string, visiting: Set<string>): void {
const node = graph.getNode(nodeId)
if (!node || node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) return
if (visiting.has(nodeId)) return
visiting.add(nodeId)
const comp = graph.getNode(node.componentId)
if (!comp) return
// If the source is an unpopulated instance, populate it first
if (comp.type === 'INSTANCE' && comp.componentId && comp.childIds.length === 0) {
ensurePopulated(comp.id, visiting)
}
// Also ensure children of the source are populated (nested instances)
for (const childId of comp.childIds) {
const child = graph.getNode(childId)
if (child?.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) {
ensurePopulated(childId, visiting)
}
}
if (comp.childIds.length > 0 && node.childIds.length === 0) {
graph.populateInstanceChildren(nodeId, node.componentId)
}
}
const visiting = new Set<string>()
for (const node of graph.getAllNodes()) {
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
ensurePopulated(node.id, visiting)
}
}
// Second pass: cloning may have introduced new empty instances not seen
// in the first pass (nested clones). Repeat until stable.
let changed = true
while (changed) {
changed = false
for (const node of graph.getAllNodes()) {
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
const comp = graph.getNode(node.componentId)
if (comp && comp.childIds.length > 0) {
graph.populateInstanceChildren(node.id, node.componentId)
changed = true
}
}
}
}
// Build overrideKey → figmaGuid map
const overrideKeyToGuid = new Map<string, string>()
for (const [id, nc] of changeMap) {
if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id)
}
// Component property defaults: defID → initialValue from componentPropDefs.
// In symbolOverride componentPropAssignments, an empty value {} (all fields
// absent) means "reset to component default". We resolve it from this map.
const propDefaults = new Map<string, ComponentPropAssignment['value']>()
for (const [, nc] of changeMap) {
if (!nc.componentPropDefs?.length) continue
for (const def of nc.componentPropDefs) {
if (def.id && def.initialValue) {
propDefaults.set(guidToString(def.id), def.initialValue)
}
}
}
// Reverse map: graph node ID → figma GUID (used by getComponentRoot kiwi fallback)
const nodeIdToGuid = new Map<string, string>()
for (const [figmaId, nodeId] of guidToNodeId) {
nodeIdToGuid.set(nodeId, figmaId)
}
// Pre-compute componentId root for every node while all internal page nodes
// are still alive. After overrides, instance swaps delete intermediate clones,
// breaking the chain. DSD resolution uses this to match across clone levels.
const preComputedRoot = new Map<string, string>()
function getPreComputedRoot(nodeId: string, depth = 0): string {
if (preComputedRoot.has(nodeId)) return preComputedRoot.get(nodeId) ?? nodeId
if (depth > 20) return nodeId
const node = graph.getNode(nodeId)
if (node?.componentId && node.componentId !== nodeId) {
const root = getPreComputedRoot(node.componentId, depth + 1)
preComputedRoot.set(nodeId, root)
return root
}
preComputedRoot.set(nodeId, nodeId)
return nodeId
}
for (const node of graph.getAllNodes()) {
if (node.componentId) getPreComputedRoot(node.id)
}
// Component root resolution (walks componentId chain to the ultimate source)
const componentIdRoot = new Map<string, string>()
function getComponentRoot(nodeId: string, depth = 0): string {
if (componentIdRoot.has(nodeId)) return componentIdRoot.get(nodeId) ?? nodeId
if (depth > 20) {
componentIdRoot.set(nodeId, nodeId)
return nodeId
}
// Try graph first
const node = graph.getNode(nodeId)
if (node?.componentId) {
const root = getComponentRoot(node.componentId, depth + 1)
componentIdRoot.set(nodeId, root)
return root
}
// For deleted nodes (internal page), resolve via kiwi symbolData
const figmaId = nodeIdToGuid.get(nodeId)
if (figmaId) {
const nc = changeMap.get(figmaId)
const symId = nc?.symbolData?.symbolID
if (symId) {
const compNodeId = guidToNodeId.get(guidToString(symId))
if (compNodeId && compNodeId !== nodeId) {
const root = getComponentRoot(compNodeId, depth + 1)
componentIdRoot.set(nodeId, root)
return root
}
}
}
componentIdRoot.set(nodeId, nodeId)
return nodeId
}
function findNodeByComponentId(parentId: string, componentId: string): string | null {
const parent = graph.getNode(parentId)
if (!parent) return null
// Pass 1: exact componentId match on direct children
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (child?.componentId === componentId) return childId
}
// Pass 2: root match — but only if exactly one child shares the root
// (multiple siblings with the same root are ambiguous)
const targetRoot = preComputedRoot.get(componentId) ?? getComponentRoot(componentId)
if (targetRoot) {
let rootMatch: string | null = null
let ambiguous = false
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child?.componentId) continue
const childRoot = preComputedRoot.get(child.componentId) ?? getComponentRoot(child.componentId)
if (childRoot === targetRoot) {
if (rootMatch) { ambiguous = true; break }
rootMatch = childId
}
}
if (rootMatch && !ambiguous) return rootMatch
}
// Pass 3: recurse into children
for (const childId of parent.childIds) {
const deep = findNodeByComponentId(childId, componentId)
if (deep) return deep
}
return null
}
function resolveOverrideTarget(instanceId: string, guids: GUID[]): string | null {
let currentId = instanceId
for (const guid of guids) {
const key = guidToString(guid)
const figmaGuid = overrideKeyToGuid.get(key) ?? key
const remapped = guidToNodeId.get(figmaGuid)
if (!remapped) return null
// The override may target the current node itself (when it's an instance
// cloned from the component the override points to)
const current = graph.getNode(currentId)
if (current?.componentId === remapped) {
continue
}
const found = findNodeByComponentId(currentId, remapped)
if (!found) return null
currentId = found
}
return currentId
}
// Apply component property assignments (boolean visibility, instance swap).
// Component children reference property definitions via componentPropRefs.
// Instances set values via componentPropAssignments. After cloning, we walk
// each instance's descendants and apply the assignments.
function findPropRefs(nodeId: string, propRefsMap: Map<string, ComponentPropRef[]>): ComponentPropRef[] | undefined {
let sourceId: string | undefined = nodeId
for (let depth = 0; sourceId && depth < 10; depth++) {
const figmaId = nodeIdToGuid.get(sourceId)
if (figmaId) {
const refs = propRefsMap.get(figmaId)
if (refs) return refs
}
const node = graph.getNode(sourceId)
const nextId = node?.componentId ?? undefined
if (nextId === sourceId) break
sourceId = nextId
}
return undefined
}
function repopulateInstance(nodeId: string, compId: string) {
const node = graph.getNode(nodeId)
if (node?.type !== 'INSTANCE') return
// Only rename when the current name matches the root component name
// (i.e. it wasn't manually overridden by the user).
const rootCompId = node.componentId ? getComponentRoot(node.componentId) : undefined
const rootComp = rootCompId ? graph.getNode(rootCompId) : undefined
for (const childId of Array.from(node.childIds)) graph.deleteNode(childId)
const comp = graph.getNode(compId)
const updates: Partial<SceneNode> = { componentId: compId }
if (comp?.name && rootComp?.name && node.name === rootComp.name) {
updates.name = comp.name
}
graph.updateNode(nodeId, updates)
if (comp && comp.childIds.length > 0) {
graph.populateInstanceChildren(nodeId, compId)
}
componentIdRoot.clear()
}
function isEmptyPropValue(v: ComponentPropAssignment['value']): boolean {
return v.boolValue === undefined && v.textValue === undefined && v.guidValue === undefined
}
function assignmentsToValueMap(
assignments: ComponentPropAssignment[],
resolveDefaults = false
): Map<string, ComponentPropAssignment['value']> {
const valueByDef = new Map<string, ComponentPropAssignment['value']>()
for (const a of assignments) {
if (!a.defID) continue
const key = guidToString(a.defID)
// In symbolOverride context, an empty value {} (all fields absent in
// kiwi binary) means "reset to the component's initialValue default".
// This is distinct from {boolValue: false} which is an explicit false.
if (resolveDefaults && isEmptyPropValue(a.value)) {
const def = propDefaults.get(key)
if (def) {
valueByDef.set(key, def)
continue
}
}
valueByDef.set(key, a.value)
}
return valueByDef
}
function applyInstanceDirectAssignments(
assignmentSources: Map<string, ComponentPropAssignment[]>,
propRefsMap: Map<string, ComponentPropRef[]>,
modified?: Set<string>
) {
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE') continue
// Only apply assignments from the instance's own kiwi data.
// Cloned instances inherit correct values from their source via
// transitive sync — walking the componentId chain would apply
// base-component assignments that should be overridden by
// symbolOverride componentPropAssignments at a higher level.
const ownFigmaId = nodeIdToGuid.get(node.id)
if (!ownFigmaId) continue
const ownAssignments = assignmentSources.get(ownFigmaId)
if (ownAssignments) {
applyPropAssignments(node.id, assignmentsToValueMap(ownAssignments), propRefsMap, modified)
}
}
}
function applySymbolOverrideAssignments(propRefsMap: Map<string, ComponentPropRef[]>, modified?: Set<string>) {
for (const [figmaId, nc] of changeMap) {
const instanceNodeId = guidToNodeId.get(figmaId)
if (!instanceNodeId) continue
if (graph.getNode(instanceNodeId)?.type !== 'INSTANCE') continue
const overrides = nc.symbolData?.symbolOverrides
if (!overrides) continue
for (const ov of overrides) {
if (!ov.componentPropAssignments?.length) continue
const guids = ov.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(instanceNodeId, guids)
if (!targetId) continue
applyPropAssignments(targetId, assignmentsToValueMap(ov.componentPropAssignments, true), propRefsMap, modified)
}
}
}
function applyComponentProperties(modified: Set<string>) {
const propRefsMap = new Map<string, ComponentPropRef[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropRefs?.length) {
propRefsMap.set(figmaId, nc.componentPropRefs)
}
}
if (propRefsMap.size === 0) return
const assignmentSources = new Map<string, ComponentPropAssignment[]>()
for (const [figmaId, nc] of changeMap) {
if (nc.componentPropAssignments?.length) {
assignmentSources.set(figmaId, nc.componentPropAssignments)
}
}
applyInstanceDirectAssignments(assignmentSources, propRefsMap, modified)
applySymbolOverrideAssignments(propRefsMap, modified)
}
function applyPropAssignments(
parentId: string,
valueByDef: Map<string, ComponentPropAssignment['value']>,
propRefsMap: Map<string, ComponentPropRef[]>,
modified?: Set<string>
) {
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child?.componentId) {
applyPropAssignments(childId, valueByDef, propRefsMap, modified)
continue
}
const refs = findPropRefs(child.componentId, propRefsMap)
if (refs) {
for (const ref of refs) {
if (!ref.defID) continue
const val = valueByDef.get(guidToString(ref.defID))
if (!val) continue
if (ref.componentPropNodeField === 'VISIBLE' && val.boolValue !== undefined) {
graph.updateNode(childId, { visible: val.boolValue })
modified?.add(childId)
} else if (ref.componentPropNodeField === 'OVERRIDDEN_SYMBOL_ID') {
const swapId = val.textValue ?? (val.guidValue ? guidToString(val.guidValue) : undefined)
if (!swapId) continue
const newCompId = guidToNodeId.get(swapId)
if (newCompId) {
repopulateInstance(childId, newCompId)
modified?.add(childId)
}
}
}
}
applyPropAssignments(childId, valueByDef, propRefsMap, modified)
}
}
// Apply derivedSymbolData — pre-computed sizes for the current set of
// component property values. Uses the same guidPath resolution as
// symbolOverrides.
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {
if (sx === 1 && sy === 1) return geom
return geom.map((g) => {
const src = g.commandsBlob
const scaled = new Uint8Array(src.length)
scaled.set(src)
const dv = new DataView(scaled.buffer, scaled.byteOffset, scaled.byteLength)
let o = 0
while (o < scaled.length) {
const cmd = scaled[o++]
if (cmd === 0) continue
const coords = cmd === 1 || cmd === 2 ? 1 : (cmd === 4 ? 3 : -1)
if (coords < 0) {
console.warn(`scaleGeometryBlobs: unknown path command ${cmd} at offset ${o - 1}`)
break
}
for (let i = 0; i < coords; i++) {
dv.setFloat32(o, dv.getFloat32(o, true) * sx, true)
dv.setFloat32(o + 4, dv.getFloat32(o + 4, true) * sy, true)
o += 8
}
}
return { windingRule: g.windingRule, commandsBlob: scaled }
})
}
function resolveDsdGeometry(
d: DerivedSymbolOverride,
target: SceneNode
): Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> {
const result: Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> = {}
const fg = resolveGeometryPaths(d.fillGeometry, blobs)
const sg = resolveGeometryPaths(d.strokeGeometry, blobs)
if (fg.length > 0) {
result.fillGeometry = fg
} else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height)
}
if (sg.length > 0) {
result.strokeGeometry = sg
} else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height)
}
return result
}
function resolveDsdUpdates(): { dsdModified: Set<string>; dsdSizeSet: Set<string> } {
const dsdModified = new Set<string>()
const dsdSizeSet = new Set<string>()
for (const [ncId, nc] of changeMap) {
if (nc.type !== 'INSTANCE') continue
const derived = nc.derivedSymbolData
if (!derived?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const d of derived) {
const guids = d.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(nodeId, guids)
if (!targetId) continue
const target = graph.getNode(targetId)
if (!target) continue
const updates: Partial<SceneNode> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
}
Object.assign(updates, resolveDsdGeometry(d, target))
if (Object.keys(updates).length > 0) {
graph.updateNode(targetId, updates)
dsdModified.add(targetId)
if (d.size) dsdSizeSet.add(targetId)
}
}
}
return { dsdModified, dsdSizeSet }
}
// Propagate DSD changes through clone chains — each clone should match
// its source for size/position/geometry. Nodes whose size was explicitly
// set by DSD keep their own values; others inherit from their source.
function propagateDsdChanges(dsdModified: Set<string>, dsdSizeSet: Set<string>) {
if (dsdModified.size === 0) return
const clonesOf = buildClonesMap()
const queue = [...dsdModified]
const visited = new Set<string>()
for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) {
const source = graph.getNode(sourceId)
if (!source) continue
const clones = clonesOf.get(sourceId)
if (!clones) continue
for (const cloneId of clones) {
if (visited.has(cloneId)) continue
visited.add(cloneId)
const clone = graph.getNode(cloneId)
if (!clone) continue
if (!dsdSizeSet.has(cloneId)) {
const cu: Partial<SceneNode> = {}
if (source.width !== clone.width) cu.width = source.width
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (Object.keys(cu).length > 0) graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
}
}
}
function applyDerivedSymbolData() {
const { dsdModified, dsdSizeSet } = resolveDsdUpdates()
propagateDsdChanges(dsdModified, dsdSizeSet)
}
// Tracks INSTANCE nodes whose componentId was changed by a swap override.
// Populated in applySymbolOverrides and recloneChildren, read in syncChildrenDeep
// to propagate swaps transitively through clone chains.
const swappedInstances = new Set<string>()
function applySymbolOverrides(): Set<string> {
const overriddenNodes = new Set<string>()
componentIdRoot.clear()
for (const [ncId, nc] of changeMap) {
if (nc.type !== 'INSTANCE') continue
const sd = nc.symbolData
if (!sd?.symbolOverrides?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const ov of sd.symbolOverrides) {
const guids = ov.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(nodeId, guids)
if (!targetId) continue
overriddenNodes.add(targetId)
if (ov.overriddenSymbolID) {
const swapGuid = guidToString(ov.overriddenSymbolID)
const newCompId = guidToNodeId.get(swapGuid)
if (newCompId) {
repopulateInstance(targetId, newCompId)
swappedInstances.add(targetId)
}
}
const { guidPath: _, overriddenSymbolID: _s, componentPropAssignments: _c, ...fields } = ov
if (Object.keys(fields).length === 0) continue
const updates = convertOverrideToProps(fields as Record<string, unknown>)
if (Object.keys(updates).length > 0) {
graph.updateNode(targetId, updates)
}
}
}
return overriddenNodes
}
function syncNodeProps(source: SceneNode, target: SceneNode) {
const updates: Partial<SceneNode> = {}
if (source.text !== target.text) updates.text = source.text
if (source.visible !== target.visible) updates.visible = source.visible
if (source.opacity !== target.opacity) updates.opacity = source.opacity
if (source.fills !== target.fills) updates.fills = copyFills(source.fills)
if (source.strokes !== target.strokes) updates.strokes = copyStrokes(source.strokes)
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}
function recloneChildren(srcChildId: string, tgtNode: SceneNode) {
const srcChild = graph.getNode(srcChildId)
if (!srcChild) return
for (const childId of [...tgtNode.childIds]) graph.deleteNode(childId)
graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId })
syncNodeProps(srcChild, tgtNode)
if (srcChild.childIds.length > 0) {
graph.populateInstanceChildren(tgtNode.id, srcChildId)
}
swappedInstances.add(tgtNode.id)
}
function syncChildrenDeep(sourceId: string, targetId: string, skip?: Set<string>) {
const src = graph.getNode(sourceId)
const tgt = graph.getNode(targetId)
if (!src || !tgt) return
const len = Math.min(src.childIds.length, tgt.childIds.length)
for (let i = 0; i < len; i++) {
if (skip?.has(tgt.childIds[i])) continue
const srcNode = graph.getNode(src.childIds[i])
const tgtNode = graph.getNode(tgt.childIds[i])
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) {
recloneChildren(src.childIds[i], tgtNode)
continue
}
syncNodeProps(srcNode, tgtNode)
syncChildrenDeep(src.childIds[i], tgt.childIds[i], skip)
}
}
function buildClonesMap(): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {
arr = []
clonesOf.set(node.componentId, arr)
}
arr.push(node.id)
}
return clonesOf
}
function expandSeedsToParents(seeds: Set<string>): Set<string> {
const expandedSeeds = new Set(seeds)
for (const seedId of seeds) {
let cur = graph.getNode(seedId)
while (cur?.parentId) {
const parent = graph.getNode(cur.parentId)
if (!parent) break
if (parent.type === 'INSTANCE' || parent.type === 'COMPONENT') {
expandedSeeds.add(parent.id)
}
cur = parent
}
}
return expandedSeeds
}
function buildNeedsSyncSet(expandedSeeds: Set<string>, clonesOf: Map<string, string[]>): Set<string> {
const needsSync = new Set<string>()
const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
const clones = clonesOf.get(id)
if (!clones) continue
for (const cloneId of clones) {
if (needsSync.has(cloneId)) continue
needsSync.add(cloneId)
queue.push(cloneId)
}
}
return needsSync
}
function syncCloneFromSource(sourceId: string, source: SceneNode, node: SceneNode, seeds: Set<string>) {
syncNodeProps(source, node)
if (source.childIds.length !== node.childIds.length) {
const childIds = [...node.childIds]
for (const childId of childIds) graph.deleteNode(childId)
if (source.childIds.length > 0) {
graph.populateInstanceChildren(node.id, sourceId)
}
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(sourceId, node.id, seeds)
}
}
function propagateOverridesTransitively(seeds: Set<string>) {
if (seeds.size === 0) return
// Stale after applySymbolOverrides changed componentIds via repopulateInstance
componentIdRoot.clear()
const clonesOf = buildClonesMap()
const expandedSeeds = expandSeedsToParents(seeds)
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]
for (let sourceId = syncQueue.shift(); sourceId !== undefined; sourceId = syncQueue.shift()) {
const clones = clonesOf.get(sourceId)
if (!clones) continue
const source = graph.getNode(sourceId)
if (!source) continue
for (const cloneId of clones) {
if (!needsSync.has(cloneId) || visited.has(cloneId)) continue
visited.add(cloneId)
const node = graph.getNode(cloneId)
if (!node) continue
// Don't overwrite nodes directly targeted by symbolOverrides
if (seeds.has(cloneId)) {
syncQueue.push(cloneId)
continue
}
syncCloneFromSource(sourceId, source, node, seeds)
syncQueue.push(cloneId)
}
}
}
// Order matters:
// 1. symbolOverrides — set property values and swap instances (kiwi + clones)
// 2. transitive sync — propagate overrides through remaining clone chains
// 3. componentProperties — toggle visibility / swap via prop assignments
// (runs after sync so visible-page overrides aren't clobbered,
// then a second sync propagates the results to deeper clones)
// 4. derivedSymbolData — apply Figma's pre-computed sizes last
const overriddenNodes = applySymbolOverrides()
propagateOverridesTransitively(overriddenNodes)
const propModified = new Set<string>()
applyComponentProperties(propModified)
if (propModified.size > 0) {
propagateOverridesTransitively(propModified)
}
applyDerivedSymbolData()
}

View file

@ -0,0 +1,139 @@
import type { SceneNode, GeometryPath } from '../../scene-graph'
import { copyGeometryPaths } from '../../copy'
import type { OverrideContext, DerivedSymbolOverride } from './types'
import { resolveGeometryPaths } from '../kiwi-convert'
import { resolveOverrideTarget } from './resolve'
import { buildClonesMap } from './sync'
function scaleGeometryBlobs(geom: GeometryPath[], sx: number, sy: number): GeometryPath[] {
if (sx === 1 && sy === 1) return geom
return geom.map((g) => {
const scaled = g.commandsBlob.slice()
const dv = new DataView(scaled.buffer, scaled.byteOffset, scaled.byteLength)
let o = 0
while (o < scaled.length) {
const cmd = scaled[o++]
if (cmd === 0) continue
const coords = cmd === 1 || cmd === 2 ? 1 : (cmd === 4 ? 3 : -1)
if (coords < 0) {
console.warn(`scaleGeometryBlobs: unknown path command ${cmd} at offset ${o - 1}`)
break
}
for (let i = 0; i < coords; i++) {
dv.setFloat32(o, dv.getFloat32(o, true) * sx, true)
dv.setFloat32(o + 4, dv.getFloat32(o + 4, true) * sy, true)
o += 8
}
}
return { windingRule: g.windingRule, commandsBlob: scaled }
})
}
function resolveDsdGeometry(
d: DerivedSymbolOverride,
target: SceneNode,
blobs: Uint8Array[]
): Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> {
const result: Pick<Partial<SceneNode>, 'fillGeometry' | 'strokeGeometry'> = {}
const fg = resolveGeometryPaths(d.fillGeometry, blobs)
const sg = resolveGeometryPaths(d.strokeGeometry, blobs)
if (fg.length > 0) {
result.fillGeometry = fg
} else if (d.size && target.fillGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.fillGeometry = scaleGeometryBlobs(target.fillGeometry, d.size.x / target.width, d.size.y / target.height)
}
if (sg.length > 0) {
result.strokeGeometry = sg
} else if (d.size && target.strokeGeometry.length > 0 && target.width > 0 && target.height > 0) {
result.strokeGeometry = scaleGeometryBlobs(target.strokeGeometry, d.size.x / target.width, d.size.y / target.height)
}
return result
}
function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeSet: Set<string> } {
const modified = new Set<string>()
const sizeSet = new Set<string>()
for (const [ncId, nc] of ctx.changeMap) {
if (nc.type !== 'INSTANCE') continue
const derived = nc.derivedSymbolData
if (!derived?.length) continue
const nodeId = ctx.guidToNodeId.get(ncId)
if (!nodeId) continue
for (const d of derived) {
const guids = d.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(ctx, nodeId, guids)
if (!targetId) continue
const target = ctx.graph.getNode(targetId)
if (!target) continue
const updates: Partial<SceneNode> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
}
Object.assign(updates, resolveDsdGeometry(d, target, ctx.blobs))
if (Object.keys(updates).length > 0) {
ctx.graph.updateNode(targetId, updates)
modified.add(targetId)
if (d.size) sizeSet.add(targetId)
}
}
}
return { modified, sizeSet }
}
function propagateDsdChanges(ctx: OverrideContext, modified: Set<string>, sizeSet: Set<string>): void {
if (modified.size === 0) return
const clonesOf = buildClonesMap(ctx.graph)
const queue = [...modified]
const visited = new Set<string>()
for (let sourceId = queue.shift(); sourceId !== undefined; sourceId = queue.shift()) {
const source = ctx.graph.getNode(sourceId)
if (!source) continue
const clones = clonesOf.get(sourceId)
if (!clones) continue
for (const cloneId of clones) {
if (visited.has(cloneId)) continue
visited.add(cloneId)
const clone = ctx.graph.getNode(cloneId)
if (!clone) continue
if (!sizeSet.has(cloneId)) {
const cu: Partial<SceneNode> = {}
if (source.width !== clone.width) cu.width = source.width
if (source.height !== clone.height) cu.height = source.height
if (source.x !== clone.x) cu.x = source.x
if (source.y !== clone.y) cu.y = source.y
if (source.fillGeometry !== clone.fillGeometry) cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry) cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
if (Object.keys(cu).length > 0) ctx.graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
}
}
}
/**
* Apply derivedSymbolData Figma's pre-computed sizes, positions,
* and geometry for instance overrides. Runs last in the pipeline.
*/
export function applyDerivedSymbolData(ctx: OverrideContext): void {
const { modified, sizeSet } = resolveDsdUpdates(ctx)
propagateDsdChanges(ctx, modified, sizeSet)
}

View file

@ -0,0 +1,99 @@
export type {
InstanceNodeChange,
OverrideContext,
ComponentPropAssignment,
ComponentPropDef,
ComponentPropRef,
ComponentPropValue,
DerivedSymbolOverride,
SymbolData,
SymbolOverride,
} from './types'
import type { SceneGraph } from '../../scene-graph'
import type { InstanceNodeChange, OverrideContext, ComponentPropValue } from './types'
import { guidToString } from '../kiwi-convert'
import { populateInstances } from './populate'
import { preComputeRoots } from './resolve'
import { applySymbolOverrides } from './symbol-overrides'
import { propagateOverridesTransitively } from './sync'
import { applyComponentProperties } from './props'
import { applyDerivedSymbolData } from './dsd'
function buildOverrideContext(
graph: SceneGraph,
changeMap: Map<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>,
blobs: Uint8Array[]
): OverrideContext {
const overrideKeyToGuid = new Map<string, string>()
for (const [id, nc] of changeMap) {
if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id)
}
const propDefaults = new Map<string, ComponentPropValue>()
for (const [, nc] of changeMap) {
if (!nc.componentPropDefs?.length) continue
for (const def of nc.componentPropDefs) {
if (def.id && def.initialValue) {
propDefaults.set(guidToString(def.id), def.initialValue)
}
}
}
const nodeIdToGuid = new Map<string, string>()
for (const [figmaId, nodeId] of guidToNodeId) {
nodeIdToGuid.set(nodeId, figmaId)
}
return {
graph,
changeMap,
guidToNodeId,
blobs,
overrideKeyToGuid,
nodeIdToGuid,
propDefaults,
preComputedRoot: new Map(),
componentIdRoot: new Map(),
swappedInstances: new Set(),
}
}
/**
* Populate empty instances from their components and apply symbol overrides.
*
* Shared between .fig file import and clipboard paste. Both paths produce
* a SceneGraph with INSTANCE nodes whose componentId references have been
* remapped to graph node IDs but whose children may be missing and whose
* overrides have not yet been applied.
*
* Resolution order:
* 1. Populate clone component trees into empty instances
* 2. Symbol overrides set property values and swap instances
* 3. Transitive sync propagate overrides through clone chains
* 4. Component properties toggle visibility / swap via prop assignments
* 5. Second transitive sync propagate property changes to deeper clones
* 6. Derived symbol data apply Figma's pre-computed sizes last
*/
export function populateAndApplyOverrides(
graph: SceneGraph,
changeMap: Map<string, InstanceNodeChange>,
guidToNodeId: Map<string, string>,
blobs: Uint8Array[] = []
): void {
populateInstances(graph)
const ctx = buildOverrideContext(graph, changeMap, guidToNodeId, blobs)
preComputeRoots(ctx)
const overriddenNodes = applySymbolOverrides(ctx)
propagateOverridesTransitively(graph, overriddenNodes, ctx.swappedInstances, ctx.componentIdRoot)
const propModified = applyComponentProperties(ctx)
if (propModified.size > 0) {
propagateOverridesTransitively(graph, propModified, ctx.swappedInstances, ctx.componentIdRoot)
}
applyDerivedSymbolData(ctx)
}

View file

@ -0,0 +1,57 @@
import type { SceneGraph } from '../../scene-graph'
/**
* Populate empty INSTANCE nodes from their source components.
*
* Instances must be populated bottom-up: if an instance's source is
* itself an unpopulated instance, populate the source first so cloned
* children are complete.
*/
export function populateInstances(graph: SceneGraph): void {
const visiting = new Set<string>()
function ensurePopulated(nodeId: string): void {
const node = graph.getNode(nodeId)
if (node?.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) return
if (visiting.has(nodeId)) return
visiting.add(nodeId)
const comp = graph.getNode(node.componentId)
if (!comp) return
if (comp.type === 'INSTANCE' && comp.componentId && comp.childIds.length === 0) {
ensurePopulated(comp.id)
}
for (const childId of comp.childIds) {
const child = graph.getNode(childId)
if (child?.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) {
ensurePopulated(childId)
}
}
if (comp.childIds.length > 0 && node.childIds.length === 0) {
graph.populateInstanceChildren(nodeId, node.componentId)
}
}
for (const node of graph.getAllNodes()) {
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
ensurePopulated(node.id)
}
}
// Cloning may introduce new empty instances not seen in the first pass
// (nested clones). Repeat until stable.
let changed = true
while (changed) {
changed = false
for (const node of graph.getAllNodes()) {
if (node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue
const comp = graph.getNode(node.componentId)
if (comp && comp.childIds.length > 0) {
graph.populateInstanceChildren(node.id, node.componentId)
changed = true
}
}
}
}

View file

@ -0,0 +1,192 @@
import type { OverrideContext, ComponentPropAssignment, ComponentPropRef, ComponentPropValue } from './types'
import { guidToString } from '../kiwi-convert'
import { resolveOverrideTarget, repopulateInstance } from './resolve'
function isEmptyPropValue(v: ComponentPropValue): boolean {
return v.boolValue === undefined && v.textValue === undefined && v.guidValue === undefined
}
/**
* Walk the componentId chain to find componentPropRefs for a node.
* The refs may be defined on the component several levels up.
*/
function findPropRefs(
ctx: OverrideContext,
nodeId: string,
propRefsMap: Map<string, ComponentPropRef[]>
): ComponentPropRef[] | undefined {
let sourceId: string | undefined = nodeId
for (let depth = 0; sourceId && depth < 10; depth++) {
const figmaId = ctx.nodeIdToGuid.get(sourceId)
if (figmaId) {
const refs = propRefsMap.get(figmaId)
if (refs) return refs
}
const node = ctx.graph.getNode(sourceId)
const nextId = node?.componentId ?? undefined
if (nextId === sourceId) break
sourceId = nextId
}
return undefined
}
/**
* Convert assignments to a defID value map, optionally resolving empty
* values to component defaults.
*
* In symbolOverride context, an empty kiwi value `{}` (all fields absent)
* means "reset to the component's initialValue default". This is distinct
* from `{boolValue: false}` which is an explicit false.
*/
function assignmentsToValueMap(
ctx: OverrideContext,
assignments: ComponentPropAssignment[],
resolveDefaults = false
): Map<string, ComponentPropValue> {
const valueByDef = new Map<string, ComponentPropValue>()
for (const a of assignments) {
if (!a.defID) continue
const key = guidToString(a.defID)
if (resolveDefaults && isEmptyPropValue(a.value)) {
const def = ctx.propDefaults.get(key)
if (def) {
valueByDef.set(key, def)
continue
}
}
valueByDef.set(key, a.value)
}
return valueByDef
}
/**
* Recursively apply prop assignments to children of a parent node.
* Handles VISIBLE toggles and OVERRIDDEN_SYMBOL_ID (instance swap).
*/
function applyPropAssignments(
ctx: OverrideContext,
parentId: string,
valueByDef: Map<string, ComponentPropValue>,
propRefsMap: Map<string, ComponentPropRef[]>,
modified?: Set<string>
): void {
const parent = ctx.graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = ctx.graph.getNode(childId)
if (!child?.componentId) {
applyPropAssignments(ctx, childId, valueByDef, propRefsMap, modified)
continue
}
const refs = findPropRefs(ctx, child.componentId, propRefsMap)
if (refs) {
for (const ref of refs) {
if (!ref.defID) continue
const val = valueByDef.get(guidToString(ref.defID))
if (!val) continue
if (ref.componentPropNodeField === 'VISIBLE' && val.boolValue !== undefined) {
ctx.graph.updateNode(childId, { visible: val.boolValue })
modified?.add(childId)
} else if (ref.componentPropNodeField === 'OVERRIDDEN_SYMBOL_ID') {
const swapId = val.textValue ?? (val.guidValue ? guidToString(val.guidValue) : undefined)
if (!swapId) continue
const newCompId = ctx.guidToNodeId.get(swapId)
if (newCompId) {
repopulateInstance(ctx, childId, newCompId)
modified?.add(childId)
}
}
}
}
applyPropAssignments(ctx, childId, valueByDef, propRefsMap, modified)
}
}
/**
* Apply component property assignments from each instance's own kiwi data.
*
* Only processes nodes with their own kiwi NC cloned instances inherit
* correct values from their source via transitive sync.
*/
function applyInstanceDirectAssignments(
ctx: OverrideContext,
assignmentSources: Map<string, ComponentPropAssignment[]>,
propRefsMap: Map<string, ComponentPropRef[]>,
modified: Set<string>
): void {
for (const node of ctx.graph.getAllNodes()) {
if (node.type !== 'INSTANCE') continue
const ownFigmaId = ctx.nodeIdToGuid.get(node.id)
if (!ownFigmaId) continue
const ownAssignments = assignmentSources.get(ownFigmaId)
if (ownAssignments) {
applyPropAssignments(ctx, node.id, assignmentsToValueMap(ctx, ownAssignments), propRefsMap, modified)
}
}
}
/**
* Apply component property assignments from symbolOverrides.
*
* These target nested instances via guidPath and may reset values to
* component defaults (empty kiwi value `{}`).
*/
function applyOverrideAssignments(
ctx: OverrideContext,
propRefsMap: Map<string, ComponentPropRef[]>,
modified: Set<string>
): void {
for (const [figmaId, nc] of ctx.changeMap) {
const instanceNodeId = ctx.guidToNodeId.get(figmaId)
if (!instanceNodeId) continue
if (ctx.graph.getNode(instanceNodeId)?.type !== 'INSTANCE') continue
const overrides = nc.symbolData?.symbolOverrides
if (!overrides) continue
for (const ov of overrides) {
if (!ov.componentPropAssignments?.length) continue
const guids = ov.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(ctx, instanceNodeId, guids)
if (!targetId) continue
applyPropAssignments(ctx, targetId, assignmentsToValueMap(ctx, ov.componentPropAssignments, true), propRefsMap, modified)
}
}
}
/**
* Apply all component property assignments (visibility toggles, instance swaps).
*
* Returns the set of modified node IDs so the caller can run a second
* transitive sync to propagate the changes to deeper clones.
*/
export function applyComponentProperties(ctx: OverrideContext): Set<string> {
const modified = new Set<string>()
const propRefsMap = new Map<string, ComponentPropRef[]>()
for (const [figmaId, nc] of ctx.changeMap) {
if (nc.componentPropRefs?.length) {
propRefsMap.set(figmaId, nc.componentPropRefs)
}
}
if (propRefsMap.size === 0) return modified
const assignmentSources = new Map<string, ComponentPropAssignment[]>()
for (const [figmaId, nc] of ctx.changeMap) {
if (nc.componentPropAssignments?.length) {
assignmentSources.set(figmaId, nc.componentPropAssignments)
}
}
applyInstanceDirectAssignments(ctx, assignmentSources, propRefsMap, modified)
applyOverrideAssignments(ctx, propRefsMap, modified)
return modified
}

View file

@ -0,0 +1,160 @@
import type { SceneNode } from '../../scene-graph'
import type { GUID } from '../codec'
import type { OverrideContext } from './types'
import { guidToString } from '../kiwi-convert'
const MAX_CHAIN_DEPTH = 20
/**
* Pre-compute componentId root for every node.
*
* Must run while all internal-page nodes are still alive. After overrides,
* instance swaps delete intermediate clones, breaking the chain.
* DSD resolution uses this to match across clone levels.
*/
export function preComputeRoots(ctx: OverrideContext): void {
function resolve(nodeId: string, depth = 0): string {
const cached = ctx.preComputedRoot.get(nodeId)
if (cached !== undefined) return cached
if (depth > MAX_CHAIN_DEPTH) return nodeId
const node = ctx.graph.getNode(nodeId)
if (node?.componentId && node.componentId !== nodeId) {
const root = resolve(node.componentId, depth + 1)
ctx.preComputedRoot.set(nodeId, root)
return root
}
ctx.preComputedRoot.set(nodeId, nodeId)
return nodeId
}
for (const node of ctx.graph.getAllNodes()) {
if (node.componentId) resolve(node.id)
}
}
/**
* Walk the componentId chain to the ultimate source COMPONENT.
* Falls back to kiwi symbolData for deleted internal-page nodes.
*/
export function getComponentRoot(ctx: OverrideContext, nodeId: string, depth = 0): string {
const cached = ctx.componentIdRoot.get(nodeId)
if (cached !== undefined) return cached
if (depth > MAX_CHAIN_DEPTH) {
ctx.componentIdRoot.set(nodeId, nodeId)
return nodeId
}
const node = ctx.graph.getNode(nodeId)
if (node?.componentId) {
const root = getComponentRoot(ctx, node.componentId, depth + 1)
ctx.componentIdRoot.set(nodeId, root)
return root
}
// For deleted nodes (internal page), resolve via kiwi symbolData
const figmaId = ctx.nodeIdToGuid.get(nodeId)
if (figmaId) {
const nc = ctx.changeMap.get(figmaId)
const symId = nc?.symbolData?.symbolID
if (symId) {
const compNodeId = ctx.guidToNodeId.get(guidToString(symId))
if (compNodeId && compNodeId !== nodeId) {
const root = getComponentRoot(ctx, compNodeId, depth + 1)
ctx.componentIdRoot.set(nodeId, root)
return root
}
}
}
ctx.componentIdRoot.set(nodeId, nodeId)
return nodeId
}
/**
* Find a descendant whose componentId matches, walking recursively.
*
* Pass 1: exact componentId on direct children.
* Pass 2: root match only if exactly one child shares the root (avoids
* ambiguity when multiple siblings share the same root).
* Pass 3: recurse into children.
*/
export function findNodeByComponentId(ctx: OverrideContext, parentId: string, componentId: string): string | null {
const parent = ctx.graph.getNode(parentId)
if (!parent) return null
for (const childId of parent.childIds) {
const child = ctx.graph.getNode(childId)
if (child?.componentId === componentId) return childId
}
const targetRoot = ctx.preComputedRoot.get(componentId) ?? getComponentRoot(ctx, componentId)
if (targetRoot) {
let rootMatch: string | null = null
let ambiguous = false
for (const childId of parent.childIds) {
const child = ctx.graph.getNode(childId)
if (!child?.componentId) continue
const childRoot = ctx.preComputedRoot.get(child.componentId) ?? getComponentRoot(ctx, child.componentId)
if (childRoot === targetRoot) {
if (rootMatch) { ambiguous = true; break }
rootMatch = childId
}
}
if (rootMatch && !ambiguous) return rootMatch
}
for (const childId of parent.childIds) {
const deep = findNodeByComponentId(ctx, childId, componentId)
if (deep) return deep
}
return null
}
/**
* Resolve a guidPath to a target node within an instance subtree.
*
* Each GUID in the path identifies an overrideKey figmaGuid graph node.
* The chain walks from the instance down to the target.
*/
export function resolveOverrideTarget(ctx: OverrideContext, instanceId: string, guids: GUID[]): string | null {
let currentId = instanceId
for (const guid of guids) {
const key = guidToString(guid)
const figmaGuid = ctx.overrideKeyToGuid.get(key) ?? key
const remapped = ctx.guidToNodeId.get(figmaGuid)
if (!remapped) return null
const current = ctx.graph.getNode(currentId)
if (current?.componentId === remapped) continue
const found = findNodeByComponentId(ctx, currentId, remapped)
if (!found) return null
currentId = found
}
return currentId
}
/**
* Repopulate an INSTANCE node with children from a new component (instance swap).
* Only renames when the current name matches the root component name (preserves
* user-given names). Clears the componentIdRoot cache after changing the tree.
*/
export function repopulateInstance(ctx: OverrideContext, nodeId: string, compId: string): void {
const node = ctx.graph.getNode(nodeId)
if (node?.type !== 'INSTANCE') return
const rootCompId = node.componentId ? getComponentRoot(ctx, node.componentId) : undefined
const rootComp = rootCompId ? ctx.graph.getNode(rootCompId) : undefined
for (const childId of Array.from(node.childIds)) ctx.graph.deleteNode(childId)
const comp = ctx.graph.getNode(compId)
const updates: Partial<SceneNode> = { componentId: compId }
if (comp?.name && rootComp?.name && node.name === rootComp.name) {
updates.name = comp.name
}
ctx.graph.updateNode(nodeId, updates)
if (comp && comp.childIds.length > 0) {
ctx.graph.populateInstanceChildren(nodeId, compId)
}
ctx.componentIdRoot.clear()
}

View file

@ -0,0 +1,53 @@
import type { OverrideContext } from './types'
import { guidToString } from '../kiwi-convert'
import { convertOverrideToProps } from '../kiwi-convert-overrides'
import { resolveOverrideTarget, repopulateInstance } from './resolve'
/**
* Apply symbolOverrides from kiwi data.
*
* Handles instance swaps (overriddenSymbolID) and property overrides
* (fills, text, visibility, etc.). Returns the set of directly
* overridden node IDs (used as seeds for transitive sync).
*/
export function applySymbolOverrides(ctx: OverrideContext): Set<string> {
const overriddenNodes = new Set<string>()
ctx.componentIdRoot.clear()
for (const [ncId, nc] of ctx.changeMap) {
if (nc.type !== 'INSTANCE') continue
const overrides = nc.symbolData?.symbolOverrides
if (!overrides?.length) continue
const nodeId = ctx.guidToNodeId.get(ncId)
if (!nodeId) continue
for (const ov of overrides) {
const guids = ov.guidPath?.guids
if (!guids?.length) continue
const targetId = resolveOverrideTarget(ctx, nodeId, guids)
if (!targetId) continue
overriddenNodes.add(targetId)
if (ov.overriddenSymbolID) {
const swapGuid = guidToString(ov.overriddenSymbolID)
const newCompId = ctx.guidToNodeId.get(swapGuid)
if (newCompId) {
repopulateInstance(ctx, targetId, newCompId)
ctx.swappedInstances.add(targetId)
}
}
const { guidPath: _, overriddenSymbolID: _s, componentPropAssignments: _c, ...fields } = ov
if (Object.keys(fields).length === 0) continue
const updates = convertOverrideToProps(fields as Record<string, unknown>)
if (Object.keys(updates).length > 0) {
ctx.graph.updateNode(targetId, updates)
}
}
}
return overriddenNodes
}

View file

@ -0,0 +1,174 @@
import type { SceneGraph, SceneNode } from '../../scene-graph'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '../../copy'
/**
* Copy appearance props from source to target (text, visibility, fills, etc.).
* Only writes properties that actually differ.
*/
export function syncNodeProps(graph: SceneGraph, source: SceneNode, target: SceneNode): void {
const updates: Partial<SceneNode> = {}
if (source.text !== target.text) updates.text = source.text
if (source.visible !== target.visible) updates.visible = source.visible
if (source.opacity !== target.opacity) updates.opacity = source.opacity
if (source.fills !== target.fills) updates.fills = copyFills(source.fills)
if (source.strokes !== target.strokes) updates.strokes = copyStrokes(source.strokes)
if (source.effects !== target.effects) updates.effects = copyEffects(source.effects)
if (source.styleRuns !== target.styleRuns) updates.styleRuns = copyStyleRuns(source.styleRuns)
if (source.layoutGrow !== target.layoutGrow) updates.layoutGrow = source.layoutGrow
if (source.textAutoResize !== target.textAutoResize) updates.textAutoResize = source.textAutoResize
if (source.locked !== target.locked) updates.locked = source.locked
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}
/**
* Re-clone an instance's children from a new source (after instance swap).
* Marks the target in `swappedInstances` so transitive sync propagates the swap.
*/
export function recloneChildren(
graph: SceneGraph,
srcChildId: string,
tgtNode: SceneNode,
swappedInstances: Set<string>
): void {
const srcChild = graph.getNode(srcChildId)
if (!srcChild) return
for (const childId of Array.from(tgtNode.childIds)) graph.deleteNode(childId)
graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId })
syncNodeProps(graph, srcChild, tgtNode)
if (srcChild.childIds.length > 0) {
graph.populateInstanceChildren(tgtNode.id, srcChildId)
}
swappedInstances.add(tgtNode.id)
}
/**
* Recursively sync children between source and target trees.
* Handles instance swap propagation via `swappedInstances`.
*/
export function syncChildrenDeep(
graph: SceneGraph,
sourceId: string,
targetId: string,
swappedInstances: Set<string>,
skip?: Set<string>
): void {
const src = graph.getNode(sourceId)
const tgt = graph.getNode(targetId)
if (!src || !tgt) return
const len = Math.min(src.childIds.length, tgt.childIds.length)
for (let i = 0; i < len; i++) {
if (skip?.has(tgt.childIds[i])) continue
const srcNode = graph.getNode(src.childIds[i])
const tgtNode = graph.getNode(tgt.childIds[i])
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) {
recloneChildren(graph, src.childIds[i], tgtNode, swappedInstances)
continue
}
syncNodeProps(graph, srcNode, tgtNode)
syncChildrenDeep(graph, src.childIds[i], tgt.childIds[i], swappedInstances, skip)
}
}
/** Build a map of componentId → list of clone node IDs. */
export function buildClonesMap(graph: SceneGraph): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {
arr = []
clonesOf.set(node.componentId, arr)
}
arr.push(node.id)
}
return clonesOf
}
/** Expand seeds to include INSTANCE/COMPONENT parents up the tree. */
function expandSeedsToParents(graph: SceneGraph, seeds: Set<string>): Set<string> {
const expanded = new Set(seeds)
for (const seedId of seeds) {
let cur = graph.getNode(seedId)
while (cur?.parentId) {
const parent = graph.getNode(cur.parentId)
if (!parent) break
if (parent.type === 'INSTANCE' || parent.type === 'COMPONENT') {
expanded.add(parent.id)
}
cur = parent
}
}
return expanded
}
/** BFS from expanded seeds through clone chains to find all nodes needing sync. */
function buildNeedsSyncSet(expandedSeeds: Set<string>, clonesOf: Map<string, string[]>): Set<string> {
const needsSync = new Set<string>()
const queue = [...expandedSeeds]
for (let id = queue.pop(); id !== undefined; id = queue.pop()) {
const clones = clonesOf.get(id)
if (!clones) continue
for (const cloneId of clones) {
if (needsSync.has(cloneId)) continue
needsSync.add(cloneId)
queue.push(cloneId)
}
}
return needsSync
}
/**
* Propagate overrides transitively through clone chains.
*
* Nodes in `seeds` are override sources their clones are synced but
* the seeds themselves are NOT overwritten (they carry explicit overrides).
*/
export function propagateOverridesTransitively(
graph: SceneGraph,
seeds: Set<string>,
swappedInstances: Set<string>,
componentIdRoot: Map<string, string>
): void {
if (seeds.size === 0) return
componentIdRoot.clear()
const clonesOf = buildClonesMap(graph)
const expandedSeeds = expandSeedsToParents(graph, seeds)
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]
for (let sourceId = syncQueue.shift(); sourceId !== undefined; sourceId = syncQueue.shift()) {
const clones = clonesOf.get(sourceId)
if (!clones) continue
const source = graph.getNode(sourceId)
if (!source) continue
for (const cloneId of clones) {
if (!needsSync.has(cloneId) || visited.has(cloneId)) continue
visited.add(cloneId)
const node = graph.getNode(cloneId)
if (!node) continue
if (seeds.has(cloneId)) {
syncQueue.push(cloneId)
continue
}
syncNodeProps(graph, source, node)
if (source.childIds.length !== node.childIds.length) {
for (const childId of Array.from(node.childIds)) graph.deleteNode(childId)
if (source.childIds.length > 0) {
graph.populateInstanceChildren(node.id, sourceId)
}
} else if (source.childIds.length > 0 && node.childIds.length > 0) {
syncChildrenDeep(graph, sourceId, node.id, swappedInstances, seeds)
}
syncQueue.push(cloneId)
}
}
}

View file

@ -0,0 +1,78 @@
import type { SceneGraph } from '../../scene-graph'
import type { GUID } from '../codec'
import type { Matrix, Vector } from '../../types'
export interface SymbolOverride {
guidPath?: { guids?: GUID[] }
overriddenSymbolID?: GUID
componentPropAssignments?: ComponentPropAssignment[]
[key: string]: unknown
}
export interface SymbolData {
symbolID?: GUID
symbolOverrides?: SymbolOverride[]
}
export interface ComponentPropRef {
defID?: GUID
componentPropNodeField: string
}
export type ComponentPropValue = {
boolValue?: boolean
textValue?: string
guidValue?: GUID
}
export interface ComponentPropAssignment {
defID?: GUID
value: ComponentPropValue
}
export interface DerivedSymbolOverride {
guidPath?: { guids?: GUID[] }
size?: Vector
transform?: Matrix
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
}
export interface ComponentPropDef {
id?: GUID
name?: string
initialValue?: ComponentPropValue
type?: number
}
export interface InstanceNodeChange {
type?: string
guid?: GUID
overrideKey?: GUID
symbolData?: SymbolData
componentPropRefs?: ComponentPropRef[]
componentPropAssignments?: ComponentPropAssignment[]
componentPropDefs?: ComponentPropDef[]
derivedSymbolData?: DerivedSymbolOverride[]
}
/**
* Shared state for override resolution.
*
* Built once in `populateAndApplyOverrides` and threaded through all
* sub-functions. Avoids closure-based coupling (a single 700-line
* function) while keeping the shared maps accessible.
*/
export interface OverrideContext {
graph: SceneGraph
changeMap: Map<string, InstanceNodeChange>
guidToNodeId: Map<string, string>
blobs: Uint8Array[]
overrideKeyToGuid: Map<string, string>
nodeIdToGuid: Map<string, string>
propDefaults: Map<string, ComponentPropValue>
preComputedRoot: Map<string, string>
componentIdRoot: Map<string, string>
swappedInstances: Set<string>
}