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:
parent
2cecdff876
commit
204b225c64
|
|
@ -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()
|
||||
}
|
||||
139
packages/core/src/kiwi/instance-overrides/dsd.ts
Normal file
139
packages/core/src/kiwi/instance-overrides/dsd.ts
Normal 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)
|
||||
}
|
||||
99
packages/core/src/kiwi/instance-overrides/index.ts
Normal file
99
packages/core/src/kiwi/instance-overrides/index.ts
Normal 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)
|
||||
}
|
||||
57
packages/core/src/kiwi/instance-overrides/populate.ts
Normal file
57
packages/core/src/kiwi/instance-overrides/populate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
192
packages/core/src/kiwi/instance-overrides/props.ts
Normal file
192
packages/core/src/kiwi/instance-overrides/props.ts
Normal 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
|
||||
}
|
||||
160
packages/core/src/kiwi/instance-overrides/resolve.ts
Normal file
160
packages/core/src/kiwi/instance-overrides/resolve.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
174
packages/core/src/kiwi/instance-overrides/sync.ts
Normal file
174
packages/core/src/kiwi/instance-overrides/sync.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
78
packages/core/src/kiwi/instance-overrides/types.ts
Normal file
78
packages/core/src/kiwi/instance-overrides/types.ts
Normal 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>
|
||||
}
|
||||
Loading…
Reference in a new issue