fix(fig): write bound fields resolved when exporting .fig (#859)
Figma draws the value a bound paint or number stores until something makes it resolve the variable again. OpenPencil resolves bound colours only when drawing, so exports wrote the colour from before the binding, and a graph bound without the editor kept stale numbers too: a file opened in Figma showed bound fills in their old colour. The exporter now writes each node with its bindings resolved, without changing the document: colours as the variable's RGB at full alpha with its alpha as the paint's opacity, which is how Figma stores them in every fixture, and numbers in scene units. Nodes without an explicit mode take the collection's default, because the mode the editor shows is not saved. The resolution moves into scene-graph as resolvedPaintBindings and resolvedNumericBindings, which the reader and reconcileNumericVariableBindings now share, and the node resolvers take a fallback of 'active' or 'default'.
This commit is contained in:
parent
5146e7f7e5
commit
4939cd4982
|
|
@ -46,6 +46,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Show variable-bound colours and numbers correctly when a `.fig` exported from OpenPencil opens in Figma. Figma draws the value a bound field stores until something makes it resolve the variable again, and exports stored the colour from before the binding, so a bound fill appeared in its old colour. Each bound field is now written as it resolves in its layer's mode, or in the collection's default mode when the layer sets none.
|
||||
- Open Figma files that use slots with each instance's own slot content instead of its component's default, keep slot properties, their settings, and instance content when saving back to `.fig`, and keep an instance's slot content when you switch its variant.
|
||||
- Stop showing a “signal is aborted without reason” error when you switch pages again before the previous page has finished loading.
|
||||
- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0.
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import { inflateSync, unzipSync } from 'fflate'
|
|||
import { exportFigFile, initCodec, SceneGraph } from '@open-pencil/core'
|
||||
import { parseFigKiwiContainer } from '@open-pencil/kiwi/fig/parse'
|
||||
import { ByteBuffer, compileSchema, decodeBinarySchema } from '@open-pencil/kiwi/schema-runtime'
|
||||
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||
import type { Color, GUID } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { expectDefined } from '#core-tests/helpers/assert'
|
||||
|
||||
interface DecodedPaint {
|
||||
color?: Color
|
||||
opacity?: number
|
||||
colorVar?: {
|
||||
value?: { alias?: { guid?: GUID } }
|
||||
dataType?: string
|
||||
|
|
@ -23,6 +25,7 @@ interface DecodedNodeChange {
|
|||
type?: string
|
||||
fillPaints?: DecodedPaint[]
|
||||
strokePaints?: DecodedPaint[]
|
||||
cornerRadius?: number
|
||||
}
|
||||
|
||||
function decodeNodeChanges(fig: Uint8Array): DecodedNodeChange[] {
|
||||
|
|
@ -92,4 +95,58 @@ describe('Figma export colour variable bindings', () => {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('writes bound fields resolved for each node\'s mode, as Figma draws the stored value', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const theme = graph.createCollection('Theme')
|
||||
const light = theme.defaultModeId
|
||||
graph.addMode(theme.id, 'dark', 'Dark')
|
||||
const surface = graph.createVariable('Surface', 'COLOR', theme.id, { r: 1, g: 1, b: 1, a: 1 })
|
||||
surface.valuesByMode.dark = { r: 0.07, g: 0.09, b: 0.15, a: 0.8 }
|
||||
const radius = graph.createVariable('Radius', 'FLOAT', theme.id, 12)
|
||||
radius.valuesByMode.dark = 16
|
||||
graph.setDefaultMode(theme.id, 'dark')
|
||||
// Stale literals, as a graph bound without the editor keeps them.
|
||||
const stale = { r: 1, g: 0, b: 0, a: 1 }
|
||||
const card = (name: string, variableModes: Record<string, string> = {}) => {
|
||||
const node = graph.createNode('FRAME', page.id, {
|
||||
name,
|
||||
width: 40,
|
||||
height: 40,
|
||||
cornerRadius: 0,
|
||||
variableModes,
|
||||
fills: [{ type: 'SOLID', color: stale, opacity: 1, visible: true }]
|
||||
})
|
||||
graph.bindVariable(node.id, 'fills/0/color', surface.id)
|
||||
graph.bindVariable(node.id, 'cornerRadius', radius.id)
|
||||
return node
|
||||
}
|
||||
const dark = card('Default mode')
|
||||
card('Light mode', { [theme.id]: light })
|
||||
|
||||
const nodeChanges = decodeNodeChanges(new Uint8Array(await exportFigFile(graph)))
|
||||
const exported = (name: string) =>
|
||||
expectDefined(
|
||||
nodeChanges.find((change) => change.name === name),
|
||||
name
|
||||
)
|
||||
|
||||
const defaultCard = exported('Default mode')
|
||||
expect(defaultCard.cornerRadius).toBe(16)
|
||||
expect(defaultCard.fillPaints?.[0]?.color).toEqual({
|
||||
r: expect.closeTo(0.07, 5),
|
||||
g: expect.closeTo(0.09, 5),
|
||||
b: expect.closeTo(0.15, 5),
|
||||
a: 1
|
||||
})
|
||||
expect(defaultCard.fillPaints?.[0]?.opacity).toBeCloseTo(0.8, 5)
|
||||
const lightCard = exported('Light mode')
|
||||
expect(lightCard.cornerRadius).toBe(12)
|
||||
expect(lightCard.fillPaints?.[0]?.color).toEqual({ r: 1, g: 1, b: 1, a: 1 })
|
||||
expect(lightCard.fillPaints?.[0]?.opacity).toBe(1)
|
||||
// Export reads the document; it does not resolve bindings into it.
|
||||
expect(graph.getNode(dark.id)?.fills[0]?.color).toEqual(stale)
|
||||
expect(graph.getNode(dark.id)?.cornerRadius).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,21 +1,4 @@
|
|||
import {
|
||||
copyFills,
|
||||
copyStrokes,
|
||||
type Color,
|
||||
type Fill,
|
||||
type SceneGraph,
|
||||
type SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
/**
|
||||
* A bound paint takes its whole color from the variable, alpha included. Drawing reads
|
||||
* that alpha from the paint's opacity rather than the color, so the variable owns both
|
||||
* and any opacity the record carried is a stale literal Figma would not apply either.
|
||||
*/
|
||||
function applyBoundColor(paint: Pick<Fill, 'color' | 'opacity'>, color: Color): void {
|
||||
paint.color = { ...color, a: 1 }
|
||||
paint.opacity = color.a
|
||||
}
|
||||
import { resolvedPaintBindings, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
/** Resolve bound paint colors only after occurrence hierarchy and modes are available. */
|
||||
export function applyDocumentPaintBindings(
|
||||
|
|
@ -23,27 +6,7 @@ export function applyDocumentPaintBindings(
|
|||
materialized: readonly SceneNode[]
|
||||
): void {
|
||||
for (const node of materialized) {
|
||||
// Most nodes bind nothing, so neither the entry array nor the change object is built
|
||||
// until a paint binding is actually found.
|
||||
let changes: Partial<SceneNode> | undefined
|
||||
for (const field in node.boundVariables) {
|
||||
if (!field.endsWith('/color')) continue
|
||||
const match = /^(fills|strokes)\/(\d+)\/color$/.exec(field)
|
||||
if (!match) continue
|
||||
const id = node.boundVariables[field]
|
||||
const kind = match[1] === 'fills' ? 'fills' : 'strokes'
|
||||
const index = Number(match[2])
|
||||
const color = graph.resolveColorVariableForNode(node.id, id)
|
||||
if (!color || !node[kind][index]) continue
|
||||
changes ??= {}
|
||||
if (kind === 'fills') {
|
||||
changes.fills ??= copyFills(node.fills)
|
||||
applyBoundColor(changes.fills[index], color)
|
||||
} else {
|
||||
changes.strokes ??= copyStrokes(node.strokes)
|
||||
applyBoundColor(changes.strokes[index], color)
|
||||
}
|
||||
}
|
||||
if (changes) graph.updateNode(node.id, changes)
|
||||
const changes = resolvedPaintBindings(graph, node)
|
||||
if (changes.fills || changes.strokes) graph.updateNode(node.id, changes)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
type SceneNodeToKiwiContext
|
||||
} from './context'
|
||||
import { mergeOverrides, serializeRuntimePropertyOverrides } from './override-claims'
|
||||
import { nodeWithResolvedBindings } from './resolved-bindings'
|
||||
import { slotContentAssignment, slotDefinitionFields } from './slots'
|
||||
|
||||
export type { KiwiNodeChange, SceneNodeToKiwiContext } from './context'
|
||||
|
|
@ -883,12 +884,13 @@ function exportKiwiNodeType(node: SceneNode, context: SceneNodeToKiwiContext): s
|
|||
}
|
||||
|
||||
export function sceneNodeToKiwiWithContext(
|
||||
node: SceneNode,
|
||||
source: SceneNode,
|
||||
parentGuid: GUID,
|
||||
childIndex: number,
|
||||
localIdCounter: { value: number },
|
||||
context: SceneNodeToKiwiContext
|
||||
): KiwiNodeChange[] {
|
||||
const node = nodeWithResolvedBindings(context.graph, source)
|
||||
const guid = getOrCreateNodeGuid(context, node.id, localIdCounter) ?? {
|
||||
sessionID: 1,
|
||||
localID: localIdCounter.value++
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
type SceneNodeToKiwiContext,
|
||||
type StyleReference
|
||||
} from './context'
|
||||
import { nodeWithResolvedBindings } from './resolved-bindings'
|
||||
|
||||
function exportedTextStyleReference(context: SceneNodeToKiwiContext, id: string): StyleReference {
|
||||
context.styleReferences ??= buildStyleReferences(context.graph)
|
||||
|
|
@ -138,11 +139,15 @@ function bindingClaim(
|
|||
}
|
||||
|
||||
function overrideClaim(
|
||||
input: ClaimInput,
|
||||
claimed: ClaimInput,
|
||||
field: string,
|
||||
path: GUID[],
|
||||
counter: { value: number }
|
||||
): KiwiSymbolOverridePayload | undefined {
|
||||
const input = {
|
||||
...claimed,
|
||||
target: nodeWithResolvedBindings(claimed.context.graph, claimed.target)
|
||||
}
|
||||
if (field === 'componentId')
|
||||
return exportedSwapOverride(input.context, input.target, path, counter)
|
||||
const claim = field.startsWith('boundVariables/')
|
||||
|
|
|
|||
21
packages/fig/src/node-change/export/resolved-bindings.ts
Normal file
21
packages/fig/src/node-change/export/resolved-bindings.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import {
|
||||
resolvedNumericBindings,
|
||||
resolvedPaintBindings,
|
||||
type SceneGraph,
|
||||
type SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
/**
|
||||
* Figma draws the value a bound field stores until something makes it resolve the binding
|
||||
* again, so each node is written with its bindings resolved for its mode. The editor resolves
|
||||
* colors only when drawing, and a graph bound without the editor keeps stale numbers too.
|
||||
* A node without an explicit mode takes its collection's default: the mode the editor happens
|
||||
* to show is not saved, and Figma opens the file in the default.
|
||||
*/
|
||||
export function nodeWithResolvedBindings(graph: SceneGraph, node: SceneNode): SceneNode {
|
||||
const changes = {
|
||||
...resolvedNumericBindings(graph, node, 'default'),
|
||||
...resolvedPaintBindings(graph, node, 'default')
|
||||
}
|
||||
return Object.keys(changes).length > 0 ? { ...node, ...changes } : node
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */
|
||||
export * from './mutation-impact'
|
||||
export * from './variables/bindings'
|
||||
export type { VariableModeFallback } from './variables'
|
||||
export { modesDefaultFirst } from './variables'
|
||||
export { rescaleNodeTree, scaleNodeChanges } from './scaling'
|
||||
import { TRANSFORM_FIELDS, SIZE_FIELDS } from './fields/geometry'
|
||||
|
|
@ -58,6 +59,7 @@ import { styleDetachmentChanges } from './shared-styles'
|
|||
import { markSourceFieldsEdited } from './source-metadata'
|
||||
import { GLYPH_AFFECTING_KEYS, invalidateTextCaches, TEXT_PICTURE_KEYS } from './text-picture'
|
||||
import * as Variables from './variables'
|
||||
import type { VariableModeFallback } from './variables'
|
||||
import { normalizeVectorNetwork } from './vector-network'
|
||||
|
||||
export type { GUID, Color, Size, Vector } from './primitives'
|
||||
|
|
@ -252,12 +254,20 @@ export class SceneGraph {
|
|||
return Variables.resolveNumberVariable(this, variableId)
|
||||
}
|
||||
|
||||
resolveColorVariableForNode(nodeId: string, variableId: string): Color | undefined {
|
||||
return Variables.resolveColorVariableForNode(this, nodeId, variableId)
|
||||
resolveColorVariableForNode(
|
||||
nodeId: string,
|
||||
variableId: string,
|
||||
fallback?: VariableModeFallback
|
||||
): Color | undefined {
|
||||
return Variables.resolveColorVariableForNode(this, nodeId, variableId, fallback)
|
||||
}
|
||||
|
||||
resolveNumberVariableForNode(nodeId: string, variableId: string): number | undefined {
|
||||
return Variables.resolveNumberVariableForNode(this, nodeId, variableId)
|
||||
resolveNumberVariableForNode(
|
||||
nodeId: string,
|
||||
variableId: string,
|
||||
fallback?: VariableModeFallback
|
||||
): number | undefined {
|
||||
return Variables.resolveNumberVariableForNode(this, nodeId, variableId, fallback)
|
||||
}
|
||||
|
||||
resolveStringVariableForNode(nodeId: string, variableId: string): string | undefined {
|
||||
|
|
|
|||
|
|
@ -135,10 +135,17 @@ export function getActiveModeId(graph: SceneGraph, collectionId: string): string
|
|||
return collection?.defaultModeId ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* What a node without an explicit mode falls back to: the mode the editor shows (`active`), or
|
||||
* the collection's default (`default`), which is what a saved document means to other tools.
|
||||
*/
|
||||
export type VariableModeFallback = 'active' | 'default'
|
||||
|
||||
export function getNodeVariableModeId(
|
||||
graph: SceneGraph,
|
||||
nodeId: string,
|
||||
collectionId: string
|
||||
collectionId: string,
|
||||
fallback: VariableModeFallback = 'active'
|
||||
): string {
|
||||
let node = graph.nodes.get(nodeId)
|
||||
while (node) {
|
||||
|
|
@ -146,6 +153,8 @@ export function getNodeVariableModeId(
|
|||
if (modeId) return modeId
|
||||
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
|
||||
}
|
||||
if (fallback === 'default')
|
||||
return graph.variableCollections.get(collectionId)?.defaultModeId ?? ''
|
||||
return getActiveModeId(graph, collectionId)
|
||||
}
|
||||
|
||||
|
|
@ -260,11 +269,12 @@ export function resolveNumberVariable(graph: SceneGraph, variableId: string): nu
|
|||
export function resolveColorVariableForNode(
|
||||
graph: SceneGraph,
|
||||
nodeId: string,
|
||||
variableId: string
|
||||
variableId: string,
|
||||
fallback: VariableModeFallback = 'active'
|
||||
): Color | undefined {
|
||||
const variable = graph.variables.get(variableId)
|
||||
if (!variable) return undefined
|
||||
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
|
||||
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId, fallback)
|
||||
const value = resolveVariable(graph, variableId, modeId)
|
||||
if (value && typeof value === 'object' && 'r' in value) return value
|
||||
return undefined
|
||||
|
|
@ -273,11 +283,12 @@ export function resolveColorVariableForNode(
|
|||
export function resolveNumberVariableForNode(
|
||||
graph: SceneGraph,
|
||||
nodeId: string,
|
||||
variableId: string
|
||||
variableId: string,
|
||||
fallback: VariableModeFallback = 'active'
|
||||
): number | undefined {
|
||||
const variable = graph.variables.get(variableId)
|
||||
if (!variable) return undefined
|
||||
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
|
||||
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId, fallback)
|
||||
const value = resolveVariable(graph, variableId, modeId)
|
||||
return typeof value === 'number' ? value : undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { copyFills, copyStrokes } from '../copy'
|
||||
import type { SceneGraph } from '../index'
|
||||
import { hasInstanceOverride } from '../instances'
|
||||
import type { SceneNode } from '../types'
|
||||
import type { Color } from '../primitives'
|
||||
import type { Fill, SceneNode, Stroke } from '../types'
|
||||
import type { VariableModeFallback } from '../variables'
|
||||
import { NUMERIC_FIELDS, isNumericVariableBindingField } from './fields'
|
||||
export { isNumericVariableBindingField } from './fields'
|
||||
|
||||
|
|
@ -29,23 +32,73 @@ export function assignVariableBindingUnits(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The values a node's numeric bindings resolve to in its mode, in scene units, for the fields
|
||||
* whose stored value differs. A width or height an instance overrides keeps its own value.
|
||||
*/
|
||||
export function resolvedNumericBindings(
|
||||
graph: SceneGraph,
|
||||
node: SceneNode,
|
||||
fallback: VariableModeFallback = 'active'
|
||||
): Partial<SceneNode> {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
for (const [field, variableId] of Object.entries(node.boundVariables)) {
|
||||
if (!NUMERIC_FIELDS.has(field)) continue
|
||||
if ((field === 'width' || field === 'height') && hasInstanceOverride(graph, node.id, field))
|
||||
continue
|
||||
const value = graph.resolveNumberVariableForNode(node.id, variableId, fallback)
|
||||
if (value === undefined || !Number.isFinite(value)) continue
|
||||
const scale = node.variableBindingScales[field] ?? 1
|
||||
const effective = value * scale
|
||||
if (!Number.isFinite(effective)) continue
|
||||
const next = field === 'opacity' ? Math.max(0, Math.min(1, effective)) : effective
|
||||
if (node[field as keyof SceneNode] !== next) Object.assign(updates, { [field]: next })
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound paint takes its whole color from the variable, alpha included, the way Figma stores
|
||||
* it: the variable's RGB at full alpha, and its alpha as the paint's opacity.
|
||||
*/
|
||||
function applyBoundColor(paint: Fill | Stroke, color: Color): void {
|
||||
paint.color = { ...color, a: 1 }
|
||||
paint.opacity = color.a
|
||||
}
|
||||
|
||||
/** The fills and strokes a node's color bindings resolve to in its mode, when it has any. */
|
||||
export function resolvedPaintBindings(
|
||||
graph: SceneGraph,
|
||||
node: SceneNode,
|
||||
fallback: VariableModeFallback = 'active'
|
||||
): Pick<Partial<SceneNode>, 'fills' | 'strokes'> {
|
||||
// Most nodes bind nothing, so no copy is made until a paint binding is actually found.
|
||||
const changes: Pick<Partial<SceneNode>, 'fills' | 'strokes'> = {}
|
||||
for (const field in node.boundVariables) {
|
||||
if (!field.endsWith('/color')) continue
|
||||
const match = /^(fills|strokes)\/(\d+)\/color$/.exec(field)
|
||||
if (!match) continue
|
||||
const color = graph.resolveColorVariableForNode(node.id, node.boundVariables[field], fallback)
|
||||
const index = Number(match[2])
|
||||
if (!color) continue
|
||||
if (match[1] === 'fills') {
|
||||
if (!node.fills[index]) continue
|
||||
changes.fills ??= copyFills(node.fills)
|
||||
applyBoundColor(changes.fills[index], color)
|
||||
} else {
|
||||
if (!node.strokes[index]) continue
|
||||
changes.strokes ??= copyStrokes(node.strokes)
|
||||
applyBoundColor(changes.strokes[index], color)
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/** Resolve live numeric bindings in scene units, without authoring instance overrides. */
|
||||
export function reconcileNumericVariableBindings(graph: SceneGraph): string[] {
|
||||
const changed: string[] = []
|
||||
for (const node of graph.getAllNodes()) {
|
||||
const updates: Partial<SceneNode> = {}
|
||||
for (const [field, variableId] of Object.entries(node.boundVariables)) {
|
||||
if (!NUMERIC_FIELDS.has(field)) continue
|
||||
if ((field === 'width' || field === 'height') && hasInstanceOverride(graph, node.id, field))
|
||||
continue
|
||||
const value = graph.resolveNumberVariableForNode(node.id, variableId)
|
||||
if (value === undefined || !Number.isFinite(value)) continue
|
||||
const scale = node.variableBindingScales[field] ?? 1
|
||||
const effective = value * scale
|
||||
if (!Number.isFinite(effective)) continue
|
||||
const next = field === 'opacity' ? Math.max(0, Math.min(1, effective)) : effective
|
||||
if (node[field as keyof SceneNode] !== next) Object.assign(updates, { [field]: next })
|
||||
}
|
||||
const updates = resolvedNumericBindings(graph, node)
|
||||
if (Object.keys(updates).length === 0) continue
|
||||
graph.updateNode(node.id, updates)
|
||||
changed.push(node.id)
|
||||
|
|
|
|||
Loading…
Reference in a new issue