Merge branch 'open-pencil:master' into master

This commit is contained in:
Sadko 2026-07-17 15:09:35 -04:00 committed by GitHub
commit 1151383de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1371 additions and 94 deletions

View file

@ -19,6 +19,7 @@
- Add mixed-selection stroke cap, join, and miter-limit controls with CanvasKit rendering and `.fig` roundtrip support.
- Add a mixed-selection corner-smoothing percentage control with live preview, per-node undo restoration, and `.fig` roundtrip coverage.
- Model imported fill, stroke, text, effect, and grid styles with reusable SDK/app selectors, automatic detach-on-edit, undo, and `.fig` definition roundtrips.
- Add variant, text, boolean, and nested instance-swap component property controls with mixed-selection undo and typed `.fig` metadata roundtrips.
- Standardize Pages, Layers/Assets navigation, and document tab states across light and dark themes.
- Standardize Vue SDK and app override type names on the `UI` acronym, including `FontPickerUI`.
- Add a headless Vue SDK NumberField with pointer scrubbing, keyboard stepping, safe arithmetic expressions, and mixed/bound states; remove the superseded ScrubInput API.

View file

@ -37,6 +37,9 @@ export function createComponentBridge(
findVariantByValues: components.findVariantByValues,
getDefaultVariantForComponentSet: components.getDefaultVariantForComponentSet,
getComponentSetVariantConflicts: components.getComponentSetVariantConflicts,
switchInstanceVariant: components.switchInstanceVariant
switchInstanceVariant: components.switchInstanceVariant,
getInstanceComponentPropertyDefinitions: components.getInstanceComponentPropertyDefinitions,
getInstanceComponentPropertyValue: components.getInstanceComponentPropertyValue,
setInstanceComponentProperty: components.setInstanceComponentProperty
}
}

View file

@ -4,6 +4,7 @@ import { randomHex } from '#core/random'
import { createComponentFocusActions } from './components/focus'
import { createComponentInstanceActions } from './components/instances'
import { createComponentPropertyActions } from './components/properties'
import { createVariantActions } from './components/variants'
import type { EditorContext } from './types'
@ -102,12 +103,17 @@ export function createComponentActions(ctx: EditorContext) {
const focusActions = createComponentFocusActions(ctx)
const instanceActions = createComponentInstanceActions(ctx)
const variantActions = createVariantActions(ctx)
const componentPropertyActions = createComponentPropertyActions(
ctx,
variantActions.switchInstanceVariant
)
return {
createComponentFromSelection,
createComponentSetFromComponents,
...instanceActions,
...focusActions,
...variantActions
...variantActions,
...componentPropertyActions
}
}

View file

@ -0,0 +1,253 @@
import type {
ComponentPropertyDefinition,
ComponentPropertyReferenceField,
SceneNode
} from '@open-pencil/scene-graph'
import type { EditorContext } from '#core/editor/types'
interface PropertyTarget {
node: SceneNode
field: ComponentPropertyReferenceField
source: SceneNode
}
function definitionOwners(ctx: EditorContext, instance: SceneNode): SceneNode[] {
if (!instance.componentId) return []
const component = ctx.graph.getNode(instance.componentId)
if (!component) return []
const parent = component.parentId ? ctx.graph.getNode(component.parentId) : null
return parent?.type === 'COMPONENT_SET' ? [parent, component] : [component]
}
function definitionsForInstance(
ctx: EditorContext,
instance: SceneNode
): ComponentPropertyDefinition[] {
const byId = new Map<string, ComponentPropertyDefinition>()
for (const owner of definitionOwners(ctx, instance)) {
for (const definition of owner.componentPropertyDefinitions) {
if (!byId.has(definition.id)) byId.set(definition.id, definition)
}
}
return [...byId.values()]
}
function findPropertyPath(
ctx: EditorContext,
sourceParent: SceneNode,
propertyId: string,
path: number[] = []
): { path: number[]; field: ComponentPropertyReferenceField; source: SceneNode } | null {
for (const [index, childId] of sourceParent.childIds.entries()) {
const child = ctx.graph.getNode(childId)
if (!child) continue
const reference = child.componentPropertyReferences.find((ref) => ref.propertyId === propertyId)
if (reference) return { path: [...path, index], field: reference.field, source: child }
const nested = findPropertyPath(ctx, child, propertyId, [...path, index])
if (nested) return nested
}
return null
}
function nodeAtPath(ctx: EditorContext, root: SceneNode, path: number[]): SceneNode | null {
let node = root
for (const index of path) {
const childId = node.childIds[index]
const child = childId ? ctx.graph.getNode(childId) : undefined
if (!child) return null
node = child
}
return node
}
function propertyTarget(
ctx: EditorContext,
instance: SceneNode,
propertyId: string
): PropertyTarget | null {
const component = instance.componentId ? ctx.graph.getNode(instance.componentId) : null
if (!component) return null
const match = findPropertyPath(ctx, component, propertyId)
if (!match) return null
const node = nodeAtPath(ctx, instance, match.path)
return node ? { node, field: match.field, source: match.source } : null
}
function swapTargetId(ctx: EditorContext, value: string): string | null {
const direct = ctx.graph.getNode(value)
if (direct?.type === 'COMPONENT') return direct.id
for (const node of ctx.graph.getAllNodes()) {
if (node.type !== 'COMPONENT') continue
if (
node.source.id === value ||
node.componentKey === value ||
node.sourceLibraryKey === value
) {
return node.id
}
}
return null
}
function targetValue(target: PropertyTarget | null): string {
if (!target) return ''
if (target.field === 'TEXT') return target.node.text
if (target.field === 'VISIBLE') return String(target.node.visible)
return target.source.componentId ?? target.node.componentId ?? ''
}
function propertyOverrides(
ctx: EditorContext,
instance: SceneNode,
target: PropertyTarget | null,
value: string,
swapComponentId: string | null
): Record<string, unknown> {
const overrides = { ...instance.overrides }
if (target?.field === 'TEXT') overrides[`${target.node.id}:text`] = value
else if (target?.field === 'VISIBLE') overrides[`${target.node.id}:visible`] = value === 'true'
else if (target?.field === 'INSTANCE_SWAP') {
overrides[`${target.node.id}:componentId`] = value
overrides[`${target.node.id}:sourceComponentId`] = target.source.id
const componentName = swapComponentId ? ctx.graph.getNode(swapComponentId)?.name : undefined
if (componentName) overrides[`${target.node.id}:name`] = componentName
}
return overrides
}
function updatePropertyTarget(
ctx: EditorContext,
target: PropertyTarget | null,
value: string,
swapComponentId: string | null
): void {
if (target?.field === 'TEXT' && target.node.type === 'TEXT') {
ctx.graph.updateNode(target.node.id, { text: value })
} else if (target?.field === 'VISIBLE') {
ctx.graph.updateNode(target.node.id, { visible: value === 'true' })
} else if (
target?.field === 'INSTANCE_SWAP' &&
target.node.type === 'INSTANCE' &&
swapComponentId
) {
ctx.graph.swapInstanceComponent(target.node.id, swapComponentId)
}
}
function applyPropertyValue(
ctx: EditorContext,
instanceId: string,
definition: ComponentPropertyDefinition,
value: string
): void {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE') return
const target = propertyTarget(ctx, instance, definition.id)
const swapComponentId = target?.field === 'INSTANCE_SWAP' ? swapTargetId(ctx, value) : null
ctx.graph.updateNode(instance.id, {
componentPropertyAssignments: {
...instance.componentPropertyAssignments,
[definition.id]: value
},
overrides: propertyOverrides(ctx, instance, target, value, swapComponentId)
})
updatePropertyTarget(ctx, target, value, swapComponentId)
}
export function reapplyInstanceComponentProperties(ctx: EditorContext, instanceId: string): void {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE') return
const definitions = new Map(
definitionsForInstance(ctx, instance).map((definition) => [definition.id, definition])
)
for (const [propertyId, value] of Object.entries(instance.componentPropertyAssignments)) {
const definition = definitions.get(propertyId)
if (definition && definition.type !== 'VARIANT') {
applyPropertyValue(ctx, instanceId, definition, value)
}
}
}
export function createComponentPropertyActions(
ctx: EditorContext,
switchVariant: (instanceId: string, propertyName: string, newValue: string) => void
) {
function getInstanceComponentPropertyDefinitions(instanceId: string) {
const instance = ctx.graph.getNode(instanceId)
return instance?.type === 'INSTANCE' ? definitionsForInstance(ctx, instance) : []
}
function getInstanceComponentPropertyValue(
instanceId: string,
definition: ComponentPropertyDefinition
): string {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE') return definition.defaultValue
if (definition.type === 'VARIANT') {
const component = instance.componentId ? ctx.graph.getNode(instance.componentId) : null
return component?.componentPropertyValues[definition.name] ?? definition.defaultValue
}
const value = instance.componentPropertyAssignments[definition.id] ?? definition.defaultValue
return definition.type === 'INSTANCE_SWAP' ? (swapTargetId(ctx, value) ?? value) : value
}
function setInstanceComponentProperty(instanceId: string, propertyId: string, value: string) {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE') return
const definition = definitionsForInstance(ctx, instance).find((item) => item.id === propertyId)
if (!definition) return
if (definition.type === 'VARIANT') {
switchVariant(instanceId, definition.name, value)
return
}
const previousAssignments = { ...instance.componentPropertyAssignments }
const previousOverrides = structuredClone(instance.overrides)
const target = propertyTarget(ctx, instance, propertyId)
const assignedValue = instance.componentPropertyAssignments[propertyId]
const previousValue =
definition.type === 'INSTANCE_SWAP' && assignedValue
? (swapTargetId(ctx, assignedValue) ?? assignedValue)
: targetValue(target)
applyPropertyValue(ctx, instanceId, definition, value)
ctx.undo.push({
label: `Change ${definition.name}`,
forward: () => {
applyPropertyValue(ctx, instanceId, definition, value)
ctx.requestRender()
},
inverse: () => {
const live = ctx.graph.getNode(instanceId)
if (live) {
ctx.graph.updateNode(instanceId, {
componentPropertyAssignments: previousAssignments,
overrides: previousOverrides
})
const restoredTarget = propertyTarget(ctx, live, propertyId)
if (restoredTarget?.field === 'TEXT' && restoredTarget.node.type === 'TEXT') {
ctx.graph.updateNode(restoredTarget.node.id, { text: previousValue })
} else if (restoredTarget?.field === 'VISIBLE') {
ctx.graph.updateNode(restoredTarget.node.id, { visible: previousValue === 'true' })
} else if (restoredTarget?.field === 'INSTANCE_SWAP') {
const componentId = swapTargetId(ctx, previousValue)
if (componentId && restoredTarget.node.type === 'INSTANCE') {
ctx.graph.swapInstanceComponent(restoredTarget.node.id, componentId)
}
}
}
ctx.requestRender()
}
})
ctx.requestRender()
}
return {
getInstanceComponentPropertyDefinitions,
getInstanceComponentPropertyValue,
reapplyInstanceComponentProperties: (instanceId: string) =>
reapplyInstanceComponentProperties(ctx, instanceId),
setInstanceComponentProperty
}
}

View file

@ -7,6 +7,7 @@ import type {
} from '@open-pencil/scene-graph'
import { buildVariantName, parseVariantName } from '@open-pencil/scene-graph/variant-name'
import { reapplyInstanceComponentProperties } from '#core/editor/components/properties'
import type { EditorContext } from '#core/editor/types'
import { randomHex } from '#core/random'
@ -243,14 +244,17 @@ export function createVariantActions(ctx: EditorContext) {
const prevComponentId = instance.componentId
ctx.graph.swapInstanceComponent(instanceId, target.id)
reapplyInstanceComponentProperties(ctx, instanceId)
ctx.undo.push({
label: 'Switch variant',
forward: () => {
ctx.graph.swapInstanceComponent(instanceId, target.id)
reapplyInstanceComponentProperties(ctx, instanceId)
ctx.requestRender()
},
inverse: () => {
ctx.graph.swapInstanceComponent(instanceId, prevComponentId)
reapplyInstanceComponentProperties(ctx, instanceId)
ctx.requestRender()
}
})

View file

@ -48,6 +48,7 @@ import type {
SharedStyleType,
VectorNetwork,
ComponentPropertyDefinition,
ComponentPropertyReference,
ComponentPropertyType,
SymbolLink,
VariantPropSpec
@ -635,6 +636,8 @@ export function nodeChangeToProps(
clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true,
componentId: extractSymbolId(nc),
componentPropertyDefinitions: extractComponentPropertyDefs(nc),
componentPropertyReferences: extractComponentPropertyRefs(nc),
componentPropertyAssignments: extractComponentPropertyAssignments(nc),
componentPropertyValues: extractComponentPropertyValues(nc),
...extractComponentMetadata(nc)
}
@ -668,6 +671,35 @@ interface RawComponentPropDef {
name?: string
type?: string
initialValue?: unknown
preferredValues?: {
stringValues?: string[]
instanceSwapValues?: Array<{ key?: string }>
}
}
interface RawComponentPropRef {
defID?: GUID
componentPropNodeField?: string | number
isDeleted?: boolean
}
interface RawComponentPropValue {
boolValue?: boolean
textValue?: string | { characters?: string }
guidValue?: GUID
}
interface RawComponentPropAssignment {
defID?: GUID
value?: RawComponentPropValue
varValue?: {
value?: {
boolValue?: boolean
textValue?: string
textDataValue?: { characters?: string }
symbolIdValue?: { guid?: GUID }
}
}
}
interface RawSymbolData {
@ -687,12 +719,65 @@ function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefiniti
name: def.name,
type: propType,
defaultValue: componentPropValueToString(def.initialValue),
variantOptions: propType === 'VARIANT' ? undefined : undefined
variantOptions: propType === 'VARIANT' ? def.preferredValues?.stringValues : undefined,
preferredValues:
propType === 'INSTANCE_SWAP'
? def.preferredValues?.instanceSwapValues
?.map((value) => value.key)
.filter((value): value is string => value !== undefined)
: undefined
})
}
return result
}
function extractComponentPropertyRefs(nc: NodeChange): ComponentPropertyReference[] {
const refs = nc.componentPropRefs as RawComponentPropRef[] | undefined
if (!refs?.length) return []
const fieldMap: Record<string, ComponentPropertyReference['field'] | undefined> = {
'0': 'VISIBLE',
'1': 'TEXT',
'2': 'INSTANCE_SWAP',
VISIBLE: 'VISIBLE',
TEXT_DATA: 'TEXT',
OVERRIDDEN_SYMBOL_ID: 'INSTANCE_SWAP'
}
return refs.flatMap((ref) => {
const field = fieldMap[String(ref.componentPropNodeField)]
return ref.defID && field && !ref.isDeleted
? [{ propertyId: guidToString(ref.defID), field }]
: []
})
}
function componentPropertyAssignmentValue(assignment: RawComponentPropAssignment): string {
if (
assignment.value &&
(assignment.value.boolValue !== undefined ||
assignment.value.textValue !== undefined ||
assignment.value.guidValue !== undefined)
) {
return componentPropValueToString(assignment.value)
}
const variableValue = assignment.varValue?.value
if (variableValue?.symbolIdValue?.guid) return guidToString(variableValue.symbolIdValue.guid)
if (variableValue?.boolValue !== undefined) return String(variableValue.boolValue)
if (variableValue?.textValue !== undefined) return variableValue.textValue
return variableValue?.textDataValue?.characters ?? ''
}
function extractComponentPropertyAssignments(nc: NodeChange): Record<string, string> {
const assignments = nc.componentPropAssignments as RawComponentPropAssignment[] | undefined
if (!assignments?.length) return {}
return Object.fromEntries(
assignments.flatMap((assignment) =>
assignment.defID
? [[guidToString(assignment.defID), componentPropertyAssignmentValue(assignment)]]
: []
)
)
}
function extractVariantPropSpecs(nc: NodeChange): VariantPropSpec[] {
const specs = nc.variantPropSpecs as Array<{ propDefId?: GUID; value?: string }> | undefined
if (!specs?.length) return []
@ -881,6 +966,7 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [
'styleIdForEffect',
'styleIdForGrid',
'styleType',
'componentPropAssignments',
'backgroundPaints',
'layoutGrids',
'exportSettings',

View file

@ -1,6 +1,11 @@
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type {
ComponentPropertyDefinition,
ComponentPropertyReferenceField,
SceneGraph,
SceneNode
} from '@open-pencil/scene-graph'
import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
/* eslint-disable max-lines */
@ -122,16 +127,21 @@ function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): P
)
}
function componentPropertyValue(value: string) {
return { textValue: { characters: value } }
}
function componentPropertyTypeForKiwi(type: string) {
if (type === 'BOOLEAN') return 'BOOL'
if (type === 'VARIANT') return 'TEXT'
return type
}
function componentPropertyValue(type: string, value: string, graph: SceneGraph) {
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
if (type === 'INSTANCE_SWAP') {
const target = graph.getNode(value)
const guid = parseGuidOrNull(target?.source.id ?? value)
return guid ? { guidValue: guid } : { textValue: { characters: value } }
}
return { textValue: { characters: value } }
}
function parseGuidOrNull(value: string) {
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
}
@ -503,7 +513,37 @@ function applyInstancePayload(
}
}
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
function componentPropertyPreferredValues(definition: ComponentPropertyDefinition) {
if (definition.type === 'INSTANCE_SWAP' && definition.preferredValues?.length) {
return {
instanceSwapValues: definition.preferredValues.map((key) => ({ type: 'COMPONENT', key }))
}
}
if (definition.type === 'VARIANT' && definition.variantOptions?.length) {
return { stringValues: [...definition.variantOptions] }
}
return undefined
}
function componentPropertyNodeField(field: ComponentPropertyReferenceField): string {
if (field === 'TEXT') return 'TEXT_DATA'
if (field === 'INSTANCE_SWAP') return 'OVERRIDDEN_SYMBOL_ID'
return 'VISIBLE'
}
function findComponentPropertyDefinition(graph: SceneGraph, id: string) {
for (const candidate of graph.getAllNodes()) {
const definition = candidate.componentPropertyDefinitions.find((item) => item.id === id)
if (definition) return definition
}
return undefined
}
function applyComponentMetadata(
context: SceneNodeToKiwiContext,
node: SceneNode,
nc: KiwiNodeChange
): void {
if (node.componentKey) nc.componentKey = node.componentKey
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
@ -526,13 +566,39 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
id,
name: def.name,
type: componentPropertyTypeForKiwi(def.type),
initialValue: componentPropertyValue(def.defaultValue)
initialValue: componentPropertyValue(def.type, def.defaultValue, context.graph),
preferredValues: componentPropertyPreferredValues(def)
}
: null
})
.filter((def): def is NonNullable<typeof def> => def !== null)
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
const componentPropRefs = node.componentPropertyReferences
.map((ref) => {
const defID = parseGuidOrNull(ref.propertyId)
if (!defID) return null
return { defID, componentPropNodeField: componentPropertyNodeField(ref.field) }
})
.filter((ref): ref is NonNullable<typeof ref> => ref !== null)
if (componentPropRefs.length > 0) nc.componentPropRefs = componentPropRefs
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
.map(([propertyId, value]) => {
const defID = parseGuidOrNull(propertyId)
const definition = findComponentPropertyDefinition(context.graph, propertyId)
return defID && definition
? {
defID,
value: componentPropertyValue(definition.type, value, context.graph)
}
: null
})
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
if (componentPropAssignments.length > 0) {
nc.componentPropAssignments = componentPropAssignments
}
const variantPropSpecs = node.variantPropSpecs
.map((spec) => {
const propDefId = parseGuidOrNull(spec.propDefId)
@ -726,7 +792,7 @@ export function sceneNodeToKiwiWithContext(
if (node.locked) nc.locked = true
applyNodeVisualProps(context, node, nc)
applyComponentMetadata(node, nc)
applyComponentMetadata(context, node, nc)
applyInstancePayload(context, node, nc, localIdCounter)
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'

View file

@ -43,6 +43,7 @@ const SDK_COMPOSABLE_PAGES = [
{ text: 'usePosition', slug: 'use-position' },
{ text: 'useLayout', slug: 'use-layout' },
{ text: 'useConstraints', slug: 'use-constraints', canonical: true },
{ text: 'useComponentProperties', slug: 'use-component-properties', canonical: true },
{ text: 'useAppearance', slug: 'use-appearance' },
{ text: 'useSharedStyleBinding', slug: 'use-shared-style-binding', canonical: true },
{ text: 'useColorModel', slug: 'use-color-model', canonical: true },

View file

@ -126,8 +126,8 @@ Figma's design documentation groups features into these areas:
| Vectors / vector networks | ✅ | ✅ | ◐ | ✅ | ✅ | Vector edit support exists; Figma Draw tools are not fully replicated. |
| Boolean operations | ✅ | ✅ | ◐ | ✅ | ✅ | Figma `BOOLEAN_OPERATION` nodes import/export as boolean operations; inspector editing remains limited. |
| Components | ✅ | ✅ | ◐ | ✅ | ✅ | Component metadata, descriptions, links, and publish fields mostly round-trip. |
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Variant values are usable; full component property authoring is incomplete. |
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Raw symbol overrides and derived symbol data are preserved for fidelity. |
| Component sets / variants | ✅ | ✅ | ◐ | ✅ | ✅ | Variant, text, boolean, and instance-swap properties are editable on instances; definition authoring remains incomplete. |
| Instances / overrides | ✅ | ✅ | ◐ | ✅ | ✅ | Component-property refs and typed assignments are modeled and editable; raw symbol overrides and derived data remain preserved for fidelity. |
| Slots | ↩ | ◐ | — | ↩ | — | Some component property payloads may survive round-trip, but Figma slots are not a first-class workflow. |
| Connectors | ◐ | ◐ | — | ◐ | ◐ | Type exists, but Figma connector semantics are weak. |
| Shape-with-text / FigJam shapes | ◐ | ◐ | — | ◐ | ◐ | Type exists, but not a full FigJam feature implementation. |

View file

@ -27,6 +27,7 @@ These are the main composables most `@open-pencil/vue` consumers will use.
- [usePosition](./use-position)
- [useLayout](./use-layout)
- [useConstraints](./use-constraints)
- [useComponentProperties](./use-component-properties)
- [useAppearance](./use-appearance)
- [useSharedStyleBinding](./use-shared-style-binding)
- [useColorModel](./use-color-model)

View file

@ -0,0 +1,43 @@
---
title: useComponentProperties
description: Read and edit variant, text, boolean, and instance-swap properties on instances.
---
# useComponentProperties
`useComponentProperties()` exposes compatible component properties for the selected instances and an
undo-aware value action.
```ts
import { useComponentProperties } from '@open-pencil/vue'
const { active, controls, setValue } = useComponentProperties()
// Property IDs are stable Figma definition IDs when imported from .fig.
setValue('12:34', 'Enabled')
```
Each item in `controls` contains:
- `id` and `name` from the component property definition;
- `type`: `VARIANT`, `TEXT`, `BOOLEAN`, or `INSTANCE_SWAP`;
- `value`: a string or `MIXED`;
- `options` for variant and instance-swap controls.
`active` is true only when every selected node is an instance and each instance exposes the same
ordered property IDs and types. Compatible multi-selection changes are grouped into one undo entry.
Text and boolean properties update the referenced instance descendant. Instance-swap properties
replace the referenced nested instance. Variant changes swap the main component and then reapply
non-variant assignments, so custom labels, visibility, and nested swaps survive the change and its
undo/redo cycle.
Imported `.fig` definitions retain typed defaults, property references, assignments, and preferred
instance-swap values. Missing swap targets remain explicit rather than silently selecting another
component.
## Related APIs
- [useSelectionState](./use-selection-state)
- [useSharedStyleBinding](./use-shared-style-binding)
- [Property Panels guide](../../guides/property-panels)

View file

@ -158,6 +158,10 @@ geometry. Manual property edits detach only the matching reference.
`overriddenSymbolID`, `symbolData.symbolOverrides[]`, `componentPropRefs[]`, `componentPropAssignments[]`
In the SceneGraph, typed definitions live in `componentPropertyDefinitions`, descendant field links
in `componentPropertyReferences`, and per-instance values in `componentPropertyAssignments`.
Variant component values remain separate in `componentPropertyValues`.
## Paint
```typescript

View file

@ -119,7 +119,8 @@ function copyPropertyDefs(
return (
defs?.map((d) => ({
...d,
variantOptions: d.variantOptions ? [...d.variantOptions] : undefined
variantOptions: d.variantOptions ? [...d.variantOptions] : undefined,
preferredValues: d.preferredValues ? [...d.preferredValues] : undefined
})) ?? []
)
}
@ -168,6 +169,8 @@ export function cloneNodeProps(src: SceneNode, componentId: string | null): Part
gridTemplateColumns: copySpread(src.gridTemplateColumns),
gridTemplateRows: copySpread(src.gridTemplateRows),
componentPropertyDefinitions: copyPropertyDefs(src.componentPropertyDefinitions),
componentPropertyReferences: copySpread(src.componentPropertyReferences),
componentPropertyAssignments: { ...src.componentPropertyAssignments },
symbolLinks: copySpread(src.symbolLinks),
variantPropSpecs: copySpread(src.variantPropSpecs),
pluginData: copySpread(src.pluginData),

View file

@ -107,7 +107,11 @@ function syncChildren(
const instChildMap = new Map<string, SceneNode>()
for (const childId of instParent.childIds) {
const child = graph.nodes.get(childId)
if (child?.componentId) instChildMap.set(child.componentId, child)
if (!child) continue
const sourceComponentId = overrides[`${child.id}:sourceComponentId`]
const mappedComponentId =
typeof sourceComponentId === 'string' ? sourceComponentId : child.componentId
if (mappedComponentId) instChildMap.set(mappedComponentId, child)
}
for (const compChildId of compParent.childIds) {
@ -146,7 +150,7 @@ function syncChildren(
copyProp(instChild, compChild, key)
}
if (compChild.childIds.length > 0) {
if (compChild.childIds.length > 0 && !(`${instChild.id}:componentId` in overrides)) {
syncChildren(graph, compChildId, instChild.id, overrides)
}
}
@ -155,8 +159,12 @@ function syncChildren(
instParent.childIds.sort((a, b) => {
const nodeA = graph.nodes.get(a)
const nodeB = graph.nodes.get(b)
const idxA = nodeA?.componentId ? compChildOrder.indexOf(nodeA.componentId) : -1
const idxB = nodeB?.componentId ? compChildOrder.indexOf(nodeB.componentId) : -1
const sourceA = nodeA ? overrides[`${nodeA.id}:sourceComponentId`] : undefined
const sourceB = nodeB ? overrides[`${nodeB.id}:sourceComponentId`] : undefined
const mappedA = typeof sourceA === 'string' ? sourceA : nodeA?.componentId
const mappedB = typeof sourceB === 'string' ? sourceB : nodeB?.componentId
const idxA = mappedA ? compChildOrder.indexOf(mappedA) : -1
const idxB = mappedB ? compChildOrder.indexOf(mappedB) : -1
return idxA - idxB
})
}

View file

@ -135,6 +135,8 @@ export function createDefaultNode(
componentId: null,
overrides: {},
componentPropertyDefinitions: [],
componentPropertyReferences: [],
componentPropertyAssignments: {},
componentPropertyValues: {},
componentKey: null,
sourceLibraryKey: null,

View file

@ -6,12 +6,15 @@ const RAW_SIZE_KEYS = new Set(['width', 'height'])
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
const STYLE_RAW_FIELDS: Partial<Record<string, string>> = {
const EDITED_RAW_FIELDS: Partial<Record<string, string>> = {
fillStyleId: 'styleIdForFill',
strokeStyleId: 'styleIdForStrokeFill',
textStyleId: 'styleIdForText',
effectStyleId: 'styleIdForEffect',
gridStyleId: 'styleIdForGrid'
gridStyleId: 'styleIdForGrid',
componentPropertyDefinitions: 'componentPropDefs',
componentPropertyReferences: 'componentPropRefs',
componentPropertyAssignments: 'componentPropAssignments'
}
const RAW_NODE_FIELD_KEYS = new Set([
@ -93,11 +96,11 @@ const RAW_NODE_FIELD_KEYS = new Set([
])
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
const styleRawFields = changeKeys
.map((key) => STYLE_RAW_FIELDS[key])
const editedRawFields = changeKeys
.map((key) => EDITED_RAW_FIELDS[key])
.filter((field): field is string => field !== undefined)
if (styleRawFields.length > 0) {
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, styleRawFields)
if (editedRawFields.length > 0) {
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, editedRawFields)
}
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null

View file

@ -482,6 +482,8 @@ export interface SceneNode {
componentId: string | null
overrides: Record<string, unknown>
componentPropertyDefinitions: ComponentPropertyDefinition[]
componentPropertyReferences: ComponentPropertyReference[]
componentPropertyAssignments: Record<string, string>
componentPropertyValues: Record<string, string>
componentKey: string | null
sourceLibraryKey: string | null
@ -512,12 +514,20 @@ export interface SceneNode {
export type ComponentPropertyType = 'VARIANT' | 'TEXT' | 'BOOLEAN' | 'INSTANCE_SWAP'
export type ComponentPropertyReferenceField = 'VISIBLE' | 'TEXT' | 'INSTANCE_SWAP'
export interface ComponentPropertyReference {
propertyId: string
field: ComponentPropertyReferenceField
}
export interface ComponentPropertyDefinition {
id: string
name: string
type: ComponentPropertyType
defaultValue: string
variantOptions?: string[]
preferredValues?: string[]
}
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'

View file

@ -135,6 +135,7 @@ These are the main APIs most SDK consumers should start with.
- `usePosition()`
- `useLayout()`
- `useConstraints()`
- `useComponentProperties()`
- `useAppearance()`
- `useSharedStyleBinding()`
- `useColorModel()`

View file

@ -0,0 +1,2 @@
export * from './model'
export * from './use'

View file

@ -0,0 +1,62 @@
import type {
ComponentPropertyDefinition,
ComponentPropertyType,
SceneNode
} from '@open-pencil/scene-graph'
import { MIXED, type MixedValue } from '#vue/controls/node-props/helpers'
export interface ComponentPropertyOption {
value: string
label: string
missing?: boolean
}
export interface ComponentPropertyControl {
id: string
name: string
type: ComponentPropertyType
value: MixedValue<string>
options: ComponentPropertyOption[]
}
export function compatibleComponentPropertyDefinitions(
definitions: ComponentPropertyDefinition[][]
): ComponentPropertyDefinition[] {
if (definitions.length === 0) return []
const first = definitions[0]
const signature = (items: ComponentPropertyDefinition[]) =>
items.map((item) => `${item.id}:${item.type}`).join('\u0000')
const expected = signature(first)
return definitions.every((items) => signature(items) === expected) ? first : []
}
export function mergedComponentPropertyValue(values: string[]): MixedValue<string> {
const first = values[0] ?? ''
return values.every((value) => value === first) ? first : MIXED
}
export function instanceSwapOptions(
components: SceneNode[],
definition: ComponentPropertyDefinition,
value: string
): ComponentPropertyOption[] {
const preferred = new Set(definition.preferredValues)
const options: ComponentPropertyOption[] = components
.filter((node) => node.type === 'COMPONENT')
.map((node) => ({
value: node.id,
label: node.name,
preferred:
preferred.has(node.componentKey ?? '') || preferred.has(node.sourceLibraryKey ?? '')
}))
.sort(
(left, right) =>
Number(right.preferred) - Number(left.preferred) || left.label.localeCompare(right.label)
)
.map(({ value: optionValue, label }) => ({ value: optionValue, label }))
if (value && !options.some((option) => option.value === value)) {
options.push({ value, label: value, missing: true })
}
return options
}

View file

@ -0,0 +1,89 @@
import { computed } from 'vue'
import type { SceneNode } from '@open-pencil/scene-graph'
import {
compatibleComponentPropertyDefinitions,
instanceSwapOptions,
mergedComponentPropertyValue,
type ComponentPropertyControl,
type ComponentPropertyOption
} from '#vue/controls/component-props/model'
import { MIXED } from '#vue/controls/node-props/helpers'
import { useEditor } from '#vue/editor/context'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
function variantOptions(editor: ReturnType<typeof useEditor>, instance: SceneNode, name: string) {
const component = instance.componentId ? editor.graph.getNode(instance.componentId) : null
const parent = component?.parentId ? editor.graph.getNode(component.parentId) : null
const values =
parent?.type === 'COMPONENT_SET' ? editor.collectVariantOptions(parent.id).get(name) : null
return [...(values ?? [])].map((value) => ({ value, label: value }))
}
export function useComponentProperties() {
const editor = useEditor()
const instances = useSceneComputed(() => {
void editor.state.sceneVersion
return editor.getSelectedNodes().filter((node) => node.type === 'INSTANCE')
})
const selectedCount = computed(() => editor.state.selectedIds.size)
const definitionSets = useSceneComputed(() => {
void editor.state.sceneVersion
return instances.value.map((instance) =>
editor.getInstanceComponentPropertyDefinitions(instance.id)
)
})
const definitions = computed(() => compatibleComponentPropertyDefinitions(definitionSets.value))
const active = computed(
() =>
instances.value.length > 0 &&
instances.value.length === selectedCount.value &&
definitions.value.length > 0
)
const controls = useSceneComputed<ComponentPropertyControl[]>(() => {
void editor.state.sceneVersion
if (!active.value || instances.value.length === 0) return []
const firstInstance = instances.value[0]
return definitions.value.map((definition) => {
const values = instances.value.map((instance) =>
editor.getInstanceComponentPropertyValue(instance.id, definition)
)
const value = mergedComponentPropertyValue(values)
let options: ComponentPropertyOption[] = []
if (definition.type === 'VARIANT') {
options = variantOptions(editor, firstInstance, definition.name)
} else if (definition.type === 'INSTANCE_SWAP') {
options = instanceSwapOptions(
[...editor.graph.getAllNodes()],
definition,
value === MIXED ? '' : value
)
}
return {
id: definition.id,
name: definition.name,
type: definition.type,
value,
options
}
})
})
function setValue(propertyId: string, value: string) {
if (!active.value) return
const targets = [...instances.value]
const definition = definitions.value.find((item) => item.id === propertyId)
if (!definition) return
const label = `Change ${definition.name}`
const run = () => {
for (const instance of targets) {
editor.setInstanceComponentProperty(instance.id, propertyId, value)
}
}
if (targets.length > 1) editor.undo.runBatch(label, run)
else run()
}
return { active, controls, setValue }
}

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "Gehrungsgrenze",
"add": "Hinzufügen",
"variants": "Varianten",
"componentProperties": "Komponenteneigenschaften",
"gapAuto": "Automatischer Abstand",
"horizontalGap": "Horizontaler Abstand",
"verticalGap": "Vertikaler Abstand",

View file

@ -155,6 +155,7 @@
"strokeMiterLimit": "Límite de inglete",
"add": "Añadir",
"variants": "Variantes",
"componentProperties": "Propiedades del componente",
"gapAuto": "Espaciado auto",
"horizontalGap": "Espaciado horizontal",
"verticalGap": "Espaciado vertical",

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "Limite donglet",
"add": "Ajouter",
"variants": "Variantes",
"componentProperties": "Propriétés du composant",
"gapAuto": "Espacement auto",
"horizontalGap": "Espacement horizontal",
"verticalGap": "Espacement vertical",

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "Limite mitra",
"add": "Aggiungi",
"variants": "Varianti",
"componentProperties": "Proprietà del componente",
"gapAuto": "Spaziatura auto",
"horizontalGap": "Spaziatura orizzontale",
"verticalGap": "Spaziatura verticale",

View file

@ -155,6 +155,7 @@
"strokeMiterLimit": "マイター制限",
"add": "追加",
"variants": "バリアント",
"componentProperties": "コンポーネントのプロパティ",
"gapAuto": "間隔自動",
"horizontalGap": "水平方向の間隔",
"verticalGap": "垂直方向の間隔",

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "Limit łączenia ostrego",
"add": "Dodaj",
"variants": "Warianty",
"componentProperties": "Właściwości komponentu",
"gapAuto": "Automatyczny odstep",
"horizontalGap": "Odstep poziomy",
"verticalGap": "Odstep pionowy",

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "Предел острого соединения",
"add": "Добавить",
"variants": "Варианты",
"componentProperties": "Свойства компонента",
"gapAuto": "Авто отступ",
"horizontalGap": "Горизонтальный отступ",
"verticalGap": "Вертикальный отступ",

View file

@ -138,6 +138,7 @@
"strokeMiterLimit": "尖角限制",
"add": "添加",
"variants": "变体",
"componentProperties": "组件属性",
"gapAuto": "自动间距",
"horizontalGap": "水平间距",
"verticalGap": "垂直间距",

View file

@ -58,6 +58,7 @@ export const panelMessageDefaults = {
pageBackground: 'Page background',
variables: 'Variables',
variants: 'Variants',
componentProperties: 'Component properties',
constraints: 'Constraints',
horizontalConstraint: 'Horizontal constraint',
verticalConstraint: 'Vertical constraint',

View file

@ -205,6 +205,16 @@ export {
useConstraints
} from '#vue/controls/constraints'
export type { ConstraintAxis, ConstraintEdge, ConstraintValue } from '#vue/controls/constraints'
export {
compatibleComponentPropertyDefinitions,
instanceSwapOptions,
mergedComponentPropertyValue,
useComponentProperties
} from '#vue/controls/component-props'
export type {
ComponentPropertyControl,
ComponentPropertyOption
} from '#vue/controls/component-props'
export type { CornerGeometryKey, CornerRadiusKey } from '#vue/controls/appearance/types'
export { PageListRoot } from '#vue/primitives/PageList'
export { PositionControlsRoot } from '#vue/primitives/PositionControls'

View file

@ -21,7 +21,7 @@ import SelectionActionsControl from './properties/SelectionActionsControl.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
import VariantSection from './properties/VariantSection.vue'
import ComponentPropertiesSection from './properties/component-properties/ComponentPropertiesSection.vue'
const variablesOpen = ref(false)
const { selectedNode: node, selectedCount: multiCount } = useSelectionState()
@ -55,6 +55,7 @@ const { panels } = useI18n()
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
</template>
</PanelHeader>
<ComponentPropertiesSection />
<PositionSection />
<ConstraintsSection />
<AppearanceSection />
@ -105,7 +106,7 @@ const { panels } = useI18n()
</button>
</div>
<VariantSection v-if="node.type === 'INSTANCE'" />
<ComponentPropertiesSection v-if="node.type === 'INSTANCE'" />
<PositionSection />
<ConstraintsSection />

View file

@ -1,63 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useSelectionState } from '@open-pencil/vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
import { useEditorStore } from '@/app/editor/active-store'
const editor = useEditorStore()
const { selectedNode: node } = useSelectionState()
const { panels } = useI18n()
const instanceComponent = computed(() => {
if (!node.value || node.value.type !== 'INSTANCE' || !node.value.componentId) return null
return editor.graph.getNode(node.value.componentId) ?? null
})
const componentSetId = computed(() => {
const comp = instanceComponent.value
if (!comp) return null
const parent = comp.parentId ? editor.graph.getNode(comp.parentId) : null
return parent?.type === 'COMPONENT_SET' ? parent.id : null
})
const variantOptions = computed(() => {
const csId = componentSetId.value
if (!csId) return new Map<string, Set<string>>()
return editor.collectVariantOptions(csId)
})
const currentValues = computed(() => {
return instanceComponent.value?.componentPropertyValues ?? {}
})
const hasVariants = computed(() => variantOptions.value.size > 0)
function switchVariant(propertyName: string, newValue: string) {
if (!node.value) return
editor.switchInstanceVariant(node.value.id, propertyName, newValue)
}
</script>
<template>
<PanelSection v-if="hasVariants" :label="panels.variants" :ui="{ title: 'text-component' }">
<div class="flex flex-col gap-panel">
<PanelFieldGroup
v-for="[propName, options] in variantOptions"
:key="propName"
:label="propName"
>
<AppSelect
:label="propName"
:model-value="currentValues[propName] ?? ''"
:options="[...options].map((value) => ({ value, label: value }))"
:data-property="propName"
@update:model-value="switchVariant(propName, $event)"
/>
</PanelFieldGroup>
</div>
</PanelSection>
</template>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import { MIXED, useComponentProperties, useI18n } from '@open-pencil/vue'
import ComponentPropertyTextField from './ComponentPropertyTextField.vue'
import AppSelect from '@/components/ui/AppSelect.vue'
import AppSwitch from '@/components/ui/AppSwitch.vue'
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
import PanelSection from '@/components/ui/panel/PanelSection.vue'
const { active, controls, setValue } = useComponentProperties()
const { panels } = useI18n()
const componentSectionUI = { title: 'text-component' }
function selectOptions(control: (typeof controls.value)[number]) {
return control.value === MIXED
? [{ value: 'MIXED', label: panels.value.mixed }, ...control.options]
: control.options
}
function selectValue(control: (typeof controls.value)[number]) {
return control.value === MIXED ? 'MIXED' : control.value
}
function updateSelect(propertyId: string, value: string) {
if (value !== 'MIXED') setValue(propertyId, value)
}
function booleanValue(control: (typeof controls.value)[number]) {
return control.value !== MIXED && control.value === 'true'
}
const sectionLabel = computed(() =>
controls.value.every((control) => control.type === 'VARIANT')
? panels.value.variants
: panels.value.componentProperties
)
</script>
<template>
<PanelSection v-if="active" :label="sectionLabel" :ui="componentSectionUI">
<div class="flex flex-col gap-panel">
<PanelFieldGroup v-for="control in controls" :key="control.id" :label="control.name">
<ComponentPropertyTextField
v-if="control.type === 'TEXT'"
:value="control.value"
:label="control.name"
:data-property="control.id"
@commit="setValue(control.id, $event)"
/>
<div v-else-if="control.type === 'BOOLEAN'" class="flex h-field items-center">
<AppSwitch
:model-value="booleanValue(control)"
:label="control.name"
:state="control.value === MIXED ? 'mixed' : 'idle'"
:data-property="control.id"
@update:model-value="setValue(control.id, String($event))"
/>
</div>
<AppSelect
v-else
:label="control.name"
:model-value="selectValue(control)"
:options="selectOptions(control)"
:data-property="control.id"
@update:model-value="updateSelect(control.id, $event)"
/>
</PanelFieldGroup>
</div>
</PanelSection>
</template>

View file

@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { MIXED, type MixedValue } from '@open-pencil/vue'
import AppInput from '@/components/ui/AppInput.vue'
const { value, label } = defineProps<{ value: MixedValue<string>; label: string }>()
const emit = defineEmits<{ commit: [value: string] }>()
const draft = ref('')
watch(
() => value,
(next) => {
draft.value = next === MIXED ? '' : next
},
{ immediate: true }
)
</script>
<template>
<AppInput
v-model="draft"
tone="panel"
size="sm"
:state="value === MIXED ? 'mixed' : 'idle'"
:placeholder="value === MIXED ? '—' : undefined"
:aria-label="label"
@change="emit('commit', draft)"
/>
</template>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { ComponentUI } from '@/components/ui/types'
import type { SwitchTheme } from '@/theme/switch'
export type AppSwitchUI = ComponentUI<SwitchTheme>
export interface AppSwitchProps {
label: string
size?: keyof SwitchTheme['variants']['size']
state?: keyof SwitchTheme['variants']['state']
ui?: AppSwitchUI
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { tv } from 'tailwind-variants'
import theme from '@/theme/switch'
const { label, size = 'sm', state = 'idle', ui } = defineProps<AppSwitchProps>()
const modelValue = defineModel<boolean>({ required: true })
const styles = computed(() => tv(theme)({ size, state }))
</script>
<template>
<SwitchRoot
v-model="modelValue"
:aria-label="label"
:data-mixed="state === 'mixed' || undefined"
:class="styles.root({ class: ui?.root })"
>
<SwitchThumb :class="styles.thumb({ class: ui?.thumb })" />
</SwitchRoot>
</template>

24
src/theme/switch.ts Normal file
View file

@ -0,0 +1,24 @@
const switchTheme = {
slots: {
root: 'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-border bg-panel-field outline-none transition-colors hover:border-border-strong focus-visible:ring-1 focus-visible:ring-panel-focus data-[state=checked]:border-accent data-[state=checked]:bg-accent',
thumb:
'pointer-events-none block rounded-full bg-muted shadow-sm transition-transform data-[state=checked]:bg-white'
},
variants: {
size: {
sm: { root: 'h-4 w-7 p-0.5', thumb: 'size-3 data-[state=checked]:translate-x-3' },
md: { root: 'h-5 w-9 p-0.5', thumb: 'size-4 data-[state=checked]:translate-x-4' }
},
state: {
idle: {},
mixed: { root: 'border-accent/60 bg-accent/20', thumb: 'translate-x-1.5 bg-accent' }
}
},
defaultVariants: {
size: 'sm' as const,
state: 'idle' as const
}
}
export type SwitchTheme = typeof switchTheme
export default switchTheme

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,159 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '#tests/helpers/canvas'
import { propertySection } from '#tests/helpers/properties'
let page: Page
let canvas: CanvasHelper
let instanceId = ''
async function selectOption(label: string, option: string) {
const section = propertySection(page, 'Component properties')
await section.getByRole('combobox', { name: label }).click()
await page.getByRole('option', { name: option, exact: true }).click()
await canvas.waitForRender()
}
async function instanceState(id = instanceId) {
return page.evaluate((nodeId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const instance = store.graph.getNode(nodeId)
if (!instance) return null
const children = store.graph.getChildren(nodeId)
return {
componentId: instance.componentId,
assignments: instance.componentPropertyAssignments,
label: children.find((node) => node.name === 'Label')?.text,
badgeVisible: children.find((node) => node.name === 'Badge')?.visible,
iconName: children.find((node) => node.type === 'INSTANCE')?.name
}
}, id)
}
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
instanceId = await page.evaluate(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const pageId = store.state.currentPageId
const iconA = store.graph.createNode('COMPONENT', pageId, { name: 'Icon A' })
store.graph.createNode('RECTANGLE', iconA.id, { name: 'A shape' })
const iconB = store.graph.createNode('COMPONENT', pageId, { name: 'Icon B' })
store.graph.createNode('ELLIPSE', iconB.id, { name: 'B shape' })
const componentSet = store.graph.createNode('COMPONENT_SET', pageId, {
name: 'Card',
componentPropertyDefinitions: [
{ id: '30:1', name: 'State', type: 'VARIANT', defaultValue: 'Default' },
{ id: '30:2', name: 'Label', type: 'TEXT', defaultValue: 'Default label' },
{ id: '30:3', name: 'Show badge', type: 'BOOLEAN', defaultValue: 'true' },
{ id: '30:4', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: iconA.id }
]
})
const createVariant = (name: string, state: string, x: number) => {
const component = store.graph.createNode('COMPONENT', componentSet.id, {
name,
x,
width: 220,
height: 100,
componentPropertyValues: { State: state }
})
store.graph.createNode('TEXT', component.id, {
name: 'Label',
text: `${state} label`,
componentPropertyReferences: [{ propertyId: '30:2', field: 'TEXT' }]
})
store.graph.createNode('FRAME', component.id, {
name: 'Badge',
componentPropertyReferences: [{ propertyId: '30:3', field: 'VISIBLE' }]
})
const icon = store.graph.createInstance(iconA.id, component.id, { name: 'Icon' })
if (!icon) throw new Error('Expected nested icon')
store.graph.updateNode(icon.id, {
componentPropertyReferences: [{ propertyId: '30:4', field: 'INSTANCE_SWAP' }]
})
return component
}
const primary = createVariant('Default card', 'Default', 0)
createVariant('Hover card', 'Hover', 260)
const instance = store.graph.createInstance(primary.id, pageId, { x: 200, y: 300 })
if (!instance) throw new Error('Expected instance')
store.select([instance.id])
return instance.id
})
await canvas.waitForRender()
})
test.afterAll(async () => {
await page.close()
})
test('renders and applies all component property control types', async () => {
const section = propertySection(page, 'Component properties')
await expect(section).toBeVisible()
await expect(section.getByRole('combobox', { name: 'State' })).toBeVisible()
await expect(section.getByRole('textbox', { name: 'Label' })).toBeVisible()
await expect(section.getByRole('switch', { name: 'Show badge' })).toBeVisible()
await expect(section.getByRole('combobox', { name: 'Icon' })).toBeVisible()
const text = section.getByRole('textbox', { name: 'Label' })
await text.fill('Custom label')
await text.blur()
await canvas.waitForRender()
expect(await instanceState()).toMatchObject({ label: 'Custom label' })
await section.getByRole('switch', { name: 'Show badge' }).click()
await canvas.waitForRender()
expect(await instanceState()).toMatchObject({ badgeVisible: false })
await selectOption('Icon', 'Icon B')
expect(await instanceState()).toMatchObject({ iconName: 'Icon B' })
await selectOption('State', 'Hover')
expect(await instanceState()).toMatchObject({ label: 'Custom label' })
await expect(section).toHaveScreenshot('component-properties-controls.png')
})
test('batches compatible mixed selection and undo', async () => {
const secondId = await page.evaluate((firstId) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const first = store.graph.getNode(firstId)
if (!first?.componentId) throw new Error('Missing first instance')
const second = store.graph.createInstance(first.componentId, store.state.currentPageId, {
x: 500,
y: 300
})
if (!second) throw new Error('Expected second instance')
store.select([firstId, second.id])
return second.id
}, instanceId)
await canvas.waitForRender()
const toggle = propertySection(page, 'Component properties').getByRole('switch', {
name: 'Show badge'
})
await expect(toggle).toHaveAttribute('data-mixed', 'true')
await toggle.click()
await canvas.waitForRender()
expect(await instanceState(instanceId)).toMatchObject({ badgeVisible: true })
expect(await instanceState(secondId)).toMatchObject({ badgeVisible: true })
await canvas.pressKey('Meta+z')
await canvas.waitForRender()
expect(await instanceState(instanceId)).toMatchObject({ badgeVisible: false })
expect(await instanceState(secondId)).toMatchObject({ badgeVisible: true })
await page.evaluate((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil store not initialized')
const rectangle = store.graph.createNode('RECTANGLE', store.state.currentPageId)
store.select([id, rectangle.id])
}, instanceId)
await canvas.waitForRender()
await expect(propertySection(page, 'Component properties')).toHaveCount(0)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -0,0 +1,133 @@
import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
function childByName(editor: ReturnType<typeof createEditor>, parentId: string, name: string) {
return editor.graph.getChildren(parentId).find((node) => node.name === name)
}
function setupComponentProperties() {
const editor = createEditor()
const pageId = editor.state.currentPageId
const iconA = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon A' })
editor.graph.createNode('RECTANGLE', iconA.id, { name: 'A shape' })
const iconB = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon B' })
editor.graph.createNode('ELLIPSE', iconB.id, { name: 'B shape' })
const component = editor.graph.createNode('COMPONENT', pageId, {
name: 'Card',
componentPropertyDefinitions: [
{ id: '10:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
{ id: '10:2', name: 'Show icon', type: 'BOOLEAN', defaultValue: 'true' },
{ id: '10:3', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: iconA.id }
]
})
editor.graph.createNode('TEXT', component.id, {
name: 'Label',
text: 'Default',
componentPropertyReferences: [{ propertyId: '10:1', field: 'TEXT' }]
})
editor.graph.createNode('FRAME', component.id, {
name: 'Badge',
componentPropertyReferences: [{ propertyId: '10:2', field: 'VISIBLE' }]
})
const sourceIcon = editor.graph.createInstance(iconA.id, component.id, { name: 'Icon' })
if (!sourceIcon) throw new Error('Expected nested source instance')
editor.graph.updateNode(sourceIcon.id, {
componentPropertyReferences: [{ propertyId: '10:3', field: 'INSTANCE_SWAP' }]
})
const instance = editor.graph.createInstance(component.id, pageId)
if (!instance) throw new Error('Expected card instance')
return { editor, component, instance, iconA, iconB }
}
describe('component property actions', () => {
test('applies and undoes text, boolean, and instance-swap properties', () => {
const { editor, instance, iconA, iconB } = setupComponentProperties()
editor.setInstanceComponentProperty(instance.id, '10:1', 'Changed')
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Changed')
expect(editor.graph.getNode(instance.id)?.componentPropertyAssignments['10:1']).toBe('Changed')
editor.undo.undo()
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Default')
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
editor.undo.undo()
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(true)
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
const swapped = childByName(editor, instance.id, 'Icon B')
expect(swapped?.componentId).toBe(iconB.id)
expect(swapped?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['B shape'])
editor.setInstanceComponentProperty(instance.id, '10:3', iconA.id)
editor.undo.undo()
expect(childByName(editor, instance.id, 'Icon B')?.componentId).toBe(iconB.id)
editor.undo.undo()
const restored = childByName(editor, instance.id, 'Icon A')
expect(restored?.componentId).toBe(iconA.id)
expect(restored?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['A shape'])
})
test('preserves assignments when the main component synchronizes', () => {
const { editor, component, instance, iconB } = setupComponentProperties()
editor.setInstanceComponentProperty(instance.id, '10:1', 'Custom')
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
const sourceLabel = childByName(editor, component.id, 'Label')
if (!sourceLabel) throw new Error('Expected source label')
editor.graph.updateNode(sourceLabel.id, { text: 'Updated default' })
editor.graph.syncInstances(component.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
const nestedInstances = editor.graph
.getChildren(instance.id)
.filter((node) => node.type === 'INSTANCE')
expect(nestedInstances).toHaveLength(1)
expect(nestedInstances[0].name).toBe('Icon B')
expect(nestedInstances[0].childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual([
'B shape'
])
})
test('reapplies non-variant assignments after variant swaps and undo', () => {
const editor = createEditor()
const pageId = editor.state.currentPageId
const componentSet = editor.graph.createNode('COMPONENT_SET', pageId, {
componentPropertyDefinitions: [
{ id: '20:1', name: 'State', type: 'VARIANT', defaultValue: 'A' },
{ id: '20:2', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
]
})
const variantA = editor.graph.createNode('COMPONENT', componentSet.id, {
name: 'State=A',
componentPropertyValues: { State: 'A' }
})
editor.graph.createNode('TEXT', variantA.id, {
name: 'Label',
text: 'A default',
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
})
const variantB = editor.graph.createNode('COMPONENT', componentSet.id, {
name: 'State=B',
componentPropertyValues: { State: 'B' }
})
editor.graph.createNode('TEXT', variantB.id, {
name: 'Label',
text: 'B default',
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
})
const instance = editor.graph.createInstance(variantA.id, pageId)
if (!instance) throw new Error('Expected variant instance')
editor.setInstanceComponentProperty(instance.id, '20:2', 'Custom')
editor.setInstanceComponentProperty(instance.id, '20:1', 'B')
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantB.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
editor.undo.undo()
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantA.id)
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
})
})

View file

@ -0,0 +1,69 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { exportFigFile, initCodec } from '@open-pencil/core'
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
import { SceneGraph } from '@open-pencil/scene-graph'
import { importNodeChanges } from '#core/kiwi/fig/import'
describe('Figma component property roundtrip', () => {
beforeAll(async () => {
await initCodec()
})
test('retains typed definitions, refs, and assignments', async () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const component = graph.createNode('COMPONENT', page.id, {
name: 'Card',
componentPropertyDefinitions: [
{ id: '30:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
{ id: '30:2', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' }
]
})
component.source.id = '10:1'
const label = graph.createNode('TEXT', component.id, {
name: 'Label',
text: 'Default',
componentPropertyReferences: [
{ propertyId: '30:1', field: 'TEXT' },
{ propertyId: '30:2', field: 'VISIBLE' }
]
})
label.source.id = '10:2'
const instance = graph.createInstance(component.id, page.id, {
name: 'Card instance',
componentPropertyAssignments: { '30:1': 'Custom', '30:2': 'false' }
})
if (!instance) throw new Error('Expected instance')
instance.source.id = '20:1'
const bytes = await exportFigFile(graph)
const parsed = parseFigBuffer(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
)
const imported = importNodeChanges(parsed.nodeChanges, parsed.blobs, undefined, {
populate: 'all'
})
const importedComponent = [...imported.getAllNodes()].find((node) => node.name === 'Card')
const importedInstance = [...imported.getAllNodes()].find(
(node) => node.name === 'Card instance'
)
const importedLabel = [...imported.getAllNodes()].find(
(node) => node.name === 'Label' && node.parentId === importedComponent?.id
)
expect(importedComponent?.componentPropertyDefinitions).toEqual([
{ id: '30:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
{ id: '30:2', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' }
])
expect(importedLabel?.componentPropertyReferences).toEqual([
{ propertyId: '30:1', field: 'TEXT' },
{ propertyId: '30:2', field: 'VISIBLE' }
])
expect(importedInstance?.componentPropertyAssignments).toEqual({
'30:1': 'Custom',
'30:2': 'false'
})
})
})

View file

@ -56,7 +56,12 @@ describe('Figma component property import', () => {
size: { x: 100, y: 20 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
componentPropDefs: [
{ id: textPropGuid, name: 'label', initialValue: { textValue: 'Menu Item' } }
{
id: textPropGuid,
name: 'label',
type: 'TEXT',
initialValue: { textValue: 'Menu Item' }
}
]
},
baseTextChange(),
@ -82,6 +87,16 @@ describe('Figma component property import', () => {
const graph = importNodeChanges(nodeChanges, [], undefined, { populate: 'all' })
const labels = Array.from(graph.getAllNodes()).filter((node) => node.type === 'TEXT')
expect(labels.map((node) => node.text).sort()).toEqual(['Menu Item', 'Profile Item'])
const component = Array.from(graph.getAllNodes()).find((node) => node.name === 'Menu item')
expect(component?.componentPropertyDefinitions).toEqual([
{ id: '3:1', name: 'label', type: 'TEXT', defaultValue: 'Menu Item' }
])
const sourceLabel = labels.find((node) => node.text === 'Menu Item')
expect(sourceLabel?.componentPropertyReferences).toEqual([{ propertyId: '3:1', field: 'TEXT' }])
const instance = Array.from(graph.getAllNodes()).find(
(node) => node.name === 'Menu item instance'
)
expect(instance?.componentPropertyAssignments).toEqual({ '3:1': 'Profile Item' })
})
test('propagates nested instance swaps through clone chains', () => {
@ -178,7 +193,15 @@ describe('Figma component property import', () => {
size: { x: 100, y: 20 },
transform: { m00: 1, m01: 0, m02: 80, m10: 0, m11: 1, m12: 0 },
componentPropDefs: [
{ id: iconPropGuid, name: 'icon', initialValue: { guidValue: mailIconGuid } }
{
id: iconPropGuid,
name: 'icon',
type: 'INSTANCE_SWAP',
initialValue: { guidValue: mailIconGuid },
preferredValues: {
instanceSwapValues: [{ type: 'COMPONENT', key: 'icon/user-key' }]
}
}
]
},
{
@ -230,6 +253,20 @@ describe('Figma component property import', () => {
]
const graph = importNodeChanges(nodeChanges, [], undefined, { populate: 'all' })
const component = Array.from(graph.getAllNodes()).find((node) => node.name === 'Menu item')
expect(component?.componentPropertyDefinitions).toEqual([
{
id: '3:2',
name: 'icon',
type: 'INSTANCE_SWAP',
defaultValue: '4:1',
preferredValues: ['icon/user-key']
}
])
const sourceInstance = Array.from(graph.getAllNodes()).find(
(node) => node.name === 'Menu item source'
)
expect(sourceInstance?.componentPropertyAssignments).toEqual({ '3:2': '4:3' })
const clone = Array.from(graph.getAllNodes()).find((node) => node.name === 'Menu item clone')
const icon = clone?.childIds
.map((id) => graph.getNode(id))

View file

@ -49,6 +49,7 @@ const RAW_FIELD_COVERAGE = {
'vectorData'
],
uiEditable: [
'componentPropAssignments',
'componentPropDefs',
'exportSettings',
'fontSize',
@ -112,6 +113,7 @@ const RAW_FIELD_COVERAGE = {
'styleIdForGrid',
'styleIdForStrokeFill',
'styleIdForText',
'styleType',
'textExplicitLayoutVersion',
'textUserLayoutVersion',
'targetAspectRatio',

View file

@ -46,12 +46,63 @@ describe('component metadata serialization', () => {
{
id: { sessionID: 90, localID: 1 },
name: 'State',
type: 'TEXT',
initialValue: { textValue: { characters: 'Enabled' } }
type: 'VARIANT',
initialValue: { textValue: { characters: 'Enabled' } },
preferredValues: { stringValues: ['Enabled'] }
}
])
expect(kiwi.variantPropSpecs).toEqual([
{ propDefId: { sessionID: 90, localID: 1 }, value: 'Enabled' }
])
})
test('writes typed component property refs and assignments', () => {
const graph = new SceneGraph()
const target = graph.createNode('COMPONENT', pageId(graph), { name: 'Target icon' })
target.source.id = '70:1'
const component = graph.createNode('COMPONENT', pageId(graph), {
name: 'Card',
componentPropertyDefinitions: [
{ id: '80:1', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' },
{ id: '80:2', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
{
id: '80:3',
name: 'Icon',
type: 'INSTANCE_SWAP',
defaultValue: target.id,
preferredValues: ['icon-key']
}
]
})
const child = graph.createNode('TEXT', component.id, {
componentPropertyReferences: [
{ propertyId: '80:1', field: 'VISIBLE' },
{ propertyId: '80:2', field: 'TEXT' }
]
})
const instance = graph.createNode('INSTANCE', pageId(graph), {
componentId: component.id,
componentPropertyAssignments: {
'80:1': 'false',
'80:2': 'Changed',
'80:3': target.id
}
})
const childKiwi = toKiwi(child, graph)[0]
expect(childKiwi.componentPropRefs).toEqual([
{ defID: { sessionID: 80, localID: 1 }, componentPropNodeField: 'VISIBLE' },
{ defID: { sessionID: 80, localID: 2 }, componentPropNodeField: 'TEXT_DATA' }
])
const instanceKiwi = toKiwi(instance, graph)[0]
expect(instanceKiwi.componentPropAssignments).toEqual([
{ defID: { sessionID: 80, localID: 1 }, value: { boolValue: false } },
{
defID: { sessionID: 80, localID: 2 },
value: { textValue: { characters: 'Changed' } }
},
{ defID: { sessionID: 80, localID: 3 }, value: { guidValue: { sessionID: 70, localID: 1 } } }
])
})
})

View file

@ -0,0 +1,57 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/scene-graph'
import {
MIXED,
compatibleComponentPropertyDefinitions,
instanceSwapOptions,
mergedComponentPropertyValue
} from '@open-pencil/vue'
describe('component property control model', () => {
test('requires identical ordered property IDs and types', () => {
const definitions = [
{ id: '1:1', name: 'Label', type: 'TEXT' as const, defaultValue: 'Default' },
{ id: '1:2', name: 'Visible', type: 'BOOLEAN' as const, defaultValue: 'true' }
]
expect(
compatibleComponentPropertyDefinitions([definitions, structuredClone(definitions)])
).toBe(definitions)
expect(
compatibleComponentPropertyDefinitions([
definitions,
[{ ...definitions[0], type: 'BOOLEAN' as const }]
])
).toEqual([])
})
test('models mixed values and preferred instance swap options', () => {
expect(mergedComponentPropertyValue(['A', 'A'])).toBe('A')
expect(mergedComponentPropertyValue(['A', 'B'])).toBe(MIXED)
const graph = new SceneGraph()
const pageId = graph.getPages()[0].id
const secondary = graph.createNode('COMPONENT', pageId, { name: 'Secondary' })
const preferred = graph.createNode('COMPONENT', pageId, {
name: 'Preferred',
componentKey: 'preferred-key'
})
expect(
instanceSwapOptions(
[secondary, preferred],
{
id: '1:3',
name: 'Icon',
type: 'INSTANCE_SWAP',
defaultValue: secondary.id,
preferredValues: ['preferred-key']
},
'missing-id'
)
).toEqual([
{ value: preferred.id, label: 'Preferred' },
{ value: secondary.id, label: 'Secondary' },
{ value: 'missing-id', label: 'missing-id', missing: true }
])
})
})