Merge branch 'fix-variables-serialization'
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
5d259b95c3
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue