openpencil/packages/scene-graph/src/variables.ts
Danila Poyarkov e704343f13 fix(fig): honor node-scoped variable modes
- Import and round-trip per-node variable mode overrides

- Resolve fill and stroke aliases through the nearest mode scope

- Preserve mode maps across cloning and instance synchronization
2026-07-21 00:23:00 +03:00

401 lines
13 KiB
TypeScript

import { omit, omitBy } from 'es-toolkit/object'
import { BLACK } from './constants'
import type { SceneGraph } from './index'
import type { Color } from './primitives'
import type { Variable, VariableCollection, VariableType, VariableValue } from './types'
export function addVariable(graph: SceneGraph, variable: Variable): void {
graph.variables.set(variable.id, variable)
const collection = graph.variableCollections.get(variable.collectionId)
if (collection && !collection.variableIds.includes(variable.id)) {
collection.variableIds.push(variable.id)
}
}
export function removeVariable(graph: SceneGraph, id: string): void {
const variable = graph.variables.get(id)
if (!variable) return
graph.variables.delete(id)
const collection = graph.variableCollections.get(variable.collectionId)
if (collection) {
collection.variableIds = collection.variableIds.filter((vid) => vid !== id)
}
for (const node of graph.nodes.values()) {
const hadBinding = Object.values(node.boundVariables).includes(id)
if (!hadBinding) continue
node.boundVariables = omitBy(node.boundVariables, (varId) => varId === id) as Record<
string,
string
>
graph.emitter.emit('node:updated', node.id, { boundVariables: { ...node.boundVariables } })
markBoundVariablesOverrideOnInstance(graph, node.id)
}
}
export function addCollection(graph: SceneGraph, collection: VariableCollection): void {
graph.variableCollections.set(collection.id, collection)
if (!graph.activeMode.has(collection.id)) {
graph.activeMode.set(collection.id, collection.defaultModeId)
}
}
function defaultVariableValue(type: VariableType, value?: VariableValue): VariableValue {
if (value !== undefined) return value
if (type === 'COLOR') return { ...BLACK }
if (type === 'FLOAT') return 0
if (type === 'BOOLEAN') return false
return ''
}
export function createVariable(
graph: SceneGraph,
generateId: () => string,
name: string,
type: VariableType,
collectionId: string,
value?: VariableValue
): Variable {
const collection = graph.variableCollections.get(collectionId)
if (!collection) throw new Error(`Collection "${collectionId}" not found`)
const id = generateId()
const defaultValue = defaultVariableValue(type, value)
const valuesByMode: Record<string, VariableValue> = {}
for (const mode of collection.modes) {
valuesByMode[mode.modeId] = structuredClone(defaultValue)
}
const variable: Variable = {
id,
name,
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false
}
addVariable(graph, variable)
return variable
}
export function createCollection(
graph: SceneGraph,
generateId: () => string,
name: string
): VariableCollection {
const id = generateId()
const modeId = generateId()
const collection: VariableCollection = {
id,
name,
modes: [{ modeId, name: 'Mode 1' }],
defaultModeId: modeId,
variableIds: []
}
addCollection(graph, collection)
return collection
}
export function removeCollection(graph: SceneGraph, id: string): void {
const collection = graph.variableCollections.get(id)
if (collection) {
for (const varId of Array.from(collection.variableIds)) {
removeVariable(graph, varId)
}
}
graph.variableCollections.delete(id)
graph.activeMode.delete(id)
}
export function getActiveModeId(graph: SceneGraph, collectionId: string): string {
const mode = graph.activeMode.get(collectionId)
if (mode) return mode
const collection = graph.variableCollections.get(collectionId)
return collection?.defaultModeId ?? ''
}
export function getNodeVariableModeId(
graph: SceneGraph,
nodeId: string,
collectionId: string
): string {
let node = graph.nodes.get(nodeId)
while (node) {
const modeId = node.variableModes[collectionId]
if (modeId) return modeId
node = node.parentId ? graph.nodes.get(node.parentId) : undefined
}
return getActiveModeId(graph, collectionId)
}
export function setActiveMode(graph: SceneGraph, collectionId: string, modeId: string): void {
graph.activeMode.set(collectionId, modeId)
}
export function addMode(
graph: SceneGraph,
collectionId: string,
modeId: string,
name: string,
sourceMode?: string
): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
collection.modes.push({ modeId, name })
const sourceModeId = sourceMode ?? collection.defaultModeId
for (const varId of collection.variableIds) {
const variable = graph.variables.get(varId)
if (!variable) continue
variable.valuesByMode[modeId] = structuredClone(
variable.valuesByMode[sourceModeId] ?? Object.values(variable.valuesByMode)[0]
)
}
}
export function removeMode(graph: SceneGraph, collectionId: string, modeId: string): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection || collection.modes.length <= 1) return
collection.modes = collection.modes.filter((m) => m.modeId !== modeId)
if (collection.defaultModeId === modeId) {
collection.defaultModeId = collection.modes[0].modeId
}
for (const varId of collection.variableIds) {
const variable = graph.variables.get(varId)
if (variable) variable.valuesByMode = omit(variable.valuesByMode, [modeId])
}
if (graph.activeMode.get(collectionId) === modeId) {
graph.activeMode.set(collectionId, collection.defaultModeId)
}
}
export function renameMode(
graph: SceneGraph,
collectionId: string,
modeId: string,
name: string
): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
const mode = collection.modes.find((m) => m.modeId === modeId)
if (mode) mode.name = name
}
export function setDefaultMode(graph: SceneGraph, collectionId: string, modeId: string): void {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return
if (!collection.modes.some((m) => m.modeId === modeId)) return
collection.defaultModeId = modeId
}
export function resolveVariable(
graph: SceneGraph,
variableId: string,
modeId?: string,
visited?: Set<string>
): VariableValue | undefined {
if (visited?.has(variableId)) return undefined
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const collection = graph.variableCollections.get(variable.collectionId)
const preferredModeId = modeId ?? getActiveModeId(graph, variable.collectionId)
const fallbackModeId = collection?.defaultModeId
let value = Object.hasOwn(variable.valuesByMode, preferredModeId)
? variable.valuesByMode[preferredModeId]
: undefined
if (
value === undefined &&
fallbackModeId &&
Object.hasOwn(variable.valuesByMode, fallbackModeId)
) {
value = variable.valuesByMode[fallbackModeId]
}
value ??= Object.values(variable.valuesByMode)[0]
if (value && typeof value === 'object' && 'aliasId' in value) {
const seen = visited ?? new Set<string>()
seen.add(variableId)
return resolveVariable(graph, value.aliasId, preferredModeId, seen)
}
return value
}
export function resolveColorVariable(graph: SceneGraph, variableId: string): Color | undefined {
const value = resolveVariable(graph, variableId)
if (value && typeof value === 'object' && 'r' in value) return value
return undefined
}
export function resolveNumberVariable(graph: SceneGraph, variableId: string): number | undefined {
const value = resolveVariable(graph, variableId)
return typeof value === 'number' ? value : undefined
}
export function resolveColorVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
): Color | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const value = resolveVariable(graph, variableId, modeId)
if (value && typeof value === 'object' && 'r' in value) return value
return undefined
}
export function resolveNumberVariableForNode(
graph: SceneGraph,
nodeId: string,
variableId: string
): number | undefined {
const variable = graph.variables.get(variableId)
if (!variable) return undefined
const modeId = getNodeVariableModeId(graph, nodeId, variable.collectionId)
const value = resolveVariable(graph, variableId, modeId)
return typeof value === 'number' ? value : undefined
}
export function getVariablesForCollection(graph: SceneGraph, collectionId: string): Variable[] {
const collection = graph.variableCollections.get(collectionId)
if (!collection) return []
return collection.variableIds
.map((id) => graph.variables.get(id))
.filter((v): v is Variable => v !== undefined)
}
export function getVariablesByType(graph: SceneGraph, type: VariableType): Variable[] {
return [...graph.variables.values()].filter((v) => v.type === type)
}
const SCALAR_BINDING_FIELDS: ReadonlySet<string> = new Set([
'opacity',
'width',
'height',
'cornerRadius',
'fontSize',
'letterSpacing',
'lineHeight',
'itemSpacing',
'strokeWeight',
'paddingLeft',
'paddingRight',
'paddingTop',
'paddingBottom',
'counterAxisSpacing',
'topLeftRadius',
'topRightRadius',
'bottomLeftRadius',
'bottomRightRadius',
'rotation',
'x',
'y',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'borderTopWeight',
'borderBottomWeight',
'borderLeftWeight',
'borderRightWeight',
'gridRowGap',
'gridColumnGap'
])
const STRING_BINDING_FIELDS: ReadonlySet<string> = new Set(['fontFamily'])
const BOOLEAN_BINDING_FIELDS: ReadonlySet<string> = new Set(['visible'])
export function bindVariable(
graph: SceneGraph,
nodeId: string,
field: string,
variableId: string
): void {
const node = graph.nodes.get(nodeId)
if (!node) return
// Validate variable exists
const variable = graph.variables.get(variableId)
if (!variable) {
throw new Error(`Variable "${variableId}" not found`)
}
// Color fields require COLOR variable type
const colorFieldMatch = field.match(/^(fills|strokes)\/(\d+)\/color$/)
if (colorFieldMatch) {
if (variable.type !== 'COLOR') {
throw new Error(`Cannot bind ${variable.type} variable to color field "${field}"`)
}
// Validate index is within current array bounds
const arrayKey = colorFieldMatch[1] as 'fills' | 'strokes'
const index = Number.parseInt(colorFieldMatch[2], 10)
const currentLength = (node[arrayKey] as unknown[] | undefined)?.length ?? 0
if (index >= currentLength) {
throw new Error(`Index ${index} out of range for ${arrayKey} (length ${currentLength})`)
}
// Auto-remove top-level dead binding (e.g. 'fills') when setting indexed binding
const topLevelKey = colorFieldMatch[1]
if (topLevelKey in node.boundVariables) {
node.boundVariables = omit(node.boundVariables, [topLevelKey])
}
}
if (SCALAR_BINDING_FIELDS.has(field) && variable.type !== 'FLOAT') {
throw new Error(`Cannot bind ${variable.type} variable to scalar field "${field}"`)
}
if (STRING_BINDING_FIELDS.has(field) && variable.type !== 'STRING') {
throw new Error(`Cannot bind ${variable.type} variable to string field "${field}"`)
}
if (BOOLEAN_BINDING_FIELDS.has(field) && variable.type !== 'BOOLEAN') {
throw new Error(`Cannot bind ${variable.type} variable to boolean field "${field}"`)
}
const isKnownField =
SCALAR_BINDING_FIELDS.has(field) ||
STRING_BINDING_FIELDS.has(field) ||
BOOLEAN_BINDING_FIELDS.has(field) ||
colorFieldMatch
if (!isKnownField) {
throw new Error(`Unknown binding field "${field}"`)
}
node.boundVariables = { ...node.boundVariables, [field]: variableId }
graph.emitter.emit('node:updated', nodeId, { boundVariables: { ...node.boundVariables } })
markBoundVariablesOverrideOnInstance(graph, nodeId)
}
export function unbindVariable(graph: SceneGraph, nodeId: string, field: string): void {
const node = graph.nodes.get(nodeId)
if (!node) return
if (!(field in node.boundVariables)) return
node.boundVariables = omit(node.boundVariables, [field])
graph.emitter.emit('node:updated', nodeId, { boundVariables: { ...node.boundVariables } })
markBoundVariablesOverrideOnInstance(graph, nodeId)
}
function markBoundVariablesOverrideOnInstance(graph: SceneGraph, nodeId: string): void {
const node = graph.nodes.get(nodeId)
if (!node) return
// If the node IS an INSTANCE itself, set the bare-key override (syncInstances
// checks `key in instance.overrides` for INSTANCE-self properties)
if (node.type === 'INSTANCE') {
node.overrides['boundVariables'] = true
return
}
// Otherwise walk up to find an INSTANCE parent and set the child-key override
// (syncChildren checks `${instChild.id}:${key}` format)
let current = node
while (current.parentId) {
const parent = graph.nodes.get(current.parentId)
if (!parent) break
if (parent.type === 'INSTANCE') {
parent.overrides[`${nodeId}:boundVariables`] = true
break
}
current = parent
}
}