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
This commit is contained in:
parent
9db46f919b
commit
e704343f13
|
|
@ -27,6 +27,7 @@
|
|||
### Fixed
|
||||
|
||||
- Keep desktop text visible across the scene and overlay canvases, refresh it after local fonts load, and preserve rendering when a requested italic face is unavailable (#395).
|
||||
- Honor node-scoped variable modes in `.fig` files so light and dark component examples keep their intended colors.
|
||||
- Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds.
|
||||
- Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files.
|
||||
- Prevent duplicate generated IDs from corrupting `.fig` round trips.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function resolveFillColorInfo(
|
|||
): ResolvedRenderColor {
|
||||
const varId = node.boundVariables[`fills/${fillIndex}/color`]
|
||||
if (varId) {
|
||||
const resolved = graph.resolveColorVariable(varId)
|
||||
const resolved = graph.resolveColorVariableForNode(node.id, varId)
|
||||
if (resolved) return resolvedVariableColor(resolved, graph)
|
||||
}
|
||||
return resolveNodeFillColor(fill, fillIndex, node, {
|
||||
|
|
@ -51,7 +51,7 @@ export function resolveStrokeColorInfo(
|
|||
): ResolvedRenderColor {
|
||||
const varId = node.boundVariables[`strokes/${strokeIndex}/color`]
|
||||
if (varId) {
|
||||
const resolved = graph.resolveColorVariable(varId)
|
||||
const resolved = graph.resolveColorVariableForNode(node.id, varId)
|
||||
if (resolved) return resolvedVariableColor(resolved, graph)
|
||||
}
|
||||
return resolveNodeStrokeColor(stroke, strokeIndex, node, {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- FIG export orchestration keeps shared GUID state in one pipeline */
|
||||
import type { CanvasKit } from 'canvaskit-wasm'
|
||||
import { deflateSync, inflateSync } from 'fflate'
|
||||
|
||||
|
|
@ -334,7 +335,8 @@ function appendInternalResources(context: InternalResourceContext): void {
|
|||
context.glyphBlobMap,
|
||||
context.blobIndexByHex,
|
||||
context.assignedGuidValues,
|
||||
context.componentPropertyDefinitionsById
|
||||
context.componentPropertyDefinitionsById,
|
||||
context.modeIdToGuid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -454,7 +456,8 @@ export async function exportFigFile(
|
|||
glyphBlobMap,
|
||||
blobIndexByHex,
|
||||
assignedGuidValues,
|
||||
componentPropertyDefinitionsById
|
||||
componentPropertyDefinitionsById,
|
||||
modeIdToGuid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export function sceneNodeToKiwi(
|
|||
glyphBlobMap = new Map<string, number>(),
|
||||
blobIndexByHex?: Map<string, number>,
|
||||
assignedGuidValues?: Set<string>,
|
||||
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>
|
||||
componentPropertyDefinitionsById?: ReadonlyMap<string, ComponentPropertyDefinition>,
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
): KiwiNodeChange[] {
|
||||
return sceneNodeToKiwiWithRuntime(
|
||||
node,
|
||||
|
|
@ -53,6 +54,7 @@ export function sceneNodeToKiwi(
|
|||
blobIndexByHex,
|
||||
assignedGuidValues,
|
||||
coreFigExportRuntime,
|
||||
componentPropertyDefinitionsById
|
||||
componentPropertyDefinitionsById,
|
||||
modeIdToGuid
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ import type {
|
|||
ComponentPropertyReference,
|
||||
ComponentPropertyType,
|
||||
SymbolLink,
|
||||
VariantPropSpec
|
||||
VariantPropSpec,
|
||||
VariableModeMap
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type { GUID } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
|
|
@ -108,6 +109,25 @@ export const VARIABLE_BINDING_FIELDS_INVERSE: Record<string, string> = Object.fr
|
|||
Object.entries(VARIABLE_BINDING_FIELDS).map(([k, v]) => [v, k])
|
||||
)
|
||||
|
||||
interface FigVariableModeMap {
|
||||
entries?: Array<{
|
||||
variableSetID?: { guid?: GUID }
|
||||
variableModeID?: GUID
|
||||
}>
|
||||
}
|
||||
|
||||
function extractVariableModes(nc: NodeChange): VariableModeMap {
|
||||
const result: VariableModeMap = {}
|
||||
const modeMap = nc.variableModeBySetMap as FigVariableModeMap | undefined
|
||||
for (const entry of modeMap?.entries ?? []) {
|
||||
const collectionGuid = entry.variableSetID?.guid
|
||||
const modeGuid = entry.variableModeID
|
||||
if (!collectionGuid || !modeGuid) continue
|
||||
result[guidToString(collectionGuid)] = guidToString(modeGuid)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const NODE_TYPE_MAP: Record<string, NodeType | 'DOCUMENT' | 'VARIABLE'> = {
|
||||
DOCUMENT: 'DOCUMENT',
|
||||
VARIABLE: 'VARIABLE',
|
||||
|
|
@ -633,6 +653,7 @@ export function nodeChangeToProps(
|
|||
expanded: true,
|
||||
autoRename: (nc.autoRename ?? true) as boolean,
|
||||
boundVariables: extractBoundVariables(nc),
|
||||
variableModes: extractVariableModes(nc),
|
||||
exportSettings: extractExportSettings(nc),
|
||||
pluginData: extractPluginData(nc),
|
||||
pluginRelaunchData: extractPluginRelaunchData(nc),
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ interface SceneNodeToKiwiContext {
|
|||
fontDigestMap?: Map<string, Uint8Array>
|
||||
glyphBlobMap?: Map<string, number>
|
||||
varIdToGuid?: Map<string, GUID>
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
/** Maps "key@version" or "key" (from variable.key/version) → variable GUID.
|
||||
* Used to convert colorVar.assetRef references in raw paints to guid references. */
|
||||
assetRefToVarGuid?: Map<string, GUID>
|
||||
|
|
@ -147,6 +148,20 @@ function parseGuidOrNull(value: string) {
|
|||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||
}
|
||||
|
||||
function serializeVariableModes(
|
||||
node: SceneNode,
|
||||
variableIdToGuid?: Map<string, GUID>,
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
): NonNullable<KiwiNodeChange['variableModeBySetMap']> | undefined {
|
||||
const entries = Object.entries(node.variableModes).flatMap(([collectionId, modeId]) => {
|
||||
const collectionGuid = variableIdToGuid?.get(collectionId) ?? parseGuidOrNull(collectionId)
|
||||
const modeGuid = modeIdToGuid?.get(modeId) ?? parseGuidOrNull(modeId)
|
||||
if (!collectionGuid || !modeGuid) return []
|
||||
return [{ variableSetID: { guid: collectionGuid }, variableModeID: modeGuid }]
|
||||
})
|
||||
return entries.length > 0 ? { entries } : undefined
|
||||
}
|
||||
|
||||
const FIGMA_PAYLOAD_VARIABLE_MAP_FIELDS = new Set([
|
||||
'variableConsumptionMap',
|
||||
'parameterConsumptionMap'
|
||||
|
|
@ -822,6 +837,12 @@ export function sceneNodeToKiwiWithContext(
|
|||
context.serializeGeometry(nodeForGeometryExport(node), nc, context.blobs)
|
||||
context.serializeVariableBindings(node, nc, context.graph, context.varIdToGuid)
|
||||
applyRawFigmaNodeFields(context, node, nc)
|
||||
const variableModeBySetMap = serializeVariableModes(
|
||||
node,
|
||||
context.varIdToGuid,
|
||||
context.modeIdToGuid
|
||||
)
|
||||
if (variableModeBySetMap) nc.variableModeBySetMap = variableModeBySetMap
|
||||
|
||||
applyExportSettingsPluginData(node)
|
||||
const pluginData = mergePluginData(node.pluginData)
|
||||
|
|
|
|||
|
|
@ -77,14 +77,14 @@ export function extractBoundVariables(nc: NodeChange): Record<string, string> {
|
|||
getOpenPencilPluginValue(nc, BOUND_VARIABLES_PLUGIN_KEY)
|
||||
)
|
||||
nc.fillPaints?.forEach((paint, i) => {
|
||||
if (paint.colorVariableBinding) {
|
||||
bindings[`fills/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
|
||||
}
|
||||
const variableGuid =
|
||||
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid
|
||||
if (variableGuid) bindings[`fills/${i}/color`] = guidToString(variableGuid)
|
||||
})
|
||||
nc.strokePaints?.forEach((paint, i) => {
|
||||
if (paint.colorVariableBinding) {
|
||||
bindings[`strokes/${i}/color`] = guidToString(paint.colorVariableBinding.variableID)
|
||||
}
|
||||
const variableGuid =
|
||||
paint.colorVariableBinding?.variableID ?? paint.colorVar?.value?.alias?.guid
|
||||
if (variableGuid) bindings[`strokes/${i}/color`] = guidToString(variableGuid)
|
||||
})
|
||||
return bindings
|
||||
}
|
||||
|
|
|
|||
|
|
@ -472,7 +472,8 @@ export function sceneNodeToKiwi(
|
|||
blobIndexByHex?: Map<string, number>,
|
||||
assignedGuidValues?: Set<string>,
|
||||
runtime: FigNodeChangeExportRuntime = EMPTY_EXPORT_RUNTIME,
|
||||
componentPropertyDefinitionsById = buildComponentPropIndex(graph)
|
||||
componentPropertyDefinitionsById = buildComponentPropIndex(graph),
|
||||
modeIdToGuid?: Map<string, GUID>
|
||||
): KiwiNodeChange[] {
|
||||
// Build assetRef to guid mapping for converting colorVar references in raw paints
|
||||
const assetRefToVarGuid = varIdToGuid ? buildAssetRefToVarGuidMap(graph, varIdToGuid) : undefined
|
||||
|
|
@ -485,6 +486,7 @@ export function sceneNodeToKiwi(
|
|||
fontDigestMap,
|
||||
glyphBlobMap,
|
||||
varIdToGuid,
|
||||
modeIdToGuid,
|
||||
assetRefToVarGuid,
|
||||
componentPropertyDefinitionsById,
|
||||
fractionalPosition,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ export function cloneNodeProps(
|
|||
...(componentId !== null ? { componentId } : {}),
|
||||
source: createDefaultSourceMetadata(),
|
||||
boundVariables: { ...src.boundVariables },
|
||||
variableModes: { ...src.variableModes },
|
||||
overrides: Object.keys(src.overrides).length > 0 ? structuredClone(src.overrides) : {},
|
||||
componentPropertyAssignments: { ...src.componentPropertyAssignments },
|
||||
componentPropertyValues: { ...src.componentPropertyValues }
|
||||
|
|
@ -183,6 +184,7 @@ export function cloneNodeProps(
|
|||
...rest,
|
||||
...(componentId !== null ? { componentId } : {}),
|
||||
boundVariables: { ...src.boundVariables },
|
||||
variableModes: { ...src.variableModes },
|
||||
overrides: Object.keys(src.overrides).length > 0 ? structuredClone(src.overrides) : {},
|
||||
fills: copyOpt(src.fills, (value) => markCopySource(value, copyFills(value))),
|
||||
strokes: copyOpt(src.strokes, (value) => markCopySource(value, copyStrokes(value))),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines -- SceneGraph exposes a stable facade over domain modules */
|
||||
export * from './images'
|
||||
export * from './copy'
|
||||
export * from './snap'
|
||||
|
|
@ -150,6 +151,10 @@ export class SceneGraph {
|
|||
return Variables.getActiveModeId(this, collectionId)
|
||||
}
|
||||
|
||||
getNodeVariableModeId(nodeId: string, collectionId: string): string {
|
||||
return Variables.getNodeVariableModeId(this, nodeId, collectionId)
|
||||
}
|
||||
|
||||
setActiveMode(collectionId: string, modeId: string): void {
|
||||
Variables.setActiveMode(this, collectionId, modeId)
|
||||
}
|
||||
|
|
@ -186,6 +191,14 @@ export class SceneGraph {
|
|||
return Variables.resolveNumberVariable(this, variableId)
|
||||
}
|
||||
|
||||
resolveColorVariableForNode(nodeId: string, variableId: string): Color | undefined {
|
||||
return Variables.resolveColorVariableForNode(this, nodeId, variableId)
|
||||
}
|
||||
|
||||
resolveNumberVariableForNode(nodeId: string, variableId: string): number | undefined {
|
||||
return Variables.resolveNumberVariableForNode(this, nodeId, variableId)
|
||||
}
|
||||
|
||||
getVariablesForCollection(collectionId: string): Variable[] {
|
||||
return Variables.getVariablesForCollection(this, collectionId)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ const INSTANCE_SYNC_PROPS: (keyof SceneNode)[] = [
|
|||
'borderRightWeight',
|
||||
'borderBottomWeight',
|
||||
'borderLeftWeight',
|
||||
'boundVariables'
|
||||
'boundVariables',
|
||||
'variableModes'
|
||||
]
|
||||
|
||||
function setSceneProp<K extends keyof SceneNode>(
|
||||
|
|
@ -68,6 +69,8 @@ function copyProp(
|
|||
} else if (key === 'boundVariables') {
|
||||
// Shallow copy the binding map — values are variable IDs (strings), not objects
|
||||
setSceneProp(target, key, { ...source.boundVariables })
|
||||
} else if (key === 'variableModes') {
|
||||
setSceneProp(target, key, { ...source.variableModes })
|
||||
} else if (key === 'gridPosition') {
|
||||
// Shallow copy the grid position object — all fields are primitives
|
||||
setSceneProp(target, key, source.gridPosition ? { ...source.gridPosition } : null)
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ export function createDefaultNode(
|
|||
symbolLinks: [],
|
||||
variantPropSpecs: [],
|
||||
boundVariables: {},
|
||||
variableModes: {},
|
||||
exportSettings: [],
|
||||
pluginData: [],
|
||||
pluginRelaunchData: [],
|
||||
|
|
|
|||
|
|
@ -499,6 +499,7 @@ export interface SceneNode {
|
|||
variantPropSpecs: VariantPropSpec[]
|
||||
|
||||
boundVariables: Record<string, string>
|
||||
variableModes: VariableModeMap
|
||||
exportSettings: ExportSetting[]
|
||||
|
||||
pluginData: PluginDataEntry[]
|
||||
|
|
@ -533,6 +534,7 @@ export interface ComponentPropertyDefinition {
|
|||
|
||||
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
|
||||
export type VariableValue = Color | number | string | boolean | { aliasId: string }
|
||||
export type VariableModeMap = Record<string, string>
|
||||
|
||||
export interface Variable {
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -113,6 +113,20 @@ export function getActiveModeId(graph: SceneGraph, collectionId: string): string
|
|||
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)
|
||||
}
|
||||
|
|
@ -214,6 +228,31 @@ export function resolveNumberVariable(graph: SceneGraph, variableId: string): nu
|
|||
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 []
|
||||
|
|
|
|||
|
|
@ -7,6 +7,82 @@ import { expectDefined } from '#tests/helpers/assert'
|
|||
import { canvas, doc, node } from './helpers'
|
||||
|
||||
describe('fig-import: variable asset refs', () => {
|
||||
test('imports node-scoped modes and native paint aliases', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
canvas(),
|
||||
{
|
||||
...node('VARIABLE_SET', 20, 1),
|
||||
name: 'Theme',
|
||||
variableSetModes: [
|
||||
{ id: { sessionID: 10, localID: 1 }, name: 'Light' },
|
||||
{ id: { sessionID: 10, localID: 2 }, name: 'Dark' }
|
||||
]
|
||||
} as NodeChange,
|
||||
{
|
||||
...node('VARIABLE', 21, 1),
|
||||
name: 'Background',
|
||||
variableSetID: { guid: { sessionID: 1, localID: 20 } },
|
||||
variableResolvedType: 'COLOR',
|
||||
variableDataValues: {
|
||||
entries: [
|
||||
{
|
||||
modeID: { sessionID: 10, localID: 1 },
|
||||
variableData: {
|
||||
dataType: 'COLOR',
|
||||
resolvedDataType: 'COLOR',
|
||||
value: { colorValue: { r: 1, g: 1, b: 1, a: 1 } }
|
||||
}
|
||||
},
|
||||
{
|
||||
modeID: { sessionID: 10, localID: 2 },
|
||||
variableData: {
|
||||
dataType: 'COLOR',
|
||||
resolvedDataType: 'COLOR',
|
||||
value: { colorValue: { r: 0, g: 0, b: 0, a: 1 } }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as NodeChange,
|
||||
node('FRAME', 30, 1, {
|
||||
name: 'Dark scope',
|
||||
variableModeBySetMap: {
|
||||
entries: [
|
||||
{
|
||||
variableSetID: { guid: { sessionID: 1, localID: 20 } },
|
||||
variableModeID: { sessionID: 10, localID: 2 }
|
||||
}
|
||||
]
|
||||
},
|
||||
fillPaints: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 1, b: 1, a: 1 },
|
||||
colorVar: {
|
||||
value: { alias: { guid: { sessionID: 1, localID: 21 } } },
|
||||
dataType: 'ALIAS',
|
||||
resolvedDataType: 'COLOR'
|
||||
}
|
||||
}
|
||||
] as NodeChange['fillPaints']
|
||||
})
|
||||
])
|
||||
|
||||
const frame = expectDefined(
|
||||
[...graph.getAllNodes()].find((candidate) => candidate.name === 'Dark scope'),
|
||||
'dark scope'
|
||||
)
|
||||
expect(frame.variableModes).toEqual({ '1:20': '10:2' })
|
||||
expect(frame.boundVariables['fills/0/color']).toBe('1:21')
|
||||
expect(graph.resolveColorVariableForNode(frame.id, '1:21')).toEqual({
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves color variables and aliases by assetRef', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(),
|
||||
|
|
|
|||
|
|
@ -117,6 +117,75 @@ describe('variable roundtrip', () => {
|
|||
expect(Object.keys(reimportedRect.boundVariables)).toContain('strokes/0/color')
|
||||
})
|
||||
|
||||
test('node-scoped variable modes survive export → re-import', async () => {
|
||||
await initCodec()
|
||||
|
||||
const graph = new SceneGraph()
|
||||
graph.addCollection({
|
||||
id: '4:55',
|
||||
name: 'Theme',
|
||||
modes: [
|
||||
{ modeId: '4:1', name: 'Light' },
|
||||
{ modeId: '4:2', name: 'Dark' }
|
||||
],
|
||||
defaultModeId: '4:1',
|
||||
variableIds: []
|
||||
})
|
||||
graph.addVariable({
|
||||
id: '5:1',
|
||||
name: 'Background',
|
||||
type: 'COLOR',
|
||||
collectionId: '4:55',
|
||||
valuesByMode: {
|
||||
'4:1': { r: 1, g: 1, b: 1, a: 1 },
|
||||
'4:2': { r: 0, g: 0, b: 0, a: 1 }
|
||||
},
|
||||
description: '',
|
||||
hiddenFromPublishing: false
|
||||
})
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.createNode('FRAME', page.id, {
|
||||
name: 'Dark scope',
|
||||
variableModes: { '4:55': '4:2' }
|
||||
})
|
||||
graph.createNode('RECTANGLE', frame.id, { name: 'Scoped child' })
|
||||
|
||||
const exported = await exportFigFile(graph)
|
||||
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
|
||||
const importedFrame = expectDefined(
|
||||
[...reimported.getAllNodes()].find((node) => node.name === 'Dark scope'),
|
||||
'dark scope'
|
||||
)
|
||||
const importedChild = expectDefined(
|
||||
[...reimported.getAllNodes()].find((node) => node.name === 'Scoped child'),
|
||||
'scoped child'
|
||||
)
|
||||
|
||||
const importedBackground = expectDefined(
|
||||
[...reimported.variables.values()].find((variable) => variable.name === 'Background'),
|
||||
'background variable'
|
||||
)
|
||||
const importedCollection = expectDefined(
|
||||
reimported.variableCollections.get(importedBackground.collectionId),
|
||||
'theme collection'
|
||||
)
|
||||
const importedDarkMode = expectDefined(
|
||||
importedCollection.modes.find((mode) => mode.name === 'Dark'),
|
||||
'dark mode'
|
||||
)
|
||||
expect(importedFrame.variableModes).toEqual({
|
||||
[importedCollection.id]: importedDarkMode.modeId
|
||||
})
|
||||
expect(reimported.resolveColorVariableForNode(importedChild.id, importedBackground.id)).toEqual(
|
||||
{
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.if(runsHeavyTests)(
|
||||
'material3.fig variables survive round-trip',
|
||||
async () => {
|
||||
|
|
|
|||
51
tests/engine/render/canvas/variable-modes.test.ts
Normal file
51
tests/engine/render/canvas/variable-modes.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph, type Fill } from '@open-pencil/scene-graph'
|
||||
|
||||
import { resolveFillColor } from '#core/canvas/renderer/colors'
|
||||
|
||||
const LIGHT = { r: 1, g: 1, b: 1, a: 1 }
|
||||
const DARK = { r: 0.04, g: 0.04, b: 0.05, a: 1 }
|
||||
const BOUND_FILL: Fill = {
|
||||
type: 'SOLID',
|
||||
color: LIGHT,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
|
||||
describe('node-scoped variable modes', () => {
|
||||
test('renderer resolves paint bindings through the nearest mode scope', () => {
|
||||
const graph = new SceneGraph()
|
||||
graph.addCollection({
|
||||
id: 'theme',
|
||||
name: 'Theme',
|
||||
modes: [
|
||||
{ modeId: 'light', name: 'Light' },
|
||||
{ modeId: 'dark', name: 'Dark' }
|
||||
],
|
||||
defaultModeId: 'light',
|
||||
variableIds: []
|
||||
})
|
||||
graph.addVariable({
|
||||
id: 'background',
|
||||
name: 'Background',
|
||||
type: 'COLOR',
|
||||
collectionId: 'theme',
|
||||
valuesByMode: { light: LIGHT, dark: DARK },
|
||||
description: '',
|
||||
hiddenFromPublishing: false
|
||||
})
|
||||
|
||||
const page = graph.addPage('Page')
|
||||
const darkFrame = graph.createNode('FRAME', page.id, {
|
||||
variableModes: { theme: 'dark' }
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', darkFrame.id, {
|
||||
fills: [BOUND_FILL],
|
||||
boundVariables: { 'fills/0/color': 'background' }
|
||||
})
|
||||
|
||||
expect(resolveFillColor(child.fills[0], 0, child, graph)).toEqual(DARK)
|
||||
})
|
||||
})
|
||||
|
|
@ -113,6 +113,52 @@ describe('Variables', () => {
|
|||
expect(graph.resolveColorVariable('v1')).toEqual({ r: 0, g: 0, b: 0, a: 1 })
|
||||
})
|
||||
|
||||
test('node variable modes inherit from the nearest ancestor', () => {
|
||||
const graph = new SceneGraph()
|
||||
graph.addCollection({
|
||||
id: 'col1',
|
||||
name: 'Theme',
|
||||
modes: [
|
||||
{ modeId: 'light', name: 'Light' },
|
||||
{ modeId: 'dark', name: 'Dark' }
|
||||
],
|
||||
defaultModeId: 'light',
|
||||
variableIds: []
|
||||
})
|
||||
graph.addVariable({
|
||||
id: 'v1',
|
||||
name: 'bg',
|
||||
type: 'COLOR',
|
||||
collectionId: 'col1',
|
||||
valuesByMode: {
|
||||
light: { r: 1, g: 1, b: 1, a: 1 },
|
||||
dark: { r: 0, g: 0, b: 0, a: 1 }
|
||||
},
|
||||
description: '',
|
||||
hiddenFromPublishing: false
|
||||
})
|
||||
|
||||
const page = graph.addPage('Page')
|
||||
const frame = graph.createNode('FRAME', page.id, { variableModes: { col1: 'dark' } })
|
||||
const child = graph.createNode('RECTANGLE', frame.id)
|
||||
const nestedOverride = graph.createNode('RECTANGLE', frame.id, {
|
||||
variableModes: { col1: 'light' }
|
||||
})
|
||||
|
||||
expect(graph.resolveColorVariableForNode(child.id, 'v1')).toEqual({
|
||||
r: 0,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 1
|
||||
})
|
||||
expect(graph.resolveColorVariableForNode(nestedOverride.id, 'v1')).toEqual({
|
||||
r: 1,
|
||||
g: 1,
|
||||
b: 1,
|
||||
a: 1
|
||||
})
|
||||
})
|
||||
|
||||
test('missing active mode falls back to default value', () => {
|
||||
const graph = new SceneGraph()
|
||||
graph.addCollection({
|
||||
|
|
|
|||
Loading…
Reference in a new issue