feat(components): add variant property data model and core actions

Data model:
- Add ComponentPropertyDefinition and ComponentPropertyType to SceneNode
- Add componentPropertyValues for per-variant property values
- Populate from .fig import via componentPropDefs kiwi field
- Parse variant name=value syntax from component names on import

Core actions (packages/core/src/editor/components/variants.ts):
- Add/remove/rename variant property definitions with undo
- Collect variant options from component set children
- Find variant by property values
- Switch instance variant by changing a property value
- Parse and build variant name strings

Combine as variants:
- Parse slash-naming (Button/Primary/Large) into variant properties
- Auto-detect property columns from consistent slash counts
- Populate componentPropertyValues on each variant component

Ref #239
This commit is contained in:
Danila Poyarkov 2026-05-07 16:12:46 +03:00
parent ad88364562
commit fee44ebdb7
5 changed files with 348 additions and 5 deletions

View file

@ -1,7 +1,9 @@
import type { SceneNode } from '#core/scene-graph'
import { randomHex } from '#core/random'
import type { ComponentPropertyDefinition, SceneNode } from '#core/scene-graph'
import { createComponentFocusActions } from './components/focus'
import { createComponentInstanceActions } from './components/instances'
import { createVariantActions } from './components/variants'
import type { EditorContext } from './types'
export function createComponentActions(ctx: EditorContext) {
@ -54,16 +56,57 @@ export function createComponentActions(ctx: EditorContext) {
) {
if (selectedNodes.length < 2) return
if (!selectedNodes.every((n) => n.type === 'COMPONENT')) return
wrapSelectionInContainer('COMPONENT_SET', selectedNodes)
const containerId = wrapSelectionInContainer('COMPONENT_SET', selectedNodes)
if (!containerId) return
const slashCounts = selectedNodes.map((n) => (n.name.match(/\//g) ?? []).length)
const hasConsistentSlashes =
slashCounts.every((c) => c === slashCounts[0]) && slashCounts[0] > 0
if (hasConsistentSlashes) {
const propCount = slashCounts[0]
const propDefs: ComponentPropertyDefinition[] = []
const propValues = new Map<string, Set<string>>()
for (let i = 0; i < propCount; i++) {
const propId = `prop:${randomHex(8)}`
const propName = i === 0 ? 'Variant' : `Property ${i + 1}`
propDefs.push({ id: propId, name: propName, type: 'VARIANT', defaultValue: '' })
propValues.set(propName, new Set())
}
for (const node of selectedNodes) {
const parts = node.name.split('/').slice(1)
const values: Record<string, string> = {}
for (let i = 0; i < propDefs.length; i++) {
const value = parts[i]?.trim() ?? ''
values[propDefs[i].name] = value
propValues.get(propDefs[i].name)?.add(value)
}
ctx.graph.updateNode(node.id, {
componentPropertyValues: values,
name: Object.values(values).join(', ')
})
}
for (const def of propDefs) {
def.variantOptions = [...(propValues.get(def.name) ?? [])]
if (!def.defaultValue && def.variantOptions[0]) def.defaultValue = def.variantOptions[0]
}
ctx.graph.updateNode(containerId, { componentPropertyDefinitions: propDefs })
}
}
const focusActions = createComponentFocusActions(ctx)
const instanceActions = createComponentInstanceActions(ctx)
const variantActions = createVariantActions(ctx)
return {
createComponentFromSelection,
createComponentSetFromComponents,
...instanceActions,
...focusActions
...focusActions,
...variantActions
}
}

View file

@ -0,0 +1,243 @@
import type { EditorContext } from '#core/editor/types'
import { randomHex } from '#core/random'
import type {
ComponentPropertyDefinition,
ComponentPropertyType,
SceneNode
} from '#core/scene-graph'
export function createVariantActions(ctx: EditorContext) {
function getComponentSetPropertyDefs(componentSetId: string): ComponentPropertyDefinition[] {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return []
return node.componentPropertyDefinitions
}
function addPropertyDefinition(
componentSetId: string,
name: string,
type: ComponentPropertyType = 'VARIANT',
defaultValue = ''
): string | undefined {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return undefined
const id = `prop:${randomHex(8)}`
const def: ComponentPropertyDefinition = {
id,
name,
type,
defaultValue,
variantOptions: type === 'VARIANT' ? [defaultValue] : undefined
}
const prevDefs = [...node.componentPropertyDefinitions]
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: [...prevDefs, def]
})
ctx.undo.push({
label: 'Add property',
forward: () => {
const n = ctx.graph.getNode(componentSetId)
if (n) {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: [...n.componentPropertyDefinitions, def]
})
}
ctx.requestRender()
},
inverse: () => {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: prevDefs
})
ctx.requestRender()
}
})
ctx.requestRender()
return id
}
function removePropertyDefinition(componentSetId: string, propertyId: string) {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return
const prevDefs = [...node.componentPropertyDefinitions]
const def = prevDefs.find((d) => d.id === propertyId)
if (!def) return
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: prevDefs.filter((d) => d.id !== propertyId)
})
for (const childId of node.childIds) {
const child = ctx.graph.getNode(childId)
if (!child) continue
const values = { ...child.componentPropertyValues }
delete values[def.name]
ctx.graph.updateNode(childId, { componentPropertyValues: values })
}
ctx.undo.push({
label: 'Remove property',
forward: () => {
const n = ctx.graph.getNode(componentSetId)
if (n) {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: n.componentPropertyDefinitions.filter(
(d) => d.id !== propertyId
)
})
for (const cid of n.childIds) {
const c = ctx.graph.getNode(cid)
if (!c) continue
const v = { ...c.componentPropertyValues }
delete v[def.name]
ctx.graph.updateNode(cid, { componentPropertyValues: v })
}
}
ctx.requestRender()
},
inverse: () => {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: prevDefs
})
ctx.requestRender()
}
})
ctx.requestRender()
}
function renamePropertyDefinition(componentSetId: string, propertyId: string, newName: string) {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return
const def = node.componentPropertyDefinitions.find((d) => d.id === propertyId)
if (!def) return
const prevName = def.name
const newDefs = node.componentPropertyDefinitions.map((d) =>
d.id === propertyId ? { ...d, name: newName } : d
)
ctx.graph.updateNode(componentSetId, { componentPropertyDefinitions: newDefs })
for (const childId of node.childIds) {
const child = ctx.graph.getNode(childId)
if (!child) continue
const values = { ...child.componentPropertyValues }
if (prevName in values) {
values[newName] = values[prevName]
delete values[prevName]
ctx.graph.updateNode(childId, { componentPropertyValues: values })
}
}
ctx.undo.push({
label: 'Rename property',
forward: () => {
const n = ctx.graph.getNode(componentSetId)
if (n) {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: n.componentPropertyDefinitions.map((d) =>
d.id === propertyId ? { ...d, name: newName } : d
)
})
}
ctx.requestRender()
},
inverse: () => {
const n = ctx.graph.getNode(componentSetId)
if (n) {
ctx.graph.updateNode(componentSetId, {
componentPropertyDefinitions: n.componentPropertyDefinitions.map((d) =>
d.id === propertyId ? { ...d, name: prevName } : d
)
})
}
ctx.requestRender()
}
})
ctx.requestRender()
}
function parseVariantName(name: string): Record<string, string> {
const values: Record<string, string> = {}
for (const part of name.split(',').map((s) => s.trim())) {
const eqIdx = part.indexOf('=')
if (eqIdx === -1) continue
values[part.slice(0, eqIdx).trim()] = part.slice(eqIdx + 1).trim()
}
return values
}
function buildVariantName(values: Record<string, string>): string {
return Object.entries(values)
.map(([k, v]) => `${k}=${v}`)
.join(', ')
}
function collectVariantOptions(componentSetId: string): Map<string, Set<string>> {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return new Map()
const options = new Map<string, Set<string>>()
for (const childId of node.childIds) {
const child = ctx.graph.getNode(childId)
if (child?.type !== 'COMPONENT') continue
for (const [key, value] of Object.entries(child.componentPropertyValues)) {
const set = options.get(key) ?? new Set()
set.add(value)
options.set(key, set)
}
}
return options
}
function findVariantByValues(
componentSetId: string,
values: Record<string, string>
): SceneNode | undefined {
const node = ctx.graph.getNode(componentSetId)
if (node?.type !== 'COMPONENT_SET') return undefined
for (const childId of node.childIds) {
const child = ctx.graph.getNode(childId)
if (child?.type !== 'COMPONENT') continue
const childValues = child.componentPropertyValues
const matches = Object.entries(values).every(([k, v]) => childValues[k] === v)
if (matches) return child
}
return undefined
}
function switchInstanceVariant(instanceId: string, propertyName: string, newValue: string) {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE' || !instance.componentId) return
const component = ctx.graph.getNode(instance.componentId)
if (!component) return
const componentSetId = component.parentId
if (!componentSetId) return
const componentSet = ctx.graph.getNode(componentSetId)
if (componentSet?.type !== 'COMPONENT_SET') return
const currentValues = { ...component.componentPropertyValues }
currentValues[propertyName] = newValue
const target = findVariantByValues(componentSetId, currentValues)
if (!target || target.id === instance.componentId) return
const prevComponentId = instance.componentId
ctx.graph.updateNode(instanceId, { componentId: target.id })
ctx.undo.push({
label: 'Switch variant',
forward: () => {
ctx.graph.updateNode(instanceId, { componentId: target.id })
ctx.requestRender()
},
inverse: () => {
ctx.graph.updateNode(instanceId, { componentId: prevComponentId })
ctx.requestRender()
}
})
ctx.requestRender()
}
return {
getComponentSetPropertyDefs,
addPropertyDefinition,
removePropertyDefinition,
renamePropertyDefinition,
parseVariantName,
buildVariantName,
collectVariantOptions,
findVariantByValues,
switchInstanceVariant
}
}

