refactor(fig): split override sync modules

This commit is contained in:
Danila Poyarkov 2026-05-18 00:36:37 +03:00
parent 1fc0b62e9e
commit 22991daf77
12 changed files with 489 additions and 574 deletions

View file

@ -1,9 +1,9 @@
import {
applyInstanceDirectAssignments,
applyOverrideAssignments
} from './component-props/assignments'
import { collectAssignmentsMap, collectPropRefsMap } from './component-props/maps'
import type { OverrideContext } from './types'
} from './assignments'
import { collectAssignmentsMap, collectPropRefsMap } from './maps'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
/**
* Apply all component property assignments (visibility toggles, instance swaps).

View file

@ -0,0 +1,48 @@
import { resolveGeometryPaths } from '#core/kiwi/node-change/convert'
import type { DerivedSymbolOverride } from '#core/kiwi/instance-overrides/types'
import type { GeometryPath, SceneNode } from '#core/scene-graph'
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
let coords = -1
if (cmd === 1 || cmd === 2) coords = 1
else if (cmd === 4) coords = 3
if (coords < 0) 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 }
})
}
export 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
}

View file

@ -0,0 +1,57 @@
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
import { buildDsdLayoutUpdates } from './layout'
import { propagateDsdChanges } from './propagate'
function applyDsdOverride(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
nodeId: string,
d: DerivedSymbolOverride,
modified: Set<string>,
sizeSet: Set<string>
): void {
const guids = d.guidPath?.guids
if (!guids?.length) return
const targetId = resolveOverrideTarget(ctx, nodeId, guids)
if (!targetId) return
const target = ctx.graph.getNode(targetId)
if (!target) return
const { updates, hasSize } = buildDsdLayoutUpdates(ctx, visibleSiblingCount, d, target)
if (d.fillGeometry?.length || d.strokeGeometry?.length) ctx.geometryOverrideNodes.add(targetId)
if (Object.keys(updates).length === 0) return
if (applyOverridePatch(ctx, { targetId, source: 'derived-symbol-data', props: updates })) {
modified.add(targetId)
}
if (hasSize) sizeSet.add(targetId)
}
function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeSet: Set<string> } {
const modified = new Set<string>()
const sizeSet = new Set<string>()
const visibleSiblingCount = new Map<string, number>()
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 || (ctx.activeNodeIds && !ctx.activeNodeIds.has(nodeId))) continue
for (const d of derived) applyDsdOverride(ctx, visibleSiblingCount, nodeId, d, modified, sizeSet)
}
return { modified, sizeSet }
}
export function applyDerivedSymbolData(ctx: OverrideContext): void {
const { modified, sizeSet } = resolveDsdUpdates(ctx)
propagateDsdChanges(ctx, modified, sizeSet)
}

View file

@ -0,0 +1,69 @@
import { convertLetterSpacing, convertLineHeight } from '#core/kiwi/node-change/convert'
import type { DerivedSymbolOverride, OverrideContext } from '#core/kiwi/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { resolveDsdGeometry } from './geometry'
function getVisibleSiblingCount(ctx: OverrideContext, cache: Map<string, number>, parentId: string): number {
const cached = cache.get(parentId)
if (cached !== undefined) return cached
const count = ctx.graph.getChildren(parentId).filter((child) => child.visible).length
cache.set(parentId, count)
return count
}
function resolveSizeOnlyPosition(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
node: SceneNode
): Pick<SceneNode, 'x' | 'y'> | null {
if (!node.parentId || getVisibleSiblingCount(ctx, visibleSiblingCount, node.parentId) !== 1 || !node.componentId) return null
const source = ctx.graph.getNode(node.componentId)
if (!source) return null
const sourceParent = source.parentId ? ctx.graph.getNode(source.parentId) : null
if (!sourceParent) return { x: source.x, y: source.y }
const withinParent = source.x >= 0 && source.y >= 0 && source.x + source.width <= sourceParent.width + 0.01 && source.y + source.height <= sourceParent.height + 0.01
return withinParent ? { x: source.x, y: source.y } : { x: 0, y: 0 }
}
function buildDsdTextUpdates(d: DerivedSymbolOverride): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
if (d.fontSize !== undefined) updates.fontSize = d.fontSize
if (d.lineHeight !== undefined) updates.lineHeight = convertLineHeight(d.lineHeight, d.fontSize)
if (d.letterSpacing !== undefined) updates.letterSpacing = convertLetterSpacing(d.letterSpacing, d.fontSize)
return updates
}
export function buildDsdLayoutUpdates(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
d: DerivedSymbolOverride,
target: SceneNode
): { updates: Partial<SceneNode>; hasSize: boolean } {
const updates: Partial<SceneNode> = buildDsdTextUpdates(d)
const figmaDerivedLayout: NonNullable<SceneNode['figmaDerivedLayout']> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
figmaDerivedLayout.width = d.size.x
figmaDerivedLayout.height = d.size.y
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
figmaDerivedLayout.x = d.transform.m02
figmaDerivedLayout.y = d.transform.m12
} else if (d.size) {
const position = resolveSizeOnlyPosition(ctx, visibleSiblingCount, target)
if (position) {
updates.x = position.x
updates.y = position.y
figmaDerivedLayout.x = position.x
figmaDerivedLayout.y = position.y
}
}
if (Object.keys(figmaDerivedLayout).length > 0) updates.figmaDerivedLayout = figmaDerivedLayout
Object.assign(updates, resolveDsdGeometry(d, target, ctx.blobs))
return { updates, hasSize: d.size !== undefined }
}

View file

@ -0,0 +1,45 @@
import { buildClonesMap } from '#core/kiwi/instance-overrides/sync'
import type { OverrideContext } from '#core/kiwi/instance-overrides/types'
import type { SceneNode } from '#core/scene-graph'
import { copyGeometryPaths } from '#core/scene-graph/copy'
export function propagateDsdChanges(
ctx: OverrideContext,
modified: Set<string>,
sizeSet: Set<string>
): void {
if (modified.size === 0) return
const clonesOf = buildClonesMap(ctx.graph, ctx.activeNodeIds)
const queue = [...modified]
const visited = new Set<string>()
let index = 0
while (index < queue.length) {
const sourceId = queue[index]
index++
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 (!ctx.geometryOverrideNodes.has(cloneId)) {
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)
}
}
}

View file

@ -1,258 +0,0 @@
import {
convertLetterSpacing,
convertLineHeight,
resolveGeometryPaths
} from '#core/kiwi/node-change/convert'
import { applyOverridePatch } from '#core/kiwi/instance-overrides/patches'
import type { SceneNode, GeometryPath } from '#core/scene-graph'
import { copyGeometryPaths } from '#core/scene-graph/copy'
import { resolveOverrideTarget } from './resolve'
import { buildClonesMap } from './sync'
import type { OverrideContext, DerivedSymbolOverride } from './types'
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
let coords = -1
if (cmd === 1 || cmd === 2) coords = 1
else if (cmd === 4) coords = 3
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 getVisibleSiblingCount(
ctx: OverrideContext,
cache: Map<string, number>,
parentId: string
): number {
const cached = cache.get(parentId)
if (cached !== undefined) return cached
const count = ctx.graph.getChildren(parentId).filter((child) => child.visible).length
cache.set(parentId, count)
return count
}
function hasSingleVisibleSibling(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
node: SceneNode
): boolean {
if (!node.parentId) return false
return getVisibleSiblingCount(ctx, visibleSiblingCount, node.parentId) === 1
}
function resolveSizeOnlyPosition(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
node: SceneNode
): Pick<SceneNode, 'x' | 'y'> | null {
if (!hasSingleVisibleSibling(ctx, visibleSiblingCount, node) || !node.componentId) return null
const source = ctx.graph.getNode(node.componentId)
if (!source) return null
const sourceParent = source.parentId ? ctx.graph.getNode(source.parentId) : null
if (!sourceParent) return { x: source.x, y: source.y }
const withinParent =
source.x >= 0 &&
source.y >= 0 &&
source.x + source.width <= sourceParent.width + 0.01 &&
source.y + source.height <= sourceParent.height + 0.01
return withinParent ? { x: source.x, y: source.y } : { x: 0, y: 0 }
}
function buildDsdTextUpdates(d: DerivedSymbolOverride): Partial<SceneNode> {
const updates: Partial<SceneNode> = {}
if (d.fontSize !== undefined) updates.fontSize = d.fontSize
if (d.lineHeight !== undefined) updates.lineHeight = convertLineHeight(d.lineHeight, d.fontSize)
if (d.letterSpacing !== undefined) {
updates.letterSpacing = convertLetterSpacing(d.letterSpacing, d.fontSize)
}
return updates
}
function buildDsdLayoutUpdates(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
d: DerivedSymbolOverride,
target: SceneNode
): { updates: Partial<SceneNode>; hasSize: boolean } {
const updates: Partial<SceneNode> = buildDsdTextUpdates(d)
const figmaDerivedLayout: NonNullable<SceneNode['figmaDerivedLayout']> = {}
if (d.size) {
updates.width = d.size.x
updates.height = d.size.y
figmaDerivedLayout.width = d.size.x
figmaDerivedLayout.height = d.size.y
}
if (d.transform) {
updates.x = d.transform.m02
updates.y = d.transform.m12
figmaDerivedLayout.x = d.transform.m02
figmaDerivedLayout.y = d.transform.m12
} else if (d.size) {
const position = resolveSizeOnlyPosition(ctx, visibleSiblingCount, target)
if (position) {
updates.x = position.x
updates.y = position.y
figmaDerivedLayout.x = position.x
figmaDerivedLayout.y = position.y
}
}
if (Object.keys(figmaDerivedLayout).length > 0) {
updates.figmaDerivedLayout = figmaDerivedLayout
}
Object.assign(updates, resolveDsdGeometry(d, target, ctx.blobs))
return { updates, hasSize: d.size !== undefined }
}
function applyDsdOverride(
ctx: OverrideContext,
visibleSiblingCount: Map<string, number>,
nodeId: string,
d: DerivedSymbolOverride,
modified: Set<string>,
sizeSet: Set<string>
): void {
const guids = d.guidPath?.guids
if (!guids?.length) return
const targetId = resolveOverrideTarget(ctx, nodeId, guids)
if (!targetId) return
const target = ctx.graph.getNode(targetId)
if (!target) return
const { updates, hasSize } = buildDsdLayoutUpdates(ctx, visibleSiblingCount, d, target)
if (d.fillGeometry?.length || d.strokeGeometry?.length) ctx.geometryOverrideNodes.add(targetId)
if (Object.keys(updates).length === 0) return
if (applyOverridePatch(ctx, { targetId, source: 'derived-symbol-data', props: updates })) {
modified.add(targetId)
}
if (hasSize) sizeSet.add(targetId)
}
function resolveDsdUpdates(ctx: OverrideContext): { modified: Set<string>; sizeSet: Set<string> } {
const modified = new Set<string>()
const sizeSet = new Set<string>()
const visibleSiblingCount = new Map<string, number>()
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 || (ctx.activeNodeIds && !ctx.activeNodeIds.has(nodeId))) continue
for (const d of derived) {
applyDsdOverride(ctx, visibleSiblingCount, nodeId, d, modified, sizeSet)
}
}
return { modified, sizeSet }
}
function propagateDsdChanges(
ctx: OverrideContext,
modified: Set<string>,
sizeSet: Set<string>
): void {
if (modified.size === 0) return
const clonesOf = buildClonesMap(ctx.graph, ctx.activeNodeIds)
const queue = [...modified]
const visited = new Set<string>()
let index = 0
while (index < queue.length) {
const sourceId = queue[index]
index++
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 (!ctx.geometryOverrideNodes.has(cloneId)) {
if (source.fillGeometry !== clone.fillGeometry)
cu.fillGeometry = copyGeometryPaths(source.fillGeometry)
if (source.strokeGeometry !== clone.strokeGeometry)
cu.strokeGeometry = copyGeometryPaths(source.strokeGeometry)
}
if (Object.keys(cu).length > 0) ctx.graph.updateNode(cloneId, cu)
}
queue.push(cloneId)
}
}
}
/**
* Apply derivedSymbolData — Figma's pre-computed sizes, positions,
* and geometry for instance overrides. Runs last in the pipeline.
*/
export function applyDerivedSymbolData(ctx: OverrideContext): void {
const { modified, sizeSet } = resolveDsdUpdates(ctx)
propagateDsdChanges(ctx, modified, sizeSet)
}

View file

@ -15,9 +15,9 @@ import type { SceneGraph } from '#core/scene-graph'
import { copyFills } from '#core/scene-graph/copy'
import { applyConstraintScaling } from './constraints'
import { applyDerivedSymbolData } from './dsd'
import { applyDerivedSymbolData } from './derived-symbol-data'
import { populateInstances } from './populate'
import { applyComponentProperties } from './props'
import { applyComponentProperties } from './component-props'
import { preComputeRoots } from './resolve'
import { applySymbolOverrides } from './symbol/overrides'
import { propagateOverridesTransitively } from './sync'

View file

@ -1,311 +0,0 @@
import type { ProtectionMap, ProtectedField } from '#core/kiwi/instance-overrides/patches'
import { isFieldProtected } from '#core/kiwi/instance-overrides/patches'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '#core/scene-graph/copy'
/**
* Copy appearance props from source to target (text, visibility, fills, etc.).
* Only writes properties that actually differ.
*/
function canSync(
protections: ProtectionMap | undefined,
targetId: string,
field: ProtectedField
): boolean {
return !isFieldProtected(protections, targetId, field)
}
type SyncFn = (
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>,
protections?: ProtectionMap
) => void
type DirectSyncKey = 'text' | 'visible' | 'opacity' | 'locked' | 'layoutGrow' | 'textAutoResize'
type CopiedSyncKey = 'fills' | 'strokes' | 'effects' | 'styleRuns'
function assignDirectUpdate(
key: DirectSyncKey,
source: SceneNode,
updates: Partial<SceneNode>
): void {
switch (key) {
case 'text':
updates.text = source.text
break
case 'visible':
updates.visible = source.visible
break
case 'opacity':
updates.opacity = source.opacity
break
case 'locked':
updates.locked = source.locked
break
case 'layoutGrow':
updates.layoutGrow = source.layoutGrow
break
case 'textAutoResize':
updates.textAutoResize = source.textAutoResize
break
}
}
function directSync(key: DirectSyncKey, field: ProtectedField): SyncFn {
return (source, target, updates, protections) => {
if (source[key] !== target[key] && canSync(protections, target.id, field)) {
assignDirectUpdate(key, source, updates)
}
}
}
const DIRECT_SYNCERS: SyncFn[] = [
directSync('text', 'text'),
directSync('visible', 'visible'),
directSync('opacity', 'opacity'),
directSync('locked', 'locked'),
directSync('layoutGrow', 'layoutGrow'),
directSync('textAutoResize', 'textAutoResize')
]
function syncDirectFields(
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>,
protections?: ProtectionMap
): void {
for (const sync of DIRECT_SYNCERS) sync(source, target, updates, protections)
}
function assignCopiedUpdate(
key: CopiedSyncKey,
source: SceneNode,
updates: Partial<SceneNode>
): void {
switch (key) {
case 'fills':
updates.fills = copyFills(source.fills)
break
case 'strokes':
updates.strokes = copyStrokes(source.strokes)
break
case 'effects':
updates.effects = copyEffects(source.effects)
break
case 'styleRuns':
updates.styleRuns = copyStyleRuns(source.styleRuns)
break
}
}
function copiedSync(key: CopiedSyncKey, field: ProtectedField): SyncFn {
return (source, target, updates, protections) => {
if (source[key] !== target[key] && canSync(protections, target.id, field)) {
assignCopiedUpdate(key, source, updates)
}
}
}
const COPIED_SYNCERS: SyncFn[] = [
copiedSync('fills', 'fills'),
copiedSync('strokes', 'strokes'),
copiedSync('effects', 'effects'),
copiedSync('styleRuns', 'styleRuns')
]
function syncCopiedFields(
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>,
protections?: ProtectionMap
): void {
for (const sync of COPIED_SYNCERS) sync(source, target, updates, protections)
}
export function syncNodeProps(
graph: SceneGraph,
source: SceneNode,
target: SceneNode,
protections?: ProtectionMap
): void {
const updates: Partial<SceneNode> = {}
syncDirectFields(source, target, updates, protections)
syncCopiedFields(source, target, updates, protections)
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>,
protections?: ProtectionMap
): 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, protections)
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>,
protections?: ProtectionMap
): 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, protections)
continue
}
syncNodeProps(graph, srcNode, tgtNode, protections)
syncChildrenDeep(graph, src.childIds[i], tgt.childIds[i], swappedInstances, skip, protections)
}
}
/** Build a map of componentId → list of clone node IDs. */
export function buildClonesMap(
graph: SceneGraph,
activeNodeIds?: Set<string>
): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
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>,
protect?: Set<string>,
activeNodeIds?: Set<string>,
protections?: ProtectionMap
): void {
if (seeds.size === 0) return
componentIdRoot.clear()
const clonesOf = buildClonesMap(graph, activeNodeIds)
const expandedSeeds = expandSeedsToParents(graph, seeds)
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
// Merge seeds + protect into a single skip set for syncChildrenDeep
const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]
let index = 0
while (index < syncQueue.length) {
const sourceId = syncQueue[index]
index++
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 (skip.has(cloneId)) {
syncQueue.push(cloneId)
continue
}
syncNodeProps(graph, source, node, protections)
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, skip, protections)
}
syncQueue.push(cloneId)
}
}
}

