From fee44ebdb74d2325c8eebb8da79d428c8c3324e4 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 7 May 2026 16:12:46 +0300 Subject: [PATCH] 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 --- packages/core/src/editor/components.ts | 49 +++- .../core/src/editor/components/variants.ts | 243 ++++++++++++++++++ packages/core/src/kiwi/node-change/convert.ts | 47 +++- .../core/src/scene-graph/node-defaults.ts | 2 + packages/core/src/scene-graph/types.ts | 12 + 5 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/editor/components/variants.ts diff --git a/packages/core/src/editor/components.ts b/packages/core/src/editor/components.ts index 3af0eb73b..b17ba0894 100644 --- a/packages/core/src/editor/components.ts +++ b/packages/core/src/editor/components.ts @@ -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>() + + 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 = {} + 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 } } diff --git a/packages/core/src/editor/components/variants.ts b/packages/core/src/editor/components/variants.ts new file mode 100644 index 000000000..637cf9e5a --- /dev/null +++ b/packages/core/src/editor/components/variants.ts @@ -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 { + const values: Record = {} + 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 { + return Object.entries(values) + .map(([k, v]) => `${k}=${v}`) + .join(', ') + } + + function collectVariantOptions(componentSetId: string): Map> { + const node = ctx.graph.getNode(componentSetId) + if (node?.type !== 'COMPONENT_SET') return new Map() + const options = new Map>() + 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 + ): 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 + } +} diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index cd63f30d2..66ee36155 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -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 = { + 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 { + const name = nc.name + if (!name?.includes('=')) return {} + const values: Record = {} + 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 diff --git a/packages/core/src/scene-graph/node-defaults.ts b/packages/core/src/scene-graph/node-defaults.ts index 1b44d6785..2228a783d 100644 --- a/packages/core/src/scene-graph/node-defaults.ts +++ b/packages/core/src/scene-graph/node-defaults.ts @@ -104,6 +104,8 @@ export function createDefaultNode( starInnerRadius: 0.38, componentId: null, overrides: {}, + componentPropertyDefinitions: [], + componentPropertyValues: {}, boundVariables: {}, pluginData: [], pluginRelaunchData: [], diff --git a/packages/core/src/scene-graph/types.ts b/packages/core/src/scene-graph/types.ts index 7d2c22b02..b58ddbf37 100644 --- a/packages/core/src/scene-graph/types.ts +++ b/packages/core/src/scene-graph/types.ts @@ -325,6 +325,8 @@ export interface SceneNode { componentId: string | null overrides: Record + componentPropertyDefinitions: ComponentPropertyDefinition[] + componentPropertyValues: Record boundVariables: Record @@ -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 }