View file

@ -37,7 +37,9 @@ import type {
TextAlignVertical,
TextCase,
ArcData,
VectorNetwork
VectorNetwork,
ComponentPropertyDefinition,
ComponentPropertyType
} from '#core/scene-graph'
import type { GUID } from '#core/types'
@ -432,10 +434,51 @@ export function nodeChangeToProps(
pluginData: extractPluginData(nc),
pluginRelaunchData: extractPluginRelaunchData(nc),
clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true,
componentId: extractSymbolId(nc)
componentId: extractSymbolId(nc),
componentPropertyDefinitions: extractComponentPropertyDefs(nc),
componentPropertyValues: extractComponentPropertyValues(nc)
}
}
const COMPONENT_PROP_TYPE_MAP: Record<string, ComponentPropertyType> = {
VARIANT: 'VARIANT',
TEXT: 'TEXT',
BOOLEAN: 'BOOLEAN',
INSTANCE_SWAP: 'INSTANCE_SWAP'
}
function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefinition[] {
const defs = nc.componentPropDefs as
| Array<{ id?: GUID; name?: string; type?: string; initialValue?: { textValue?: string } }>
| undefined
if (!defs?.length) return []
const result: ComponentPropertyDefinition[] = []
for (const def of defs) {
if (!def.id || !def.name) continue
const propType = COMPONENT_PROP_TYPE_MAP[def.type ?? ''] ?? 'VARIANT'
result.push({
id: guidToString(def.id),
name: def.name,
type: propType,
defaultValue: def.initialValue?.textValue ?? '',
variantOptions: propType === 'VARIANT' ? undefined : undefined
})
}
return result
}
function extractComponentPropertyValues(nc: NodeChange): Record<string, string> {
const name = nc.name
if (!name?.includes('=')) return {}
const values: Record<string, string> = {}
for (const part of name.split(',').map((s) => s.trim())) {
const eqIdx = part.indexOf('=')
if (eqIdx === -1) continue
values[part.slice(0, eqIdx).trim()] = part.slice(eqIdx + 1).trim()
}
return values
}
function isComponentSet(nc: NodeChange): boolean {
const defs = nc.componentPropDefs as Array<{ type?: string }> | undefined
if (!defs?.length) return false

View file

@ -104,6 +104,8 @@ export function createDefaultNode(
starInnerRadius: 0.38,
componentId: null,
overrides: {},
componentPropertyDefinitions: [],
componentPropertyValues: {},
boundVariables: {},
pluginData: [],
pluginRelaunchData: [],

View file

@ -325,6 +325,8 @@ export interface SceneNode {
componentId: string | null
overrides: Record<string, unknown>
componentPropertyDefinitions: ComponentPropertyDefinition[]
componentPropertyValues: Record<string, string>
boundVariables: Record<string, string>
@ -339,6 +341,16 @@ export interface SceneNode {
textPicture: Uint8Array | null
}
export type ComponentPropertyType = 'VARIANT' | 'TEXT' | 'BOOLEAN' | 'INSTANCE_SWAP'
export interface ComponentPropertyDefinition {
id: string
name: string
type: ComponentPropertyType
defaultValue: string
variantOptions?: string[]
}
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
export type VariableValue = Color | number | string | boolean | { aliasId: string }