View file

@ -0,0 +1,71 @@
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { syncNodeProps } from './fields'
export function recloneChildren(
graph: SceneGraph,
srcChildId: string,
tgtNode: SceneNode,
swappedInstances: Set<string>,
protections?: ProtectionMap
): 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, protections)
if (srcChild.childIds.length > 0) graph.populateInstanceChildren(tgtNode.id, srcChildId)
swappedInstances.add(tgtNode.id)
}
export function syncChildrenDeep(
graph: SceneGraph,
sourceId: string,
targetId: string,
swappedInstances: Set<string>,
skip?: Set<string>,
protections?: ProtectionMap
): 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, protections)
continue
}
syncNodeProps(graph, srcNode, tgtNode, protections)
syncChildrenDeep(graph, src.childIds[i], tgt.childIds[i], swappedInstances, skip, protections)
}
}
export function buildClonesMap(
graph: SceneGraph,
activeNodeIds?: Set<string>
): Map<string, string[]> {
const clonesOf = new Map<string, string[]>()
for (const node of graph.getAllNodes()) {
if (activeNodeIds && !activeNodeIds.has(node.id)) continue
if (!node.componentId) continue
let arr = clonesOf.get(node.componentId)
if (!arr) {
arr = []
clonesOf.set(node.componentId, arr)
}
arr.push(node.id)
}
return clonesOf
}

