Extract named types, deduplicate field maps, fix internal canvas GUID

- Define VariableAnyValue, VariableDataEntry, VariableConsumptionEntry,
  VariableDataValuesEntry in codec.ts — remove inline type monsters
- Extract VARIABLE_BINDING_FIELDS / VARIABLE_BINDING_FIELDS_INVERSE to
  kiwi-convert.ts — single source of truth for export and import
- Track internal canvas GUID during page loop instead of recalculating
  with incorrect idx+2 formula (broke when scene nodes existed)
- Remove unused modeGuidToId map, unnecessary as-casts
This commit is contained in:
Danila Poyarkov 2026-03-08 22:22:26 +03:00
parent d9fbc6fb92
commit 5a3e68eade
5 changed files with 90 additions and 92 deletions

View file

@ -82,12 +82,15 @@ 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) },
@ -116,14 +119,7 @@ export async function exportFigFile(
}
if (graph.variableCollections.size > 0) {
const hasInternalPage = pages.some((p) => p.internalOnly)
let internalCanvasGuid: { sessionID: number; localID: number }
if (hasInternalPage) {
const internalPage = pages.find((p) => p.internalOnly)!
const idx = pages.indexOf(internalPage)
internalCanvasGuid = { sessionID: 0, localID: idx + 2 }
} else {
if (!internalCanvasGuid) {
const internalLocalID = localIdCounter.value++
internalCanvasGuid = { sessionID: 0, localID: internalLocalID }
nodeChanges.push({

View file

@ -4,9 +4,9 @@ import { deflateSync, inflateSync } from 'fflate'
import { weightToStyle } from './fonts'
import { encodeVectorNetworkBlob } from './vector'
import { stringToGuid } from './kiwi/kiwi-convert'
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>
@ -161,28 +161,6 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
}
}
const BOUND_VARIABLE_FIELD_MAP: 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 function sceneNodeToKiwi(
node: SceneNode,
parentGuid: { sessionID: number; localID: number },
@ -347,9 +325,9 @@ export function sceneNodeToKiwi(
}
if (Object.keys(node.boundVariables).length > 0) {
const entries: Array<{ variableData: { value: { alias: { guid: { sessionID: number; localID: number } } }; dataType: string; resolvedDataType: string }; variableField: string }> = []
const entries: VariableConsumptionEntry[] = []
for (const [field, varId] of Object.entries(node.boundVariables)) {
const kiwiField = BOUND_VARIABLE_FIELD_MAP[field]
const kiwiField = VARIABLE_BINDING_FIELDS[field]
if (!kiwiField) continue
const variable = graph.variables.get(varId)
if (!variable) continue

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
@ -290,12 +315,12 @@ export interface NodeChange {
horizontalConstraint?: string
verticalConstraint?: string
// Variables
variableData?: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string }
variableConsumptionMap?: { entries?: Array<{ nodeField?: number; variableData?: { value?: { alias?: { guid: GUID }; colorValue?: { r: number; g: number; b: number; a: number }; boolValue?: boolean; textValue?: string; floatValue?: number }; dataType?: string; resolvedDataType?: string }; variableField?: string }> }
variableData?: VariableDataEntry
variableConsumptionMap?: { entries?: VariableConsumptionEntry[] }
variableSetModes?: Array<{ id: GUID; name: string; sortPosition?: string }>
variableSetID?: { guid: GUID }
variableResolvedType?: string
variableDataValues?: { entries?: Array<{ modeID: GUID; variableData: { value?: { boolValue?: boolean; textValue?: string; floatValue?: number; colorValue?: { r: number; g: number; b: number; a: number }; alias?: { guid: GUID } }; dataType?: string; resolvedDataType?: string } }> }
variableDataValues?: { entries?: VariableDataValuesEntry[] }
variableScopes?: string[]
}

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'
@ -78,18 +83,13 @@ export function importNodeChanges(
}
function importVariables() {
const modeGuidToId = new Map<string, string>()
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE_SET') continue
const modes = (nc.variableSetModes ?? []).map(
(m: { id?: { sessionID: number; localID: number }; name?: string }) => {
const modeId = m.id ? guidToString(m.id) : 'default'
if (m.id) modeGuidToId.set(modeId, modeId)
return { modeId, name: m.name ?? 'Mode' }
}
)
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({
@ -104,8 +104,7 @@ export function importNodeChanges(
for (const [id, nc] of changeMap) {
if (nc.type !== 'VARIABLE') continue
const setIdObj = nc.variableSetID as { guid?: { sessionID: number; localID: number } } | undefined
const collectionId = setIdObj?.guid ? guidToString(setIdObj.guid) : (parentMap.get(id) ?? '')
const collectionId = nc.variableSetID?.guid ? guidToString(nc.variableSetID.guid) : (parentMap.get(id) ?? '')
if (!graph.variableCollections.has(collectionId)) {
const parentNc = changeMap.get(collectionId)
@ -118,34 +117,32 @@ export function importNodeChanges(
})
}
const resolvedType = nc.variableResolvedType as string | undefined
let type: VariableType = 'FLOAT'
const resolvedType = nc.variableResolvedType
if (resolvedType === 'COLOR') type = 'COLOR'
else if (resolvedType === 'BOOLEAN') type = 'BOOLEAN'
else if (resolvedType === 'STRING') type = 'STRING'
const valuesByMode: Record<string, VariableValue> = {}
const dataValues = nc.variableDataValues as { entries?: Array<{ modeID?: { sessionID: number; localID: number }; variableData?: { value?: Record<string, unknown>; dataType?: string; resolvedDataType?: string } }> } | undefined
if (dataValues?.entries) {
for (const entry of dataValues.entries) {
const modeId = entry.modeID ? guidToString(entry.modeID) : 'default'
if (nc.variableDataValues?.entries) {
for (const entry of nc.variableDataValues.entries) {
const modeId = guidToString(entry.modeID)
const vd = entry.variableData
if (!vd?.value) continue
if (!vd.value) continue
const dt = vd.dataType ?? vd.resolvedDataType
if (dt === 'COLOR' && vd.value.colorValue) {
const c = vd.value.colorValue as { r: number; g: number; b: number; a: number }
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 as boolean) ?? false
valuesByMode[modeId] = vd.value.boolValue ?? false
} else if (dt === 'STRING') {
valuesByMode[modeId] = (vd.value.textValue as string) ?? ''
} else if (dt === 'ALIAS' && vd.value.alias) {
const alias = vd.value.alias as { guid?: { sessionID: number; localID: number } }
if (alias.guid) valuesByMode[modeId] = { aliasId: guidToString(alias.guid) }
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 as number) ?? 0
valuesByMode[modeId] = vd.value.floatValue ?? 0
}
}
}
@ -169,41 +166,17 @@ export function importNodeChanges(
}
function importVariableBindings() {
const fieldMap: Record<string, string> = {
CORNER_RADIUS: 'cornerRadius',
RECTANGLE_TOP_LEFT_CORNER_RADIUS: 'topLeftRadius',
RECTANGLE_TOP_RIGHT_CORNER_RADIUS: 'topRightRadius',
RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS: 'bottomLeftRadius',
RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS: 'bottomRightRadius',
STROKE_WEIGHT: 'strokeWeight',
STACK_SPACING: 'itemSpacing',
STACK_PADDING_LEFT: 'paddingLeft',
STACK_PADDING_TOP: 'paddingTop',
STACK_PADDING_RIGHT: 'paddingRight',
STACK_PADDING_BOTTOM: 'paddingBottom',
STACK_COUNTER_SPACING: 'counterAxisSpacing',
VISIBLE: 'visible',
OPACITY: 'opacity',
WIDTH: 'width',
HEIGHT: 'height',
FONT_SIZE: 'fontSize',
LETTER_SPACING: 'letterSpacing',
LINE_HEIGHT: 'lineHeight'
}
for (const [ncId, nc] of changeMap) {
const consumption = nc.variableConsumptionMap as { entries?: Array<{ variableData?: { value?: { alias?: { guid?: { sessionID: number; localID: number } } } }; variableField?: string }> } | undefined
if (!consumption?.entries?.length) continue
if (!nc.variableConsumptionMap?.entries?.length) continue
const nodeId = guidToNodeId.get(ncId)
if (!nodeId) continue
for (const entry of consumption.entries) {
const alias = entry.variableData?.value?.alias
if (!alias?.guid) continue
const variableId = guidToString(alias.guid)
const field = fieldMap[entry.variableField ?? '']
if (field) graph.bindVariable(nodeId, field, variableId)
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))
}
}
}

View file

@ -44,6 +44,32 @@ export function stringToGuid(str: string): GUID {
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 {