From 204b225c64cf0f7a49520ee3b337a4eb8c11dc98 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 14 Mar 2026 10:33:05 +0300 Subject: [PATCH] Split instance-overrides.ts into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/kiwi/instance-overrides.ts | 781 ------------------ .../core/src/kiwi/instance-overrides/dsd.ts | 139 ++++ .../core/src/kiwi/instance-overrides/index.ts | 99 +++ .../src/kiwi/instance-overrides/populate.ts | 57 ++ .../core/src/kiwi/instance-overrides/props.ts | 192 +++++ .../src/kiwi/instance-overrides/resolve.ts | 160 ++++ .../instance-overrides/symbol-overrides.ts | 53 ++ .../core/src/kiwi/instance-overrides/sync.ts | 174 ++++ .../core/src/kiwi/instance-overrides/types.ts | 78 ++ 9 files changed, 952 insertions(+), 781 deletions(-) delete mode 100644 packages/core/src/kiwi/instance-overrides.ts create mode 100644 packages/core/src/kiwi/instance-overrides/dsd.ts create mode 100644 packages/core/src/kiwi/instance-overrides/index.ts create mode 100644 packages/core/src/kiwi/instance-overrides/populate.ts create mode 100644 packages/core/src/kiwi/instance-overrides/props.ts create mode 100644 packages/core/src/kiwi/instance-overrides/resolve.ts create mode 100644 packages/core/src/kiwi/instance-overrides/symbol-overrides.ts create mode 100644 packages/core/src/kiwi/instance-overrides/sync.ts create mode 100644 packages/core/src/kiwi/instance-overrides/types.ts diff --git a/packages/core/src/kiwi/instance-overrides.ts b/packages/core/src/kiwi/instance-overrides.ts deleted file mode 100644 index 0ff8deef9..000000000 --- a/packages/core/src/kiwi/instance-overrides.ts +++ /dev/null @@ -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, - guidToNodeId: Map, - 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): 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() - 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() - 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() - 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() - 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() - 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() - 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): 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 = { 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 { - const valueByDef = new Map() - 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, - propRefsMap: Map, - modified?: Set - ) { - 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, modified?: Set) { - 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) { - const propRefsMap = new Map() - for (const [figmaId, nc] of changeMap) { - if (nc.componentPropRefs?.length) { - propRefsMap.set(figmaId, nc.componentPropRefs) - } - } - if (propRefsMap.size === 0) return - - const assignmentSources = new Map() - 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, - propRefsMap: Map, - modified?: Set - ) { - 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, 'fillGeometry' | 'strokeGeometry'> { - const result: Pick, '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; dsdSizeSet: Set } { - const dsdModified = new Set() - const dsdSizeSet = new Set() - - 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 = {} - 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, dsdSizeSet: Set) { - if (dsdModified.size === 0) return - - const clonesOf = buildClonesMap() - - const queue = [...dsdModified] - const visited = new Set() - 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 = {} - 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() - - function applySymbolOverrides(): Set { - const overriddenNodes = new Set() - 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) - if (Object.keys(updates).length > 0) { - graph.updateNode(targetId, updates) - } - } - } - return overriddenNodes - } - - function syncNodeProps(source: SceneNode, target: SceneNode) { - const updates: Partial = {} - 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) { - 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 { - const clonesOf = new Map() - 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): Set { - 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, clonesOf: Map): Set { - const needsSync = new Set() - 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) { - 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) { - 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() - 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() - applyComponentProperties(propModified) - - if (propModified.size > 0) { - propagateOverridesTransitively(propModified) - } - - applyDerivedSymbolData() -} diff --git a/packages/core/src/kiwi/instance-overrides/dsd.ts b/packages/core/src/kiwi/instance-overrides/dsd.ts new file mode 100644 index 000000000..c6b662e03 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/dsd.ts @@ -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, 'fillGeometry' | 'strokeGeometry'> { + const result: Pick, '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; sizeSet: Set } { + const modified = new Set() + const sizeSet = new Set() + + 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 = {} + 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, sizeSet: Set): void { + if (modified.size === 0) return + + const clonesOf = buildClonesMap(ctx.graph) + const queue = [...modified] + const visited = new Set() + + 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 = {} + 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) +} diff --git a/packages/core/src/kiwi/instance-overrides/index.ts b/packages/core/src/kiwi/instance-overrides/index.ts new file mode 100644 index 000000000..0a4cbfbb5 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/index.ts @@ -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, + guidToNodeId: Map, + blobs: Uint8Array[] +): OverrideContext { + const overrideKeyToGuid = new Map() + for (const [id, nc] of changeMap) { + if (nc.overrideKey) overrideKeyToGuid.set(guidToString(nc.overrideKey), id) + } + + const propDefaults = new Map() + 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() + 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, + guidToNodeId: Map, + 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) +} diff --git a/packages/core/src/kiwi/instance-overrides/populate.ts b/packages/core/src/kiwi/instance-overrides/populate.ts new file mode 100644 index 000000000..2cdecc3c1 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/populate.ts @@ -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() + + 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 + } + } + } +} diff --git a/packages/core/src/kiwi/instance-overrides/props.ts b/packages/core/src/kiwi/instance-overrides/props.ts new file mode 100644 index 000000000..c84518238 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/props.ts @@ -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 +): 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 { + const valueByDef = new Map() + 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, + propRefsMap: Map, + modified?: Set +): 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, + propRefsMap: Map, + modified: Set +): 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, + modified: Set +): 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 { + const modified = new Set() + + const propRefsMap = new Map() + 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() + 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 +} diff --git a/packages/core/src/kiwi/instance-overrides/resolve.ts b/packages/core/src/kiwi/instance-overrides/resolve.ts new file mode 100644 index 000000000..b4522d416 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/resolve.ts @@ -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 = { 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() +} diff --git a/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts b/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts new file mode 100644 index 000000000..3087c3a54 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/symbol-overrides.ts @@ -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 { + const overriddenNodes = new Set() + 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) + if (Object.keys(updates).length > 0) { + ctx.graph.updateNode(targetId, updates) + } + } + } + return overriddenNodes +} diff --git a/packages/core/src/kiwi/instance-overrides/sync.ts b/packages/core/src/kiwi/instance-overrides/sync.ts new file mode 100644 index 000000000..809dff62a --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/sync.ts @@ -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 = {} + 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 +): 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, + skip?: Set +): 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 { + const clonesOf = new Map() + 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): Set { + 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, clonesOf: Map): Set { + const needsSync = new Set() + 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, + swappedInstances: Set, + componentIdRoot: Map +): 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() + 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) + } + } +} diff --git a/packages/core/src/kiwi/instance-overrides/types.ts b/packages/core/src/kiwi/instance-overrides/types.ts new file mode 100644 index 000000000..4282aec9e --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/types.ts @@ -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 + guidToNodeId: Map + blobs: Uint8Array[] + + overrideKeyToGuid: Map + nodeIdToGuid: Map + propDefaults: Map + preComputedRoot: Map + componentIdRoot: Map + swappedInstances: Set +}