View file

@ -0,0 +1,103 @@
import type { ProtectionMap, ProtectedField } from '#core/kiwi/instance-overrides/patches'
import { isFieldProtected } from '#core/kiwi/instance-overrides/patches'
import type { SceneGraph, SceneNode } from '#core/scene-graph'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns } from '#core/scene-graph/copy'
function canSync(
protections: ProtectionMap | undefined,
targetId: string,
field: ProtectedField
): boolean {
return !isFieldProtected(protections, targetId, field)
}
type SyncFn = (
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>,
protections?: ProtectionMap
) => void
type DirectSyncKey = 'text' | 'visible' | 'opacity' | 'locked' | 'layoutGrow' | 'textAutoResize'
type CopiedSyncKey = 'fills' | 'strokes' | 'effects' | 'styleRuns'
function assignDirectUpdate(
key: DirectSyncKey,
source: SceneNode,
updates: Partial<SceneNode>
): void {
switch (key) {
case 'text': updates.text = source.text; break
case 'visible': updates.visible = source.visible; break
case 'opacity': updates.opacity = source.opacity; break
case 'locked': updates.locked = source.locked; break
case 'layoutGrow': updates.layoutGrow = source.layoutGrow; break
case 'textAutoResize': updates.textAutoResize = source.textAutoResize; break
}
}
function directSync(key: DirectSyncKey, field: ProtectedField): SyncFn {
return (source, target, updates, protections) => {
if (source[key] !== target[key] && canSync(protections, target.id, field)) {
assignDirectUpdate(key, source, updates)
}
}
}
const DIRECT_SYNCERS: SyncFn[] = [
directSync('text', 'text'),
directSync('visible', 'visible'),
directSync('opacity', 'opacity'),
directSync('locked', 'locked'),
directSync('layoutGrow', 'layoutGrow'),
directSync('textAutoResize', 'textAutoResize')
]
function assignCopiedUpdate(
key: CopiedSyncKey,
source: SceneNode,
updates: Partial<SceneNode>
): void {
switch (key) {
case 'fills': updates.fills = copyFills(source.fills); break
case 'strokes': updates.strokes = copyStrokes(source.strokes); break
case 'effects': updates.effects = copyEffects(source.effects); break
case 'styleRuns': updates.styleRuns = copyStyleRuns(source.styleRuns); break
}
}
function copiedSync(key: CopiedSyncKey, field: ProtectedField): SyncFn {
return (source, target, updates, protections) => {
if (source[key] !== target[key] && canSync(protections, target.id, field)) {
assignCopiedUpdate(key, source, updates)
}
}
}
const COPIED_SYNCERS: SyncFn[] = [
copiedSync('fills', 'fills'),
copiedSync('strokes', 'strokes'),
copiedSync('effects', 'effects'),
copiedSync('styleRuns', 'styleRuns')
]
function syncFields(
source: SceneNode,
target: SceneNode,
updates: Partial<SceneNode>,
protections?: ProtectionMap
): void {
for (const sync of DIRECT_SYNCERS) sync(source, target, updates, protections)
for (const sync of COPIED_SYNCERS) sync(source, target, updates, protections)
}
export function syncNodeProps(
graph: SceneGraph,
source: SceneNode,
target: SceneNode,
protections?: ProtectionMap
): void {
const updates: Partial<SceneNode> = {}
syncFields(source, target, updates, protections)
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
}

