diff --git a/CHANGELOG.md b/CHANGELOG.md index aae39da6f..5aefed4c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/editor/bridges/components.ts b/packages/core/src/editor/bridges/components.ts index 988c34fa0..75c5c487e 100644 --- a/packages/core/src/editor/bridges/components.ts +++ b/packages/core/src/editor/bridges/components.ts @@ -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 } } diff --git a/packages/core/src/editor/components.ts b/packages/core/src/editor/components.ts index f8a743919..4539a7e16 100644 --- a/packages/core/src/editor/components.ts +++ b/packages/core/src/editor/components.ts @@ -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 } } diff --git a/packages/core/src/editor/components/properties.ts b/packages/core/src/editor/components/properties.ts new file mode 100644 index 000000000..bcc43e04f --- /dev/null +++ b/packages/core/src/editor/components/properties.ts @@ -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() + 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 { + 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 + } +} diff --git a/packages/core/src/editor/components/variants.ts b/packages/core/src/editor/components/variants.ts index 96a8055d2..889f4c29d 100644 --- a/packages/core/src/editor/components/variants.ts +++ b/packages/core/src/editor/components/variants.ts @@ -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() } }) diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index cdde94c86..a3c893432 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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 = { + '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 { + 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', diff --git a/packages/core/src/kiwi/fig/node-change/export-node.ts b/packages/core/src/kiwi/fig/node-change/export-node.ts index 7c6842349..da3c52133 100644 --- a/packages/core/src/kiwi/fig/node-change/export-node.ts +++ b/packages/core/src/kiwi/fig/node-change/export-node.ts @@ -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 => 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 => 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 => 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' diff --git a/packages/docs/.vitepress/sdk-sidebar.ts b/packages/docs/.vitepress/sdk-sidebar.ts index f0c1148a7..a3ca25527 100644 --- a/packages/docs/.vitepress/sdk-sidebar.ts +++ b/packages/docs/.vitepress/sdk-sidebar.ts @@ -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 }, diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index 816ec1bd4..d710637aa 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -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. | diff --git a/packages/docs/programmable/sdk/api/composables/index.md b/packages/docs/programmable/sdk/api/composables/index.md index c919bc5b6..925e916d0 100644 --- a/packages/docs/programmable/sdk/api/composables/index.md +++ b/packages/docs/programmable/sdk/api/composables/index.md @@ -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) diff --git a/packages/docs/programmable/sdk/api/composables/use-component-properties.md b/packages/docs/programmable/sdk/api/composables/use-component-properties.md new file mode 100644 index 000000000..e289a5578 --- /dev/null +++ b/packages/docs/programmable/sdk/api/composables/use-component-properties.md @@ -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) diff --git a/packages/docs/reference/node-types.md b/packages/docs/reference/node-types.md index 3413d7d7d..34fecaea7 100644 --- a/packages/docs/reference/node-types.md +++ b/packages/docs/reference/node-types.md @@ -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 diff --git a/packages/scene-graph/src/copy.ts b/packages/scene-graph/src/copy.ts index c391cdebf..ac606d94f 100644 --- a/packages/scene-graph/src/copy.ts +++ b/packages/scene-graph/src/copy.ts @@ -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), diff --git a/packages/scene-graph/src/instances.ts b/packages/scene-graph/src/instances.ts index c11709aec..5e9fe4bac 100644 --- a/packages/scene-graph/src/instances.ts +++ b/packages/scene-graph/src/instances.ts @@ -107,7 +107,11 @@ function syncChildren( const instChildMap = new Map() 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 }) } diff --git a/packages/scene-graph/src/node-defaults.ts b/packages/scene-graph/src/node-defaults.ts index 9cb699993..f0e8428a2 100644 --- a/packages/scene-graph/src/node-defaults.ts +++ b/packages/scene-graph/src/node-defaults.ts @@ -135,6 +135,8 @@ export function createDefaultNode( componentId: null, overrides: {}, componentPropertyDefinitions: [], + componentPropertyReferences: [], + componentPropertyAssignments: {}, componentPropertyValues: {}, componentKey: null, sourceLibraryKey: null, diff --git a/packages/scene-graph/src/source-metadata.ts b/packages/scene-graph/src/source-metadata.ts index b86095098..d1c8d9241 100644 --- a/packages/scene-graph/src/source-metadata.ts +++ b/packages/scene-graph/src/source-metadata.ts @@ -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> = { +const EDITED_RAW_FIELDS: Partial> = { 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 diff --git a/packages/scene-graph/src/types.ts b/packages/scene-graph/src/types.ts index 2e297fa43..ecc1dfadd 100644 --- a/packages/scene-graph/src/types.ts +++ b/packages/scene-graph/src/types.ts @@ -482,6 +482,8 @@ export interface SceneNode { componentId: string | null overrides: Record componentPropertyDefinitions: ComponentPropertyDefinition[] + componentPropertyReferences: ComponentPropertyReference[] + componentPropertyAssignments: Record componentPropertyValues: Record 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' diff --git a/packages/vue/README.md b/packages/vue/README.md index 3f266bcab..7f524d966 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -135,6 +135,7 @@ These are the main APIs most SDK consumers should start with. - `usePosition()` - `useLayout()` - `useConstraints()` +- `useComponentProperties()` - `useAppearance()` - `useSharedStyleBinding()` - `useColorModel()` diff --git a/packages/vue/src/controls/component-props/index.ts b/packages/vue/src/controls/component-props/index.ts new file mode 100644 index 000000000..2551633ff --- /dev/null +++ b/packages/vue/src/controls/component-props/index.ts @@ -0,0 +1,2 @@ +export * from './model' +export * from './use' diff --git a/packages/vue/src/controls/component-props/model.ts b/packages/vue/src/controls/component-props/model.ts new file mode 100644 index 000000000..1480401dd --- /dev/null +++ b/packages/vue/src/controls/component-props/model.ts @@ -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 + 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 { + 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 +} diff --git a/packages/vue/src/controls/component-props/use.ts b/packages/vue/src/controls/component-props/use.ts new file mode 100644 index 000000000..96e2547ac --- /dev/null +++ b/packages/vue/src/controls/component-props/use.ts @@ -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, 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(() => { + 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 } +} diff --git a/packages/vue/src/i18n/locales/de/panels.json b/packages/vue/src/i18n/locales/de/panels.json index 340d3fbe7..3d263ff58 100644 --- a/packages/vue/src/i18n/locales/de/panels.json +++ b/packages/vue/src/i18n/locales/de/panels.json @@ -138,6 +138,7 @@ "strokeMiterLimit": "Gehrungsgrenze", "add": "Hinzufügen", "variants": "Varianten", + "componentProperties": "Komponenteneigenschaften", "gapAuto": "Automatischer Abstand", "horizontalGap": "Horizontaler Abstand", "verticalGap": "Vertikaler Abstand", diff --git a/packages/vue/src/i18n/locales/es/panels.json b/packages/vue/src/i18n/locales/es/panels.json index db8805d63..4a4fcc387 100644 --- a/packages/vue/src/i18n/locales/es/panels.json +++ b/packages/vue/src/i18n/locales/es/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/fr/panels.json b/packages/vue/src/i18n/locales/fr/panels.json index 64d268d76..6b37f9302 100644 --- a/packages/vue/src/i18n/locales/fr/panels.json +++ b/packages/vue/src/i18n/locales/fr/panels.json @@ -138,6 +138,7 @@ "strokeMiterLimit": "Limite d’onglet", "add": "Ajouter", "variants": "Variantes", + "componentProperties": "Propriétés du composant", "gapAuto": "Espacement auto", "horizontalGap": "Espacement horizontal", "verticalGap": "Espacement vertical", diff --git a/packages/vue/src/i18n/locales/it/panels.json b/packages/vue/src/i18n/locales/it/panels.json index 602b1f10b..ab4c6c4c0 100644 --- a/packages/vue/src/i18n/locales/it/panels.json +++ b/packages/vue/src/i18n/locales/it/panels.json @@ -138,6 +138,7 @@ "strokeMiterLimit": "Limite mitra", "add": "Aggiungi", "variants": "Varianti", + "componentProperties": "Proprietà del componente", "gapAuto": "Spaziatura auto", "horizontalGap": "Spaziatura orizzontale", "verticalGap": "Spaziatura verticale", diff --git a/packages/vue/src/i18n/locales/ja/panels.json b/packages/vue/src/i18n/locales/ja/panels.json index bbcb094d1..e7772c066 100644 --- a/packages/vue/src/i18n/locales/ja/panels.json +++ b/packages/vue/src/i18n/locales/ja/panels.json @@ -155,6 +155,7 @@ "strokeMiterLimit": "マイター制限", "add": "追加", "variants": "バリアント", + "componentProperties": "コンポーネントのプロパティ", "gapAuto": "間隔自動", "horizontalGap": "水平方向の間隔", "verticalGap": "垂直方向の間隔", diff --git a/packages/vue/src/i18n/locales/pl/panels.json b/packages/vue/src/i18n/locales/pl/panels.json index 32303f268..46eaaab58 100644 --- a/packages/vue/src/i18n/locales/pl/panels.json +++ b/packages/vue/src/i18n/locales/pl/panels.json @@ -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", diff --git a/packages/vue/src/i18n/locales/ru/panels.json b/packages/vue/src/i18n/locales/ru/panels.json index 7d1194d84..5cf5c3f5f 100644 --- a/packages/vue/src/i18n/locales/ru/panels.json +++ b/packages/vue/src/i18n/locales/ru/panels.json @@ -138,6 +138,7 @@ "strokeMiterLimit": "Предел острого соединения", "add": "Добавить", "variants": "Варианты", + "componentProperties": "Свойства компонента", "gapAuto": "Авто отступ", "horizontalGap": "Горизонтальный отступ", "verticalGap": "Вертикальный отступ", diff --git a/packages/vue/src/i18n/locales/zh-cn/panels.json b/packages/vue/src/i18n/locales/zh-cn/panels.json index b1eaed683..5a8463307 100644 --- a/packages/vue/src/i18n/locales/zh-cn/panels.json +++ b/packages/vue/src/i18n/locales/zh-cn/panels.json @@ -138,6 +138,7 @@ "strokeMiterLimit": "尖角限制", "add": "添加", "variants": "变体", + "componentProperties": "组件属性", "gapAuto": "自动间距", "horizontalGap": "水平间距", "verticalGap": "垂直间距", diff --git a/packages/vue/src/i18n/messages/panels.ts b/packages/vue/src/i18n/messages/panels.ts index f4af289ac..273a55032 100644 --- a/packages/vue/src/i18n/messages/panels.ts +++ b/packages/vue/src/i18n/messages/panels.ts @@ -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', diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index b06880fe0..cacbfee60 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -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' diff --git a/src/components/DesignPanel.vue b/src/components/DesignPanel.vue index 000ce1320..6aa79ed00 100644 --- a/src/components/DesignPanel.vue +++ b/src/components/DesignPanel.vue @@ -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() + @@ -105,7 +106,7 @@ const { panels } = useI18n() - + diff --git a/src/components/properties/VariantSection.vue b/src/components/properties/VariantSection.vue deleted file mode 100644 index 4bccb6484..000000000 --- a/src/components/properties/VariantSection.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - diff --git a/src/components/properties/component-properties/ComponentPropertiesSection.vue b/src/components/properties/component-properties/ComponentPropertiesSection.vue new file mode 100644 index 000000000..2555c99b3 --- /dev/null +++ b/src/components/properties/component-properties/ComponentPropertiesSection.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/properties/component-properties/ComponentPropertyTextField.vue b/src/components/properties/component-properties/ComponentPropertyTextField.vue new file mode 100644 index 000000000..7289e5d7f --- /dev/null +++ b/src/components/properties/component-properties/ComponentPropertyTextField.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui/AppSwitch.vue b/src/components/ui/AppSwitch.vue new file mode 100644 index 000000000..e3ee54d5f --- /dev/null +++ b/src/components/ui/AppSwitch.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/theme/switch.ts b/src/theme/switch.ts new file mode 100644 index 000000000..3dc407241 --- /dev/null +++ b/src/theme/switch.ts @@ -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 diff --git a/tests/e2e/design/panel.spec.ts-snapshots/design-panel-paint-effects-export-openpencil-darwin.png b/tests/e2e/design/panel.spec.ts-snapshots/design-panel-paint-effects-export-openpencil-darwin.png index 21814db0d..a9ef7ec1c 100644 Binary files a/tests/e2e/design/panel.spec.ts-snapshots/design-panel-paint-effects-export-openpencil-darwin.png and b/tests/e2e/design/panel.spec.ts-snapshots/design-panel-paint-effects-export-openpencil-darwin.png differ diff --git a/tests/e2e/properties/component-properties.spec.ts b/tests/e2e/properties/component-properties.spec.ts new file mode 100644 index 000000000..46ca4625d --- /dev/null +++ b/tests/e2e/properties/component-properties.spec.ts @@ -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) +}) diff --git a/tests/e2e/properties/component-properties.spec.ts-snapshots/component-properties-controls-openpencil-darwin.png b/tests/e2e/properties/component-properties.spec.ts-snapshots/component-properties-controls-openpencil-darwin.png new file mode 100644 index 000000000..d71936624 Binary files /dev/null and b/tests/e2e/properties/component-properties.spec.ts-snapshots/component-properties-controls-openpencil-darwin.png differ diff --git a/tests/engine/editor/components/properties.test.ts b/tests/engine/editor/components/properties.test.ts new file mode 100644 index 000000000..98d82aa59 --- /dev/null +++ b/tests/engine/editor/components/properties.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'bun:test' + +import { createEditor } from '@open-pencil/core/editor' + +function childByName(editor: ReturnType, 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') + }) +}) diff --git a/tests/engine/io/fig/export/component-properties.test.ts b/tests/engine/io/fig/export/component-properties.test.ts new file mode 100644 index 000000000..68d933029 --- /dev/null +++ b/tests/engine/io/fig/export/component-properties.test.ts @@ -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' + }) + }) +}) diff --git a/tests/engine/io/fig/import/component-props.test.ts b/tests/engine/io/fig/import/component-props.test.ts index a661b99a2..ff4ea7aa3 100644 --- a/tests/engine/io/fig/import/component-props.test.ts +++ b/tests/engine/io/fig/import/component-props.test.ts @@ -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)) diff --git a/tests/engine/io/fig/import/raw-field-coverage.test.ts b/tests/engine/io/fig/import/raw-field-coverage.test.ts index 84b92d5fa..a9a75844d 100644 --- a/tests/engine/io/fig/import/raw-field-coverage.test.ts +++ b/tests/engine/io/fig/import/raw-field-coverage.test.ts @@ -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', diff --git a/tests/engine/kiwi/serialize-fixes/component-metadata.test.ts b/tests/engine/kiwi/serialize-fixes/component-metadata.test.ts index 7f3005933..812a46eab 100644 --- a/tests/engine/kiwi/serialize-fixes/component-metadata.test.ts +++ b/tests/engine/kiwi/serialize-fixes/component-metadata.test.ts @@ -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 } } } + ]) + }) }) diff --git a/tests/engine/vue/controls/component-properties.test.ts b/tests/engine/vue/controls/component-properties.test.ts new file mode 100644 index 000000000..d7e1bc905 --- /dev/null +++ b/tests/engine/vue/controls/component-properties.test.ts @@ -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 } + ]) + }) +})