From eb7a663c1c94af8b3981c1ee7f2973cf7e622de2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 18 May 2026 19:50:34 +0300 Subject: [PATCH] fix(fig): preserve scene node defaults during overrides --- packages/core/src/io/formats/fig/export.ts | 2 +- .../kiwi/instance-overrides/symbol/props.ts | 4 +-- .../core/src/kiwi/node-change/export-node.ts | 2 +- .../core/src/kiwi/node-change/serialize.ts | 8 ++--- packages/core/src/scene-graph/index.ts | 5 ++++ .../render/canvas/effects/ordering.test.ts | 30 ++++++++----------- 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index 08c96b8a0..6aed44963 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -209,7 +209,7 @@ export async function exportFigFile( if (page.internalOnly) canvasNc.internalOnly = true nodeChanges.push(canvasNc) - const children = graph.getChildren(page.id) + const children = graph.getChildren(page.id).filter((child) => !child.internalOnly) for (let i = 0; i < children.length; i++) { nodeChanges.push( ...sceneNodeToKiwi( diff --git a/packages/core/src/kiwi/instance-overrides/symbol/props.ts b/packages/core/src/kiwi/instance-overrides/symbol/props.ts index 7c3df8546..c27fd3cce 100644 --- a/packages/core/src/kiwi/instance-overrides/symbol/props.ts +++ b/packages/core/src/kiwi/instance-overrides/symbol/props.ts @@ -87,8 +87,8 @@ function applyOverrideLayout(ov: Record, updates: Partial, updates: Partial): void { - if (ov.strokeWeight != null && !ov.strokePaints) { - updates.strokes = updates.strokes ?? [] + if (ov.strokeWeight != null && !ov.strokePaints && updates.strokes) { + for (const stroke of updates.strokes) stroke.weight = ov.strokeWeight as number } if (ov.strokeAlign != null && updates.strokes) { let align: 'INSIDE' | 'OUTSIDE' | 'CENTER' = 'CENTER' diff --git a/packages/core/src/kiwi/node-change/export-node.ts b/packages/core/src/kiwi/node-change/export-node.ts index c3c340961..0f62e4fef 100644 --- a/packages/core/src/kiwi/node-change/export-node.ts +++ b/packages/core/src/kiwi/node-change/export-node.ts @@ -197,7 +197,7 @@ export function sceneNodeToKiwiWithContext( } const result: KiwiNodeChange[] = [nc] - const children = context.graph.getChildren(node.id) + const children = context.graph.getChildren(node.id).filter((child) => !child.internalOnly) for (let i = 0; i < children.length; i++) { result.push(...context.sceneNodeToKiwi(children[i], guid, i, localIdCounter, context)) } diff --git a/packages/core/src/kiwi/node-change/serialize.ts b/packages/core/src/kiwi/node-change/serialize.ts index 92dac7333..c22fe9f7d 100644 --- a/packages/core/src/kiwi/node-change/serialize.ts +++ b/packages/core/src/kiwi/node-change/serialize.ts @@ -190,8 +190,8 @@ function exportTextData(node: SceneNode): NodeChange['textData'] { } } -export function safeColor(c: Color): Color { - return { r: c.r, g: c.g, b: c.b, a: c.a } +export function safeColor(c: Color | Omit): Color { + return { r: c.r, g: c.g, b: c.b, a: 'a' in c ? c.a : 1 } } function fillToKiwiPaint(f: SceneNode['fills'][number]): Paint { @@ -266,7 +266,7 @@ function serializeTextProps( nc.textAutoResize = autoResize nc.textAlignHorizontal = node.textAlignHorizontal nc.textAlignVertical = 'TOP' - nc.textUserLayoutVersion = 5 + nc.textUserLayoutVersion = 4 nc.textExplicitLayoutVersion = 1 nc.textBidiVersion = 1 nc.textDecorationSkipInk = true @@ -275,7 +275,7 @@ function serializeTextProps( nc.fontVersion = '' nc.emojiImageSet = 'APPLE' if (fontDigestMap) nc.derivedTextData = buildDerivedTextData(node, fontDigestMap, blobs) - nc.lineHeight = node.lineHeight != null ? { value: node.lineHeight, units: 'PIXELS' } : { value: 100, units: 'PERCENT' } + if (node.lineHeight != null) nc.lineHeight = { value: node.lineHeight, units: 'PIXELS' } nc.letterSpacing = { value: node.letterSpacing, units: 'PIXELS' } if (node.textDecoration !== 'NONE') { nc.textDecoration = node.textDecoration === 'UNDERLINE' ? 'UNDERLINE' : 'STRIKETHROUGH' diff --git a/packages/core/src/scene-graph/index.ts b/packages/core/src/scene-graph/index.ts index 6b35e1848..4d74664ee 100644 --- a/packages/core/src/scene-graph/index.ts +++ b/packages/core/src/scene-graph/index.ts @@ -402,6 +402,11 @@ export class SceneGraph { if (node.textPicture && textChanged) node.textPicture = null if (node.figmaDerivedTextGlyphs && 'text' in changes) node.figmaDerivedTextGlyphs = null } + changes = Object.fromEntries( + (Object.entries(changes) as Array<[string, unknown]>).filter(([, value]) => + value !== undefined + ) + ) as Partial if (changes.vectorNetwork) { changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) } } diff --git a/tests/engine/render/canvas/effects/ordering.test.ts b/tests/engine/render/canvas/effects/ordering.test.ts index a9949432d..f9b72be10 100644 --- a/tests/engine/render/canvas/effects/ordering.test.ts +++ b/tests/engine/render/canvas/effects/ordering.test.ts @@ -3,7 +3,8 @@ import { describe, expect, mock, test } from 'bun:test' import type { Canvas } from 'canvaskit-wasm' import { renderShapeUncached } from '#core/canvas/scene' -import type { SceneGraph, SceneNode } from '#core/scene-graph' +import { createDefaultNode } from '#core/scene-graph/node-defaults' +import type { SceneGraph } from '#core/scene-graph' import { createMockCanvas, createMockRenderer } from './helpers' @@ -11,9 +12,7 @@ describe('Renderer effect ordering (Behavioral)', () => { test('drop shadow renders before fills', () => { const r = createMockRenderer() const canvas = createMockCanvas() - const node: Partial = { - id: 'node1', - type: 'RECTANGLE', + const node = createDefaultNode(() => 'node1', 'RECTANGLE', { width: 100, height: 100, effects: [ @@ -27,11 +26,10 @@ describe('Renderer effect ordering (Behavioral)', () => { } ], fills: [{ visible: true, type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1 }], - strokes: [], - strokeGeometry: [] - } + strokes: [] + }) const graph: Partial = { - getNode: mock(() => node as SceneNode) + getNode: mock(() => node) } const callOrder: string[] = [] @@ -42,7 +40,7 @@ describe('Renderer effect ordering (Behavioral)', () => { callOrder.push('drawNodeFill') }) - renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph) + renderShapeUncached(r, canvas as Canvas, node, graph as SceneGraph) expect(callOrder).toEqual(['renderEffects:behind', 'drawNodeFill', 'renderEffects:front']) }) @@ -50,9 +48,7 @@ describe('Renderer effect ordering (Behavioral)', () => { test('inner shadow and blur render after strokes', () => { const r = createMockRenderer() const canvas = createMockCanvas() - const node: Partial = { - id: 'node1', - type: 'RECTANGLE', + const node = createDefaultNode(() => 'node1', 'RECTANGLE', { width: 100, height: 100, fills: [], @@ -67,11 +63,10 @@ describe('Renderer effect ordering (Behavioral)', () => { spread: 0 } ], - strokes: [{ visible: true, weight: 1, opacity: 1 }], - strokeGeometry: [] - } + strokes: [{ visible: true, weight: 1, opacity: 1, color: { r: 0, g: 0, b: 0, a: 1 } }] + }) const graph: Partial = { - getNode: mock(() => node as SceneNode) + getNode: mock(() => node) } const callOrder: string[] = [] @@ -82,9 +77,8 @@ describe('Renderer effect ordering (Behavioral)', () => { callOrder.push('drawStrokeWithAlign') }) - renderShapeUncached(r, canvas as Canvas, node as SceneNode, graph as SceneGraph) + renderShapeUncached(r, canvas as Canvas, node, graph as SceneGraph) - // Strokes are rendered between behind and front effects const strokeIdx = callOrder.indexOf('drawStrokeWithAlign') const frontIdx = callOrder.indexOf('renderEffects:front') expect(strokeIdx).toBeGreaterThan(-1)