View file

@ -0,0 +1,3 @@
export { syncNodeProps } from './fields'
export { buildClonesMap, recloneChildren, syncChildrenDeep } from './clones'
export { propagateOverridesTransitively } from './propagate'

View file

@ -0,0 +1,88 @@
import type { ProtectionMap } from '#core/kiwi/instance-overrides/patches'
import type { SceneGraph } from '#core/scene-graph'
import { buildClonesMap, syncChildrenDeep } from './clones'
import { syncNodeProps } from './fields'
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
}
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
}
export function propagateOverridesTransitively(
graph: SceneGraph,
seeds: Set<string>,
swappedInstances: Set<string>,
componentIdRoot: Map<string, string>,
protect?: Set<string>,
activeNodeIds?: Set<string>,
protections?: ProtectionMap
): void {
if (seeds.size === 0) return
componentIdRoot.clear()
const clonesOf = buildClonesMap(graph, activeNodeIds)
const expandedSeeds = expandSeedsToParents(graph, seeds)
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
const skip = protect && protect.size > 0 ? new Set([...seeds, ...protect]) : seeds
const visited = new Set<string>()
const syncQueue = [...expandedSeeds]
let index = 0
while (index < syncQueue.length) {
const sourceId = syncQueue[index]
index++
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 (skip.has(cloneId)) {
syncQueue.push(cloneId)
continue
}
syncNodeProps(graph, source, node, protections)
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, skip, protections)
}
syncQueue.push(cloneId)
}
}
}