From 22991daf770fec4203cb4e95379dbff97e0ce975 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 18 May 2026 00:36:37 +0300 Subject: [PATCH] refactor(fig): split override sync modules --- .../{props.ts => component-props/index.ts} | 6 +- .../derived-symbol-data/geometry.ts | 48 +++ .../derived-symbol-data/index.ts | 57 ++++ .../derived-symbol-data/layout.ts | 69 ++++ .../derived-symbol-data/propagate.ts | 45 +++ .../core/src/kiwi/instance-overrides/dsd.ts | 258 --------------- .../core/src/kiwi/instance-overrides/index.ts | 4 +- .../core/src/kiwi/instance-overrides/sync.ts | 311 ------------------ .../kiwi/instance-overrides/sync/clones.ts | 71 ++++ .../kiwi/instance-overrides/sync/fields.ts | 103 ++++++ .../src/kiwi/instance-overrides/sync/index.ts | 3 + .../kiwi/instance-overrides/sync/propagate.ts | 88 +++++ 12 files changed, 489 insertions(+), 574 deletions(-) rename packages/core/src/kiwi/instance-overrides/{props.ts => component-props/index.ts} (79%) create mode 100644 packages/core/src/kiwi/instance-overrides/derived-symbol-data/geometry.ts create mode 100644 packages/core/src/kiwi/instance-overrides/derived-symbol-data/index.ts create mode 100644 packages/core/src/kiwi/instance-overrides/derived-symbol-data/layout.ts create mode 100644 packages/core/src/kiwi/instance-overrides/derived-symbol-data/propagate.ts delete mode 100644 packages/core/src/kiwi/instance-overrides/dsd.ts delete mode 100644 packages/core/src/kiwi/instance-overrides/sync.ts create mode 100644 packages/core/src/kiwi/instance-overrides/sync/clones.ts create mode 100644 packages/core/src/kiwi/instance-overrides/sync/fields.ts create mode 100644 packages/core/src/kiwi/instance-overrides/sync/index.ts create mode 100644 packages/core/src/kiwi/instance-overrides/sync/propagate.ts diff --git a/packages/core/src/kiwi/instance-overrides/props.ts b/packages/core/src/kiwi/instance-overrides/component-props/index.ts similarity index 79% rename from packages/core/src/kiwi/instance-overrides/props.ts rename to packages/core/src/kiwi/instance-overrides/component-props/index.ts index 191aaa7b5..a6ec92b03 100644 --- a/packages/core/src/kiwi/instance-overrides/props.ts +++ b/packages/core/src/kiwi/instance-overrides/component-props/index.ts @@ -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). diff --git a/packages/core/src/kiwi/instance-overrides/derived-symbol-data/geometry.ts b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/geometry.ts new file mode 100644 index 000000000..e3ced779b --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/geometry.ts @@ -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, '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 +} diff --git a/packages/core/src/kiwi/instance-overrides/derived-symbol-data/index.ts b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/index.ts new file mode 100644 index 000000000..502cdd83a --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/index.ts @@ -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, + nodeId: string, + d: DerivedSymbolOverride, + modified: Set, + sizeSet: Set +): 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; sizeSet: Set } { + const modified = new Set() + const sizeSet = new Set() + const visibleSiblingCount = new Map() + + 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) +} diff --git a/packages/core/src/kiwi/instance-overrides/derived-symbol-data/layout.ts b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/layout.ts new file mode 100644 index 000000000..96c0acb52 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/layout.ts @@ -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, 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, + node: SceneNode +): Pick | 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 { + const updates: Partial = {} + 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, + d: DerivedSymbolOverride, + target: SceneNode +): { updates: Partial; hasSize: boolean } { + const updates: Partial = buildDsdTextUpdates(d) + const figmaDerivedLayout: NonNullable = {} + + 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 } +} diff --git a/packages/core/src/kiwi/instance-overrides/derived-symbol-data/propagate.ts b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/propagate.ts new file mode 100644 index 000000000..5e44ea94a --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/derived-symbol-data/propagate.ts @@ -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, + sizeSet: Set +): void { + if (modified.size === 0) return + + const clonesOf = buildClonesMap(ctx.graph, ctx.activeNodeIds) + const queue = [...modified] + const visited = new Set() + + 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 = {} + 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) + } + } +} diff --git a/packages/core/src/kiwi/instance-overrides/dsd.ts b/packages/core/src/kiwi/instance-overrides/dsd.ts deleted file mode 100644 index 44d7efecd..000000000 --- a/packages/core/src/kiwi/instance-overrides/dsd.ts +++ /dev/null @@ -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, '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 getVisibleSiblingCount( - ctx: OverrideContext, - cache: Map, - 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, - node: SceneNode -): boolean { - if (!node.parentId) return false - return getVisibleSiblingCount(ctx, visibleSiblingCount, node.parentId) === 1 -} - -function resolveSizeOnlyPosition( - ctx: OverrideContext, - visibleSiblingCount: Map, - node: SceneNode -): Pick | 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 { - const updates: Partial = {} - 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, - d: DerivedSymbolOverride, - target: SceneNode -): { updates: Partial; hasSize: boolean } { - const updates: Partial = buildDsdTextUpdates(d) - const figmaDerivedLayout: NonNullable = {} - - 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, - nodeId: string, - d: DerivedSymbolOverride, - modified: Set, - sizeSet: Set -): 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; sizeSet: Set } { - const modified = new Set() - const sizeSet = new Set() - const visibleSiblingCount = new Map() - - 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, - sizeSet: Set -): void { - if (modified.size === 0) return - - const clonesOf = buildClonesMap(ctx.graph, ctx.activeNodeIds) - const queue = [...modified] - const visited = new Set() - - 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 = {} - 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) -} diff --git a/packages/core/src/kiwi/instance-overrides/index.ts b/packages/core/src/kiwi/instance-overrides/index.ts index e86ea0fe6..972a9254b 100644 --- a/packages/core/src/kiwi/instance-overrides/index.ts +++ b/packages/core/src/kiwi/instance-overrides/index.ts @@ -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' diff --git a/packages/core/src/kiwi/instance-overrides/sync.ts b/packages/core/src/kiwi/instance-overrides/sync.ts deleted file mode 100644 index c4b7a2948..000000000 --- a/packages/core/src/kiwi/instance-overrides/sync.ts +++ /dev/null @@ -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, - 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 -): 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, - protections?: ProtectionMap -): void { - for (const sync of DIRECT_SYNCERS) sync(source, target, updates, protections) -} - -function assignCopiedUpdate( - key: CopiedSyncKey, - source: SceneNode, - updates: Partial -): 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, - 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 = {} - 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, - 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, - skip?: Set, - 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 -): Map { - const clonesOf = new Map() - 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): 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, - protect?: Set, - activeNodeIds?: Set, - 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() - 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) - } - } -} diff --git a/packages/core/src/kiwi/instance-overrides/sync/clones.ts b/packages/core/src/kiwi/instance-overrides/sync/clones.ts new file mode 100644 index 000000000..de8f9dfd3 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/sync/clones.ts @@ -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, + 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, + skip?: Set, + 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 +): Map { + const clonesOf = new Map() + 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 +} diff --git a/packages/core/src/kiwi/instance-overrides/sync/fields.ts b/packages/core/src/kiwi/instance-overrides/sync/fields.ts new file mode 100644 index 000000000..438198e1d --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/sync/fields.ts @@ -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, + 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 +): 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 +): 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, + 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 = {} + syncFields(source, target, updates, protections) + if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates) +} diff --git a/packages/core/src/kiwi/instance-overrides/sync/index.ts b/packages/core/src/kiwi/instance-overrides/sync/index.ts new file mode 100644 index 000000000..74de0c4a1 --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/sync/index.ts @@ -0,0 +1,3 @@ +export { syncNodeProps } from './fields' +export { buildClonesMap, recloneChildren, syncChildrenDeep } from './clones' +export { propagateOverridesTransitively } from './propagate' diff --git a/packages/core/src/kiwi/instance-overrides/sync/propagate.ts b/packages/core/src/kiwi/instance-overrides/sync/propagate.ts new file mode 100644 index 000000000..0abe7fa6b --- /dev/null +++ b/packages/core/src/kiwi/instance-overrides/sync/propagate.ts @@ -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): 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 +} + +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 +} + +export function propagateOverridesTransitively( + graph: SceneGraph, + seeds: Set, + swappedInstances: Set, + componentIdRoot: Map, + protect?: Set, + activeNodeIds?: Set, + 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() + 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) + } + } +}