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:
parent
ad88364562
commit
fee44ebdb7
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
243
packages/core/src/editor/components/variants.ts
Normal file
243
packages/core/src/editor/components/variants.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ export function createDefaultNode(
|
|||
starInnerRadius: 0.38,
|
||||
componentId: null,
|
||||
overrides: {},
|
||||
componentPropertyDefinitions: [],
|
||||
componentPropertyValues: {},
|
||||
boundVariables: {},
|
||||
pluginData: [],
|
||||
pluginRelaunchData: [],
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue