fix(fig): preserve scene node defaults during overrides

This commit is contained in:
Danila Poyarkov 2026-05-18 19:50:34 +03:00
parent 955aa4d1e9
commit eb7a663c1c
6 changed files with 25 additions and 26 deletions

View file

@ -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(

View file

@ -87,8 +87,8 @@ function applyOverrideLayout(ov: Record<string, unknown>, updates: Partial<Scene
}
function applyOverrideStrokes(ov: Record<string, unknown>, updates: Partial<SceneNode>): 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'

View file

@ -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))
}

View file

@ -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, 'a'>): 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'

View file

@ -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<SceneNode>
if (changes.vectorNetwork) {
changes = { ...changes, vectorNetwork: normalizeVectorNetwork(changes.vectorNetwork) }
}

View file

@ -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<SceneNode> = {
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<SceneGraph> = {
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<SceneNode> = {
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<SceneGraph> = {
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)