From 23645d5a2d3836afae620c04512b35c59e68eb2a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 17 Jul 2026 21:59:49 +0300 Subject: [PATCH] feat(editor): add component property controls - Model typed component definitions, references, assignments, and preferred swap values - Add SDK-owned variant, text, boolean, and nested instance-swap controls with mixed-selection undo - Preserve assignments across variant changes and component synchronization - Round-trip component property metadata through .fig import and export --- CHANGELOG.md | 1 + .../core/src/editor/bridges/components.ts | 5 +- packages/core/src/editor/components.ts | 8 +- .../core/src/editor/components/properties.ts | 253 ++++++++++++++++++ .../core/src/editor/components/variants.ts | 4 + .../core/src/kiwi/fig/node-change/convert.ts | 88 +++++- .../src/kiwi/fig/node-change/export-node.ts | 84 +++++- packages/docs/.vitepress/sdk-sidebar.ts | 1 + packages/docs/development/roadmap.md | 4 +- .../programmable/sdk/api/composables/index.md | 1 + .../composables/use-component-properties.md | 43 +++ packages/docs/reference/node-types.md | 4 + packages/scene-graph/src/copy.ts | 5 +- packages/scene-graph/src/instances.ts | 16 +- packages/scene-graph/src/node-defaults.ts | 2 + packages/scene-graph/src/source-metadata.ts | 15 +- packages/scene-graph/src/types.ts | 10 + packages/vue/README.md | 1 + .../vue/src/controls/component-props/index.ts | 2 + .../vue/src/controls/component-props/model.ts | 62 +++++ .../vue/src/controls/component-props/use.ts | 89 ++++++ packages/vue/src/i18n/locales/de/panels.json | 1 + packages/vue/src/i18n/locales/es/panels.json | 1 + packages/vue/src/i18n/locales/fr/panels.json | 1 + packages/vue/src/i18n/locales/it/panels.json | 1 + packages/vue/src/i18n/locales/ja/panels.json | 1 + packages/vue/src/i18n/locales/pl/panels.json | 1 + packages/vue/src/i18n/locales/ru/panels.json | 1 + .../vue/src/i18n/locales/zh-cn/panels.json | 1 + packages/vue/src/i18n/messages/panels.ts | 1 + packages/vue/src/index.ts | 10 + src/components/DesignPanel.vue | 5 +- src/components/properties/VariantSection.vue | 63 ----- .../ComponentPropertiesSection.vue | 73 +++++ .../ComponentPropertyTextField.vue | 31 +++ src/components/ui/AppSwitch.vue | 36 +++ src/theme/switch.ts | 24 ++ ...paint-effects-export-openpencil-darwin.png | Bin 22622 -> 24982 bytes .../properties/component-properties.spec.ts | 159 +++++++++++ ...-properties-controls-openpencil-darwin.png | Bin 0 -> 7932 bytes .../editor/components/properties.test.ts | 133 +++++++++ .../fig/export/component-properties.test.ts | 69 +++++ .../io/fig/import/component-props.test.ts | 41 ++- .../io/fig/import/raw-field-coverage.test.ts | 2 + .../component-metadata.test.ts | 55 +++- .../vue/controls/component-properties.test.ts | 57 ++++ 46 files changed, 1371 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/editor/components/properties.ts create mode 100644 packages/docs/programmable/sdk/api/composables/use-component-properties.md create mode 100644 packages/vue/src/controls/component-props/index.ts create mode 100644 packages/vue/src/controls/component-props/model.ts create mode 100644 packages/vue/src/controls/component-props/use.ts delete mode 100644 src/components/properties/VariantSection.vue create mode 100644 src/components/properties/component-properties/ComponentPropertiesSection.vue create mode 100644 src/components/properties/component-properties/ComponentPropertyTextField.vue create mode 100644 src/components/ui/AppSwitch.vue create mode 100644 src/theme/switch.ts create mode 100644 tests/e2e/properties/component-properties.spec.ts create mode 100644 tests/e2e/properties/component-properties.spec.ts-snapshots/component-properties-controls-openpencil-darwin.png create mode 100644 tests/engine/editor/components/properties.test.ts create mode 100644 tests/engine/io/fig/export/component-properties.test.ts create mode 100644 tests/engine/vue/controls/component-properties.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d938a05..5fda5d7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,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 21814db0d287b003c5f6f72b26f9548e95463445..a9ef7ec1c69183a85ec5e7de9cf3a40f65879975 100644 GIT binary patch literal 24982 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 F{})p2hcy5I literal 22622 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 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 0000000000000000000000000000000000000000..d71936624467cf2618b9d6be20f5cb474a73ad0a GIT binary patch literal 7932 zcmZ{p1yEbzx9uq!ED+owc+uic(Lj*`f#U9N#fuaPQlQYH#iclu0>!nsOK~XfMT)z; z)46l!fA75aG836^l5@Vv*=w)g+9yi=wE`{{1r`z#67DNSIXLjX4LnCd$iQpC)m}Xk z5AA9Z!2dW~#R=X_TB zKiBA%Q_^vrnhL(_;$%|6Ur_QC>8cs=J4xcdGvX8Ux3OfNx3zKy&^o0fl^l6YnLoDY z2b8)jHN8=7yZsY1`#FwUtgf-~3nWZ2o-YKSm{Cv0GTsvNlk{gMIrLY__~ie+0QG!c zANHz+F)+BA%*3v^yT{^b>G%`k9M0I!qy+OJBDLJ%j?Tbu*^EkMz`I=bG}*jtTdr41 zjd{2PB`U;$d_dU;qzb)6ZjzJe*D6W#E%d})ENI*p3Gy#G^ZU&$EH3;4Qf^ubq)VjL zx=g$UTKu&hqI%&eg?>#lx;a%-9nMLgk!ZgR4svK)hZA<0wiE;#!-GoXoU6WkKV;uW z;2t3MuLnBKW?dZ`yB7yl1g$L&AD43RzqE50GM~uMAf{#qW5g+?KD3J?-*5_A>^RPW zuas0y+uJ7$%WL@cQzwjl?wFnzJeDurYYAFE>=D5WWY+WJZ*?904AbK{w3s!H%Q`PM z%*-uF?!E5u%SO7nkqVv{JQJR==VM@bl5e1~>>7)?N{6uSFIhgf6VRDXdo7gCeHKKx zBTI$wd3ArnJ~y2W14-L*P?kpGeeR>N)nB&c>X|)TaMFASC$wHo;zLjgk~xLZ(p zBrBXLDV*>`a*_p|TmHkmy>QusO`?u_>g+1Brk!;cJG#S=Q|{keMcEJ{o_<%uhaR^J zn`@Mh$3b(A@uC}zQyoPF4?CwdPjgK?YOM>7lRrCp%SdhpI_+c7^kS74ho`BlGbiua z1&;SXR_li!@Vo@YgY>ddf@Lxr7&jAn5pa@mbGDVGYy14Yyp6el*dYoPgr>|RRr&6$ z@$1F#mhHHcgrbPH=&lDVh;}+t*~ta=!e1hIyV>JaA-d%Ar2bL6I%<34lKAU&QhBxn zkL~TVg~L~mS5=o{;@5dzIw31TRJO)ub-v!h-L{iBC(RD##}55^F#?8~oGIR&nS47gaXM`_lD>Ibw!aCFWi+|yRhl1q6* zp8S7YOu9d;tvzB}v`%u{1$8XT9DYO|DU{MXsQ6iFI68v&}mLUb!D%p?){ATqx96YTP{!RocnUsV{|! z)fPCWKKV!P`3&s{Um7mfiC?fKym<+`pd#{ZI%E&>y{;(jX>Enal;bK;+Xp^W56>z~Z$4Da<(9$LT}7s3*?JcAmLZEr+dpaN@}Vy4|q zpID36mzB;Kb6S~_c;mv>ji;leMW7w^E);_7n%jf)w4Mq7$|m2=W)T=#0-_IBfvnyt z$$rNcED=mGuncE@i+O2Zfm@M`MXUE+i(X)1VGt+cN3vgns*aOz3H)4Mgc>VYd5zp} zK0d!rYkdtY)Kf!ALRQ*fRs19#J(BbJN@4rbbaiLml7MOWI!z}~K6}!Xi&sxTSYD~a z!bfg(PHCe`P??iv>)C1XS(=lmL+Hemm%KM?H#^u&E%QX23+EZE_QbN~?=YROO0hXz z7TNP>ad{oCJ@MLaM z#2?iM=zS2e&zXY#W>y2}mJXjJkWy-(jMszT;vi z!4x?^T%Cct0Vc`Tqc~ZD=yAdEb@g_u&pjHf-b5WmnBKf!v0fZwuAO6sdS!(GF`aCLjK|Dt zSsx}rt;WG~UvCA+4k1=YlgS{rl*9Kk@Ca5fnB6A%r5`v6s-9a>xh>+)FWD=WqmHN-CRqI<95$}7OPlx@kun_P}@#v zfazoHyW+@y4s~uuMV2mVGH$~k9>Ed2<*~LQy7;x3^Ry;50#BX6G5F!#1<>WlY11k= zb3};7)y@&3vhC1GR;jgs{DcbL0;%B@S(ow8bCYs$5?nk7sh!lP@MRl>l4jdKn8;<@ z3d+biR0-XBu*v;l*++}+)H`VQBX_JgmT!Rkn)r%vdS|5WKH@0+ss3wgYm5n_3YZh+ zS@mvB8s`gZ*}I;yOf@ryI2Xo9=R+QkyEkidwp+W5PiP8LPqC?#Cp;d#({as2ES*Wa zoT|b`?a2bJvTvQQ%1Z}1LWBU|xZOzgN(=_r&X9^J^GWV51<6OqM& zIv$F0%?#xeBJ$n>f*rJN)9{O@sF_PSU1|q!f!GDZZ7KyXw(j-o4=)?@@zXU$chcr_ zI4dfGjrK)VU%nX))F$HUHFfF*R}e7Hry0C)HGDZpTZTc_RK za*@|*5x;%7r5hr~^)MeFA2Bg8eSLjl`Beo41=ZEnwY3*V&3|F>FC6E-7Zx%{_wc^m zR+)2!!wJrf-n{Yr+j=E>X0tpESB+Xm= zUT%FS+Z;n@s9?(x9gdu&guw9g@)C?8G(-Bap8;iHK=}}M(P>zz7Xr)ISQPR6qvh!- zzyPYpXPY-}^&82OptO!U93NLNfJf<)boS`b($e~)qoD!&rxx?VspSfcJTvxTL;gKk zq((|Y0#i`Xq(oj5l(EnQ5ea-xOibL|wB#pEOiIcW_azt%_1D-6Y0PUwFmq0|c6N2; z>i3fd2U~EbHI4hdaNksvtN*^pXEQqI$WNH&iZP2$n}^jCs%4WNCRO2u|Mm9vwyw5T zgm!%{+G%PmM~gKT64u->V}||HQ=QROt21Ya@&L`2&H{Bp!Zzj^lvlAjtewcD`BIgr}3=?)MO!Z+L9%T6L9M{3& zIq(%I?ru@c7XpEc{8EWAERBlsZ(*xuc*?P!w{K16V)#Qdjv3Vx8zoJ)DZhcp^U6ej zSq0`&*9D?jlMMx#qE7IUv0vU^wyP?>&J7Nxw?*nfksEvFDQzL+4v+pH`^z`@d-Qq0 z^69$oC9_KEs$%2WsMu_!$rvcFk1mBt4u+@X)gKR4lekD!D)?nzYf$GnFg)zPUs3NL z5J1YQ-+fRs{POAk3@9G2v+a?f`wiNJ&!0ElZ<5$`k~s~+@2`R1`4e>K$4L%?*4b#{ z9nH<@&->~zz*PN0_YV&yfj3))g@tM_A3WB(qp(T2TKsR^ot=+R@WhcILPA0|mX@o< zOU>RdPx~1dU@`AqanZX%(6Eht)(~4mDeOsuK!yFCui3di*@(es*)902$oL(qpp-2c z0BF$Hw|*w%^1WqWETxdwfo|KO?w#%V?u6?|fpUsHe_BRHhJeG2O1j`-f|_J7tIbFn z!pD0s@%i-h^tiL1pC3@pK(W@aOENJbQq(mxs=N;MVUfvz4uicueE5(WPz&9?=tN+b zzw?o*&JMVo4@SYb7A!S+yHnR*`9e~X<{M_~>G{IK!tSnZbWcbbEuZ!9Oof42UQ6Eg z&Q6MXk!)2ai!gp3N|5t6eV;E1=YxJ+$p{5MUtcKAR!v(-NC=lvQ&_CiVqNR$Ap6_x z;i`GZNUZq0$vrTwws(ur+Ldn=bK^F_q7qB6}e3ca`LD;G#LJ@!tU4O zXK`6}w>v)DqHAkwo}QjY-}K+JBEQBt$#b}DRn%WI>1$@B(O3dVRMIvi0a$lZZB=%HNQ6@jOw zqLMhYMG?!)$5&HZn>24z*(F^7x6{*`e|)(6@_?3?z^reH?UIcXwVjof0jv133i+7AB@wjGy-=i&7F39e+1KvJ+U;yvjYL z@SRuNhSF4+cLlh(oL4)7fyYOkFcMPIvT1uq!zPdC%*=%?EmVRy(W+`{5xP~e!~aed zFwm(ARPh@&y@)z7SW^S!5JNO=k=l94xF5Ry-1Twbq$J2b z(*$GmK!7i-g8Da+2!tTsILi6gl9wKt(~exEuIcL#SK>cPbpP?#3h)9e5)!!J-z?x? z6kiLmoN(?*M=tJ5I;Im8E3U?NR3%tQdjA@=0FF8R*%)&SSzcO4os^#$D1^^es1pgG zsnqd~_jtr~ky)}iy(BCvAjLfPJpbb~KGkr_|6 z^5xw@jcU5!QsCWTXPnV@)1U93E$KhJ{oUYZ5^yms;N5z?9!(o~4c&)fcDvw?DPAsl zuf#@1cI*I+D?TOV>Tcz!E$HbHIS$bl0jBob5Fa&ic5lS~i6h|g+uw&SIW{IHCMwEz zuOQ`GnfB%NYAE1$Ow7#T2!zDd@3${B7?*>d9xVDk2K*^00%#%2cbgnAzY6WUQL(?` zkJlS>fyX~F2nh&su60ZT1Vlv0pTW$`)+k+Cf@xZ&%XBtsy#Fp+*Z2-4b9yg%l2Wx6 z7NRqG(k&Q)$O($=2&8H$nV9ZMxbAS&s7L{-Pf$CoRT6}USNjfJ7iQVBZFMG>;Z|5{j} zASRw}JRV6GvLJOv5oWF7Mh;P%V^iwty}j5sJD%HJtmjS~sfUPsEh-`sdC6S2TceI02-pF;L$ zPOpI4FaSD9wr;ca2*qPDf{WI7^AiG_R9j2ySl)azLnPFd#Ent>FJO*lmt>~ELh(0F z;}&5qE^rhpYN?FJkfG(><)Q05@YkM}d^&c_kgw2Y5DrH$oFwTOON%;_5w*E(z%q(~ zM)jtZ$D;Rz3?nF$2<&jci)RvtQkDOfp3TgsTI}WIrx2QmKY#3Zh^U5^z>>I3Yipyw zk>zvU!^OHqAbyD1ndyfEYKaDf>9KQm(`NqMLpA`u(Gg22l*w|U{|{RG zXGs6>&DA%uzP?^az#Y;~&!y-!o+FJ-!qKpk_3n6}PfkHz9*}&%FkyoRKsZfLk9XuJ zF5%-HNSPozJG%%x+BTmPa}|~0udfm^ilzv{5PNB;Hy6&nsxNi*t>RMb_SbYm3 zupUmGDAVCS=e4x9uKV#r>Nf!2*qE5o5qi?nKLKk1#6g!ZaCx*uNkj8-xL@1INW%Bj z(o)!U_5B8sf1a+bZLx9XN_$X{swytr)#Z4v8Zh8y@4vnCgoK2^Mm`$#ci?pksALF3 zga|}6>>V83o&lAaxxwq&U!qwAT-UD1bYuV$^((%WmuK+VyoSMGhK7a#0TQ%QU{2#E z54-)Dit&|Vjr@3Ct3jfEwCC{*KZvQR0p0cPxSk;WVf-ZFX5{YY>9N;b0o^NTNPD%nY>Zwsqc|RX~+H zo0~CYJje@H-KxRZRR>uT@4OBdtoSJ)(BX8UvDw*_`FC#7A3o5TfQ0Dike;hge_~LG zM^+9;y}Q`Q&NdaWFb@vK(QXE)akZwT1hZD?dVL~aAxVj;4)9R1xZi6G%r`7r z(V893RnJzoT)O4$K58V5QhtQt?DPx?P{b~PgI&n6Fw!zN85-u(#_yiRVUum8cxU*1=1L%g>D*7Z>+lYWLUJm7BJH zi(SZ2aE10_f!)vW6IO4FTU;5-#Fe!epKdWyG;D>8KV+CuIcP~D%e#UBw;ulV+uO~n zBmNxB49+Bm``>%AX`KvY+?yn-XY?v(#qxVTZm^3}l3>3JPlRy&U92CgidN|89tm^c zQpRr=<>v_h&%}Q1qoqc)LMYOo1Gt#cw!#^5<%`^*4-mO4SG{Ur%27cz0*lYF2ynuf zrBsUcSZ;ie{;afzaOtPOfK27F&hq=|A~o7`w%kyxMSzNT9i*N@-maAJ`F|4yZ)WO# z)$epNjOVz02^GF(*T2(*!!u02LmY~LV(*Kijfh(|T|jR6l6Dn!MCTFnObb+Ssz}Gl zd6>o>CQa{E^=$?v)c$5a7SxFg(8(QpO?z$g+kkJ{MH0Iufe^ z3(~nPY?DGR%+)%ZKt}~i;-D?YQr~ZPd;KUmO1UFlLrQ^9Cf)GSA)yoP<8ax$t zdWtRq6*OfpP$Lv5T>_vl8X8(-W23(kXY->e%26c|F~0ML{st?({x0Q`DJ&ntBNc%F z(M|5}B^8}{(zfJ%A zIiwpHtxQc#IWGLz1A-qpIXRYC`~eU_y$|MsWv|&KKzVl!2h~+oRWEef(7m;_Not~X zxl(qvxAiM#0JH~_^D^@C671o6r3T<$bJ@f|!lVq7%T<{7U(`mAeQ;BW`DrZaJS2jE zghjZDykG@{1Olx}iaP&pc&=I4Ay<`|k-_mv5gkkgQe{X6D3=d78_?~6KU=ftwUrmG zd@ELVBtElRRz_l?WtA?NT2wByB{HjP1hI3ndv$pUyL*2oOZ)r>70bkA!#o$Z&7pWu z^tsYKvbqlP|24CS)<%{r(C)vLM}^|I6Y251l z*LWLRsFn>NZQPkt&AcHTehT3C6V%X^-WK&6SOi1V#~TNlN(>z!kFF8O@w)HtPxp!tR+!lf1mV zax%xBAw{-<7ZCJbB`}}xVuoz+qyzwI4;Tl{mg}L|eI(;qUyGmxgFw1X9wigDhPC!@ z?CkacsuBgr)7piH2FUO!CV`EB#HB;g|HXq$Wo1=WAcX~;4zS$t8^I#ufwA7j-<1^+ zG?SY<^X5<+Ly|ypV&W~3Jyg9r2Y>^R*GXhV&k9!de8I1_#^gD2+@z0K0#7Pv+?wd^m(-FgR^$H;2i!ue2+)>uYNz9?mBr5D1_r zAo;BEjfp3ur(>_>c@dM52?ssiudc2h95@D}fyrJVIic0qjAaQpkhBuX9T1U{<};6o zhxO^>7{5Cd_(AXwoaep)x|2{Tn|?fW36iaFI9#apVLe!=WoKbgFtr210ZonC2S+ip+xj3xV3JVMZaYlz3`b^|~A}bbbWE6oeE*dBPse6Tc|K`rJJCa7jx| zSRtiWs3&DI|4%CX@1b6d*T64qYfQ*V5OC}I>FIr}_!{s5{a;{3K?)V%xAvGe*9TUR OUdg|f`zB)=@_zu_4jgj; literal 0 HcmV?d00001 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 } + ]) + }) +})