Merge branch 'fix-variables-serialization'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Danila Poyarkov 2026-03-08 23:10:45 +03:00
commit 5d259b95c3
8 changed files with 458 additions and 35 deletions

View file

@ -10,6 +10,7 @@
### Fixes
- Serialize variables, collections, and bindings to `.fig` files — previously lost on save (#65)
- Double-click on layer tree no longer toggles expand/collapse — use the chevron instead
- Page rename input matches layer rename styling

View file

@ -3,11 +3,12 @@ import { zipSync, deflateSync } from 'fflate'
import { CANVAS_BG_COLOR, IS_TAURI } from './constants'
import { sceneNodeToKiwi, fractionalPosition, buildFigKiwi } from './kiwi-serialize'
import { initCodec, getCompiledSchema, getSchemaBytes } from './kiwi/codec'
import { stringToGuid } from './kiwi/kiwi-convert'
import { renderThumbnail } from './render-image'
import type { NodeChange } from './kiwi/codec'
import type { SkiaRenderer } from './renderer'
import type { SceneGraph } from './scene-graph'
import type { SceneGraph, VariableValue } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
const THUMBNAIL_1X1 = Uint8Array.from(
@ -19,6 +20,33 @@ const THUMBNAIL_1X1 = Uint8Array.from(
type KiwiNodeChange = NodeChange & Record<string, unknown>
function variableValueToKiwi(
value: VariableValue,
type: string
): { value: Record<string, unknown>; dataType: string; resolvedDataType: string } {
if (typeof value === 'object' && value !== null && 'aliasId' in value) {
return {
value: { alias: { guid: stringToGuid(value.aliasId) } },
dataType: 'ALIAS',
resolvedDataType: type === 'COLOR' ? 'COLOR' : type === 'BOOLEAN' ? 'BOOLEAN' : type === 'STRING' ? 'STRING' : 'FLOAT'
}
}
if (type === 'COLOR' && typeof value === 'object' && value !== null && 'r' in value) {
return {
value: { colorValue: { r: value.r, g: value.g, b: value.b, a: value.a } },
dataType: 'COLOR',
resolvedDataType: 'COLOR'
}
}
if (type === 'BOOLEAN') {
return { value: { boolValue: !!value }, dataType: 'BOOLEAN', resolvedDataType: 'BOOLEAN' }
}
if (type === 'STRING') {
return { value: { textValue: String(value) }, dataType: 'STRING', resolvedDataType: 'STRING' }
}
return { value: { floatValue: Number(value) }, dataType: 'FLOAT', resolvedDataType: 'FLOAT' }
}
const THUMBNAIL_WIDTH = 400
const THUMBNAIL_HEIGHT = 225
@ -53,12 +81,16 @@ export async function exportFigFile(
const blobs: Uint8Array[] = []
const pages = graph.getPages(true)
const nodeIdToGuid = new Map<string, { sessionID: number; localID: number }>()
let internalCanvasGuid: { sessionID: number; localID: number } | null = null
for (let p = 0; p < pages.length; p++) {
const page = pages[p]
const canvasLocalID = localIdCounter.value++
const canvasGuid = { sessionID: 0, localID: canvasLocalID }
if (page.internalOnly) internalCanvasGuid = canvasGuid
const canvasNc: KiwiNodeChange = {
guid: canvasGuid,
parentIndex: { guid: docGuid, position: fractionalPosition(p) },
@ -80,7 +112,86 @@ export async function exportFigFile(
const children = graph.getChildren(page.id)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs))
nodeChanges.push(
...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, nodeIdToGuid)
)
}
}
if (graph.variableCollections.size > 0) {
if (!internalCanvasGuid) {
const internalLocalID = localIdCounter.value++
internalCanvasGuid = { sessionID: 0, localID: internalLocalID }
nodeChanges.push({
guid: internalCanvasGuid,
parentIndex: { guid: docGuid, position: fractionalPosition(pages.length) },
type: 'CANVAS',
name: 'Internal Only Canvas',
visible: true,
opacity: 1,
phase: 'CREATED',
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
strokeWeight: 1,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
internalOnly: true
})
}
let collIdx = 0
for (const [colId, col] of graph.variableCollections) {
const colGuid = stringToGuid(colId)
const colNc: KiwiNodeChange = {
guid: colGuid,
parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(collIdx++) },
type: 'VARIABLE_SET',
name: col.name,
phase: 'CREATED',
strokeAlign: 'CENTER',
strokeJoin: 'BEVEL',
variableSetModes: col.modes.map((m, i) => ({
id: stringToGuid(m.modeId),
name: m.name,
sortPosition: fractionalPosition(i)
}))
}
nodeChanges.push(colNc)
let varIdx = 0
for (const varId of col.variableIds) {
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = stringToGuid(varId)
const resolvedType =
variable.type === 'COLOR'
? 'COLOR'
: variable.type === 'BOOLEAN'
? 'BOOLEAN'
: variable.type === 'STRING'
? 'STRING'
: 'FLOAT'
const entries = Object.entries(variable.valuesByMode).map(([modeId, value]) => ({
modeID: stringToGuid(modeId),
variableData: variableValueToKiwi(value, variable.type)
}))
const varNc: KiwiNodeChange = {
guid: varGuid,
parentIndex: { guid: internalCanvasGuid, position: fractionalPosition(varIdx++) },
type: 'VARIABLE',
name: variable.name,
phase: 'CREATED',
strokeAlign: 'CENTER',
strokeJoin: 'BEVEL',
variableSetID: { guid: colGuid },
variableResolvedType: resolvedType,
variableDataValues: { entries },
variableScopes: ['ALL_SCOPES']
}
nodeChanges.push(varNc)
}
}
}

View file

@ -4,8 +4,9 @@ import { deflateSync, inflateSync } from 'fflate'
import { weightToStyle } from './fonts'
import { encodeVectorNetworkBlob } from './vector'
import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert'
import type { NodeChange, Paint } from './kiwi/codec'
import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph'
type KiwiNodeChange = NodeChange & Record<string, unknown>
@ -166,10 +167,12 @@ export function sceneNodeToKiwi(
childIndex: number,
localIdCounter: { value: number },
graph: SceneGraph,
blobs: Uint8Array[]
blobs: Uint8Array[],
nodeIdToGuid?: Map<string, { sessionID: number; localID: number }>
): KiwiNodeChange[] {
const localID = localIdCounter.value++
const guid = { sessionID: 1, localID }
nodeIdToGuid?.set(node.id, guid)
const sx = node.flipX ? -1 : 1
const cos = Math.cos((node.rotation * Math.PI) / 180)
const sin = Math.sin((node.rotation * Math.PI) / 180)
@ -321,10 +324,31 @@ export function sceneNodeToKiwi(
})
}
if (Object.keys(node.boundVariables).length > 0) {
const entries: VariableConsumptionEntry[] = []
for (const [field, varId] of Object.entries(node.boundVariables)) {
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const variable = graph.variables.get(varId)
if (!variable) continue
const varGuid = stringToGuid(varId)
const resolvedType = variable.type === 'COLOR' ? 'COLOR' : variable.type === 'BOOLEAN' ? 'BOOLEAN' : variable.type === 'STRING' ? 'STRING' : 'FLOAT'
entries.push({
variableData: {
value: { alias: { guid: varGuid } },
dataType: 'ALIAS',
resolvedDataType: resolvedType
},
variableField: kiwiField
})
}
if (entries.length > 0) nc.variableConsumptionMap = { entries }
}
const result: KiwiNodeChange[] = [nc]
const children = graph.getChildren(node.id)
for (let i = 0; i < children.length; i++) {
result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs))
result.push(...sceneNodeToKiwi(children[i], guid, i, localIdCounter, graph, blobs, nodeIdToGuid))
}
return result

View file

@ -209,6 +209,31 @@ export interface Effect {
blendMode?: string
}
export interface VariableAnyValue {
boolValue?: boolean
textValue?: string
floatValue?: number
colorValue?: Color
alias?: { guid: GUID }
}
export interface VariableDataEntry {
value?: VariableAnyValue
dataType?: string
resolvedDataType?: string
}
export interface VariableConsumptionEntry {
nodeField?: number
variableData?: VariableDataEntry
variableField?: string
}
export interface VariableDataValuesEntry {
modeID: GUID
variableData: VariableDataEntry
}
export interface NodeChange {
[key: string]: unknown
guid: GUID
@ -289,6 +314,14 @@ export interface NodeChange {
// Constraints
horizontalConstraint?: string
verticalConstraint?: string
// Variables
variableData?: VariableDataEntry
variableConsumptionMap?: { entries?: VariableConsumptionEntry[] }
variableSetModes?: Array<{ id: GUID; name: string; sortPosition?: string }>
variableSetID?: { guid: GUID }
variableResolvedType?: string
variableDataValues?: { entries?: VariableDataValuesEntry[] }
variableScopes?: string[]
}
export interface FigmaMessage {

View file

@ -1,7 +1,12 @@
import type { VariableType, VariableValue } from '../scene-graph'
import { SceneGraph } from '../scene-graph'
import { guidToString, nodeChangeToProps, sortChildren } from './kiwi-convert'
import {
guidToString,
nodeChangeToProps,
sortChildren,
VARIABLE_BINDING_FIELDS_INVERSE
} from './kiwi-convert'
import { populateAndApplyOverrides } from './instance-overrides'
import type { InstanceNodeChange } from './instance-overrides'
@ -67,7 +72,7 @@ export function importNodeChanges(
if (!nc) return
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
const node = graph.createNode(nodeType, graphParentId, props)
guidToNodeId.set(ncId, node.id)
@ -79,26 +84,33 @@ export function importNodeChanges(
function importVariables() {
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const varData = (
nc as unknown as {
variableData?: {
value?: { boolValue?: boolean; textValue?: string; floatValue?: number }
dataType?: string
}
}
).variableData
if (!varData) continue
if (nc.type !== 'VARIABLE_SET') continue
const parentId = parentMap.get(id) ?? ''
const parentNc = changeMap.get(parentId)
const collectionName = parentNc?.name ?? 'Variables'
const collectionId = parentId
const modes = (nc.variableSetModes ?? []).map((m) => {
const modeId = m.id ? guidToString(m.id) : 'default'
return { modeId, name: m.name ?? 'Mode' }
})
if (modes.length === 0) modes.push({ modeId: 'default', name: 'Default' })
graph.addCollection({
id,
name: nc.name ?? 'Variables',
modes,
defaultModeId: modes[0].modeId,
variableIds: []
})
}
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId)
graph.addCollection({
id: collectionId,
name: collectionName,
name: parentNc?.name ?? 'Variables',
modes: [{ modeId: 'default', name: 'Default' }],
defaultModeId: 'default',
variableIds: []
@ -106,19 +118,39 @@ export function importNodeChanges(
}
let type: VariableType = 'FLOAT'
let value: VariableValue = 0
const dt = varData.dataType
const v = varData.value
const resolvedType = nc.variableResolvedType
if (resolvedType === 'COLOR') type = 'COLOR'
else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN'
else if (resolvedType === 'STRING') type = 'STRING'
if (dt === 'BOOLEAN' || dt === '0') {
type = 'BOOLEAN'
value = v?.boolValue ?? false
} else if (dt === 'STRING' || dt === '2') {
type = 'STRING'
value = v?.textValue ?? ''
} else {
type = 'FLOAT'
value = v?.floatValue ?? 0
const valuesByMode: Record<string, VariableValue> = {}
if (nc.variableDataValues?.entries) {
for (const entry of nc.variableDataValues.entries) {
const modeId = guidToString(entry.modeID)
const vd = entry.variableData
if (!vd.value) continue
const dt = vd.dataType ?? vd.resolvedDataType
if (dt === 'COLOR' && vd.value.colorValue) {
const c = vd.value.colorValue
valuesByMode[modeId] = { r: c.r, g: c.g, b: c.b, a: c.a }
} else if (dt === 'BOOLEAN') {
valuesByMode[modeId] = vd.value.boolValue ?? false
} else if (dt === 'STRING') {
valuesByMode[modeId] = vd.value.textValue ?? ''
} else if (dt === 'ALIAS' && vd.value.alias?.guid) {
valuesByMode[modeId] = { aliasId: guidToString(vd.value.alias.guid) }
} else {
valuesByMode[modeId] = vd.value.floatValue ?? 0
}
}
}
if (Object.keys(valuesByMode).length === 0) {
const col = graph.variableCollections.get(collectionId)
const defaultMode = col?.defaultModeId ?? 'default'
valuesByMode[defaultMode] = type === 'BOOLEAN' ? false : type === 'STRING' ? '' : type === 'COLOR' ? { r: 0, g: 0, b: 0, a: 1 } : 0
}
graph.addVariable({
@ -126,13 +158,29 @@ export function importNodeChanges(
name: nc.name ?? 'Variable',
type,
collectionId,
valuesByMode: { default: value },
valuesByMode,
description: '',
hiddenFromPublishing: false
})
}
}
function importVariableBindings() {
for (const [ncId, nc] of changeMap) {
if (!nc.variableConsumptionMap?.entries?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const entry of nc.variableConsumptionMap.entries) {
const varGuid = entry.variableData?.value?.alias?.guid
if (!varGuid) continue
const field = VARIABLE_BINDING_FIELDS_INVERSE[entry.variableField ?? '']
if (field) graph.bindVariable(nodeId, field, guidToString(varGuid))
}
}
}
// Find the document node (type=DOCUMENT or guid 0:0)
let docId: string | null = null
for (const [id, nc] of changeMap) {
@ -172,6 +220,7 @@ export function importNodeChanges(
}
importVariables()
importVariableBindings()
// Remap componentId from original Figma GUIDs to imported node IDs
for (const node of graph.getAllNodes()) {

View file

@ -39,6 +39,37 @@ export function guidToString(guid: GUID): string {
return `${guid.sessionID}:${guid.localID}`
}
export function stringToGuid(str: string): GUID {
const [session, local] = str.split(':')
return { sessionID: parseInt(session, 10), localID: parseInt(local, 10) }
}
export const VARIABLE_BINDING_FIELDS: Record<string, string> = {
cornerRadius: 'CORNER_RADIUS',
topLeftRadius: 'RECTANGLE_TOP_LEFT_CORNER_RADIUS',
topRightRadius: 'RECTANGLE_TOP_RIGHT_CORNER_RADIUS',
bottomLeftRadius: 'RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS',
bottomRightRadius: 'RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS',
strokeWeight: 'STROKE_WEIGHT',
itemSpacing: 'STACK_SPACING',
paddingLeft: 'STACK_PADDING_LEFT',
paddingTop: 'STACK_PADDING_TOP',
paddingRight: 'STACK_PADDING_RIGHT',
paddingBottom: 'STACK_PADDING_BOTTOM',
counterAxisSpacing: 'STACK_COUNTER_SPACING',
visible: 'VISIBLE',
opacity: 'OPACITY',
width: 'WIDTH',
height: 'HEIGHT',
fontSize: 'FONT_SIZE',
letterSpacing: 'LETTER_SPACING',
lineHeight: 'LINE_HEIGHT'
}
export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fromEntries(
Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k])
)
const convertColor = normalizeColor
function imageHashToString(hash: Record<string, number>): string {

View file

@ -71,6 +71,7 @@ enum NodeType {
SECTION_OVERLAY = 26;
WASHI_TAPE = 27;
VARIABLE = 28;
VARIABLE_SET = 29;
}
enum ShapeWithTextType {
@ -1480,6 +1481,12 @@ message NodeChange {
string accessibleLabel = 304;
bool propsAreBubbled = 305;
VariableData variableData = 306;
VariableDataMap variableConsumptionMap = 307;
VariableSetMode[] variableSetModes = 312;
VariableSetID variableSetID = 313;
VariableResolvedDataType variableResolvedType = 314;
VariableDataValues variableDataValues = 315;
VariableScope[] variableScopes = 353;
uint gridRowCount = 435;
uint gridColumnCount = 436;
float gridRowGap = 437;
@ -1997,17 +2004,106 @@ enum VariableDataType {
BOOLEAN = 0;
FLOAT = 1;
STRING = 2;
ALIAS = 3;
COLOR = 4;
}
enum VariableResolvedDataType {
BOOLEAN = 0;
FLOAT = 1;
STRING = 2;
COLOR = 4;
}
message VariableID {
GUID guid = 1;
}
message VariableSetID {
GUID guid = 1;
}
message VariableAnyValue {
bool boolValue = 1;
string textValue = 2;
float floatValue = 3;
VariableID alias = 4;
Color colorValue = 5;
}
message VariableData {
VariableAnyValue value = 1;
VariableDataType dataType = 2;
VariableResolvedDataType resolvedDataType = 3;
}
message VariableSetMode {
GUID id = 1;
string name = 2;
string sortPosition = 3;
}
message VariableDataValuesEntry {
GUID modeID = 1;
VariableData variableData = 2;
}
message VariableDataValues {
VariableDataValuesEntry[] entries = 1;
}
enum VariableScope {
ALL_SCOPES = 0;
TEXT_CONTENT = 1;
CORNER_RADIUS = 2;
WIDTH_HEIGHT = 3;
GAP = 4;
ALL_FILLS = 5;
FRAME_FILL = 6;
SHAPE_FILL = 7;
TEXT_FILL = 8;
STROKE = 9;
STROKE_FLOAT = 10;
EFFECT_FLOAT = 11;
EFFECT_COLOR = 12;
OPACITY = 13;
FONT_FAMILY = 15;
FONT_SIZE = 16;
LINE_HEIGHT = 17;
LETTER_SPACING = 18;
}
enum VariableField {
MISSING = 0;
CORNER_RADIUS = 1;
STROKE_WEIGHT = 4;
STACK_SPACING = 5;
STACK_PADDING_LEFT = 6;
STACK_PADDING_TOP = 7;
STACK_PADDING_RIGHT = 8;
STACK_PADDING_BOTTOM = 9;
VISIBLE = 10;
WIDTH = 12;
HEIGHT = 13;
RECTANGLE_TOP_LEFT_CORNER_RADIUS = 14;
RECTANGLE_TOP_RIGHT_CORNER_RADIUS = 15;
RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS = 16;
RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS = 17;
OPACITY = 31;
FONT_SIZE = 32;
LETTER_SPACING = 34;
LINE_HEIGHT = 36;
STACK_COUNTER_SPACING = 23;
}
message VariableDataMapEntry {
uint nodeField = 1;
VariableData variableData = 2;
VariableField variableField = 3;
}
message VariableDataMap {
VariableDataMapEntry[] entries = 1;
}
enum HTMLTag {

View file

@ -851,3 +851,81 @@ describe('edge cases', () => {
expect(overflows).toBe(0)
})
})
describe('variable roundtrip', () => {
test('variables and collections survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Design Tokens')
graph.createVariable('color/primary', 'COLOR', col.id, { r: 0.23, g: 0.51, b: 0.96, a: 1 })
graph.createVariable('spacing/base', 'FLOAT', col.id, 8)
graph.createVariable('visible', 'BOOLEAN', col.id, true)
graph.createVariable('label', 'STRING', col.id, 'Hello')
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(4)
expect(reimported.variableCollections.size).toBe(1)
const reimportedCol = [...reimported.variableCollections.values()][0]
expect(reimportedCol.name).toBe('Design Tokens')
expect(reimportedCol.variableIds).toHaveLength(4)
const vars = [...reimported.variables.values()]
const colorVar = vars.find((v) => v.name === 'color/primary')!
expect(colorVar.type).toBe('COLOR')
const colorVal = Object.values(colorVar.valuesByMode)[0] as { r: number; g: number; b: number; a: number }
expect(colorVal.r).toBeCloseTo(0.23, 1)
const floatVar = vars.find((v) => v.name === 'spacing/base')!
expect(floatVar.type).toBe('FLOAT')
expect(Object.values(floatVar.valuesByMode)[0]).toBe(8)
const boolVar = vars.find((v) => v.name === 'visible')!
expect(boolVar.type).toBe('BOOLEAN')
expect(Object.values(boolVar.valuesByMode)[0]).toBe(true)
const strVar = vars.find((v) => v.name === 'label')!
expect(strVar.type).toBe('STRING')
expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello')
})
test('variable bindings survive export → re-import', async () => {
await initCodec()
const graph = new SceneGraph()
const col = graph.createCollection('Tokens')
const floatVar = graph.createVariable('radius', 'FLOAT', col.id, 12)
const page = graph.getPages()[0]
const rect = graph.createNode('RECTANGLE', page.id, {
name: 'Bound Rect',
width: 100,
height: 100,
cornerRadius: 12,
})
graph.bindVariable(rect.id, 'cornerRadius', floatVar.id)
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const reimportedRect = [...reimported.getAllNodes()].find((n) => n.name === 'Bound Rect')!
expect(reimportedRect).toBeDefined()
expect(Object.keys(reimportedRect.boundVariables)).toContain('cornerRadius')
})
test('material3.fig variables survive round-trip', async () => {
const buf = readFileSync(resolve(FIXTURES, 'material3.fig'))
const original = await parseFigFile(buf.buffer as ArrayBuffer)
const exported = await exportFigFile(original)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
expect(reimported.variables.size).toBe(original.variables.size)
expect(reimported.variableCollections.size).toBeGreaterThanOrEqual(
[...original.variableCollections.values()].filter((c) => c.variableIds.length > 0).length
)
})
})