Merge branch 'open-pencil:master' into master
This commit is contained in:
commit
1151383de0
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
253
packages/core/src/editor/components/properties.ts
Normal file
253
packages/core/src/editor/components/properties.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import type {
|
||||
ComponentPropertyDefinition,
|
||||
ComponentPropertyReferenceField,
|
||||
SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import type { EditorContext } from '#core/editor/types'
|
||||
|
||||
interface PropertyTarget {
|
||||
node: SceneNode
|
||||
field: ComponentPropertyReferenceField
|
||||
source: SceneNode
|
||||
}
|
||||
|
||||
function definitionOwners(ctx: EditorContext, instance: SceneNode): SceneNode[] {
|
||||
if (!instance.componentId) return []
|
||||
const component = ctx.graph.getNode(instance.componentId)
|
||||
if (!component) return []
|
||||
const parent = component.parentId ? ctx.graph.getNode(component.parentId) : null
|
||||
return parent?.type === 'COMPONENT_SET' ? [parent, component] : [component]
|
||||
}
|
||||
|
||||
function definitionsForInstance(
|
||||
ctx: EditorContext,
|
||||
instance: SceneNode
|
||||
): ComponentPropertyDefinition[] {
|
||||
const byId = new Map<string, ComponentPropertyDefinition>()
|
||||
for (const owner of definitionOwners(ctx, instance)) {
|
||||
for (const definition of owner.componentPropertyDefinitions) {
|
||||
if (!byId.has(definition.id)) byId.set(definition.id, definition)
|
||||
}
|
||||
}
|
||||
return [...byId.values()]
|
||||
}
|
||||
|
||||
function findPropertyPath(
|
||||
ctx: EditorContext,
|
||||
sourceParent: SceneNode,
|
||||
propertyId: string,
|
||||
path: number[] = []
|
||||
): { path: number[]; field: ComponentPropertyReferenceField; source: SceneNode } | null {
|
||||
for (const [index, childId] of sourceParent.childIds.entries()) {
|
||||
const child = ctx.graph.getNode(childId)
|
||||
if (!child) continue
|
||||
const reference = child.componentPropertyReferences.find((ref) => ref.propertyId === propertyId)
|
||||
if (reference) return { path: [...path, index], field: reference.field, source: child }
|
||||
const nested = findPropertyPath(ctx, child, propertyId, [...path, index])
|
||||
if (nested) return nested
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function nodeAtPath(ctx: EditorContext, root: SceneNode, path: number[]): SceneNode | null {
|
||||
let node = root
|
||||
for (const index of path) {
|
||||
const childId = node.childIds[index]
|
||||
const child = childId ? ctx.graph.getNode(childId) : undefined
|
||||
if (!child) return null
|
||||
node = child
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
function propertyTarget(
|
||||
ctx: EditorContext,
|
||||
instance: SceneNode,
|
||||
propertyId: string
|
||||
): PropertyTarget | null {
|
||||
const component = instance.componentId ? ctx.graph.getNode(instance.componentId) : null
|
||||
if (!component) return null
|
||||
const match = findPropertyPath(ctx, component, propertyId)
|
||||
if (!match) return null
|
||||
const node = nodeAtPath(ctx, instance, match.path)
|
||||
return node ? { node, field: match.field, source: match.source } : null
|
||||
}
|
||||
|
||||
function swapTargetId(ctx: EditorContext, value: string): string | null {
|
||||
const direct = ctx.graph.getNode(value)
|
||||
if (direct?.type === 'COMPONENT') return direct.id
|
||||
for (const node of ctx.graph.getAllNodes()) {
|
||||
if (node.type !== 'COMPONENT') continue
|
||||
if (
|
||||
node.source.id === value ||
|
||||
node.componentKey === value ||
|
||||
node.sourceLibraryKey === value
|
||||
) {
|
||||
return node.id
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function targetValue(target: PropertyTarget | null): string {
|
||||
if (!target) return ''
|
||||
if (target.field === 'TEXT') return target.node.text
|
||||
if (target.field === 'VISIBLE') return String(target.node.visible)
|
||||
return target.source.componentId ?? target.node.componentId ?? ''
|
||||
}
|
||||
|
||||
function propertyOverrides(
|
||||
ctx: EditorContext,
|
||||
instance: SceneNode,
|
||||
target: PropertyTarget | null,
|
||||
value: string,
|
||||
swapComponentId: string | null
|
||||
): Record<string, unknown> {
|
||||
const overrides = { ...instance.overrides }
|
||||
if (target?.field === 'TEXT') overrides[`${target.node.id}:text`] = value
|
||||
else if (target?.field === 'VISIBLE') overrides[`${target.node.id}:visible`] = value === 'true'
|
||||
else if (target?.field === 'INSTANCE_SWAP') {
|
||||
overrides[`${target.node.id}:componentId`] = value
|
||||
overrides[`${target.node.id}:sourceComponentId`] = target.source.id
|
||||
const componentName = swapComponentId ? ctx.graph.getNode(swapComponentId)?.name : undefined
|
||||
if (componentName) overrides[`${target.node.id}:name`] = componentName
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
function updatePropertyTarget(
|
||||
ctx: EditorContext,
|
||||
target: PropertyTarget | null,
|
||||
value: string,
|
||||
swapComponentId: string | null
|
||||
): void {
|
||||
if (target?.field === 'TEXT' && target.node.type === 'TEXT') {
|
||||
ctx.graph.updateNode(target.node.id, { text: value })
|
||||
} else if (target?.field === 'VISIBLE') {
|
||||
ctx.graph.updateNode(target.node.id, { visible: value === 'true' })
|
||||
} else if (
|
||||
target?.field === 'INSTANCE_SWAP' &&
|
||||
target.node.type === 'INSTANCE' &&
|
||||
swapComponentId
|
||||
) {
|
||||
ctx.graph.swapInstanceComponent(target.node.id, swapComponentId)
|
||||
}
|
||||
}
|
||||
|
||||
function applyPropertyValue(
|
||||
ctx: EditorContext,
|
||||
instanceId: string,
|
||||
definition: ComponentPropertyDefinition,
|
||||
value: string
|
||||
): void {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
if (instance?.type !== 'INSTANCE') return
|
||||
const target = propertyTarget(ctx, instance, definition.id)
|
||||
const swapComponentId = target?.field === 'INSTANCE_SWAP' ? swapTargetId(ctx, value) : null
|
||||
ctx.graph.updateNode(instance.id, {
|
||||
componentPropertyAssignments: {
|
||||
...instance.componentPropertyAssignments,
|
||||
[definition.id]: value
|
||||
},
|
||||
overrides: propertyOverrides(ctx, instance, target, value, swapComponentId)
|
||||
})
|
||||
updatePropertyTarget(ctx, target, value, swapComponentId)
|
||||
}
|
||||
|
||||
export function reapplyInstanceComponentProperties(ctx: EditorContext, instanceId: string): void {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
if (instance?.type !== 'INSTANCE') return
|
||||
const definitions = new Map(
|
||||
definitionsForInstance(ctx, instance).map((definition) => [definition.id, definition])
|
||||
)
|
||||
for (const [propertyId, value] of Object.entries(instance.componentPropertyAssignments)) {
|
||||
const definition = definitions.get(propertyId)
|
||||
if (definition && definition.type !== 'VARIANT') {
|
||||
applyPropertyValue(ctx, instanceId, definition, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createComponentPropertyActions(
|
||||
ctx: EditorContext,
|
||||
switchVariant: (instanceId: string, propertyName: string, newValue: string) => void
|
||||
) {
|
||||
function getInstanceComponentPropertyDefinitions(instanceId: string) {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
return instance?.type === 'INSTANCE' ? definitionsForInstance(ctx, instance) : []
|
||||
}
|
||||
|
||||
function getInstanceComponentPropertyValue(
|
||||
instanceId: string,
|
||||
definition: ComponentPropertyDefinition
|
||||
): string {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
if (instance?.type !== 'INSTANCE') return definition.defaultValue
|
||||
if (definition.type === 'VARIANT') {
|
||||
const component = instance.componentId ? ctx.graph.getNode(instance.componentId) : null
|
||||
return component?.componentPropertyValues[definition.name] ?? definition.defaultValue
|
||||
}
|
||||
const value = instance.componentPropertyAssignments[definition.id] ?? definition.defaultValue
|
||||
return definition.type === 'INSTANCE_SWAP' ? (swapTargetId(ctx, value) ?? value) : value
|
||||
}
|
||||
|
||||
function setInstanceComponentProperty(instanceId: string, propertyId: string, value: string) {
|
||||
const instance = ctx.graph.getNode(instanceId)
|
||||
if (instance?.type !== 'INSTANCE') return
|
||||
const definition = definitionsForInstance(ctx, instance).find((item) => item.id === propertyId)
|
||||
if (!definition) return
|
||||
if (definition.type === 'VARIANT') {
|
||||
switchVariant(instanceId, definition.name, value)
|
||||
return
|
||||
}
|
||||
|
||||
const previousAssignments = { ...instance.componentPropertyAssignments }
|
||||
const previousOverrides = structuredClone(instance.overrides)
|
||||
const target = propertyTarget(ctx, instance, propertyId)
|
||||
const assignedValue = instance.componentPropertyAssignments[propertyId]
|
||||
const previousValue =
|
||||
definition.type === 'INSTANCE_SWAP' && assignedValue
|
||||
? (swapTargetId(ctx, assignedValue) ?? assignedValue)
|
||||
: targetValue(target)
|
||||
|
||||
applyPropertyValue(ctx, instanceId, definition, value)
|
||||
ctx.undo.push({
|
||||
label: `Change ${definition.name}`,
|
||||
forward: () => {
|
||||
applyPropertyValue(ctx, instanceId, definition, value)
|
||||
ctx.requestRender()
|
||||
},
|
||||
inverse: () => {
|
||||
const live = ctx.graph.getNode(instanceId)
|
||||
if (live) {
|
||||
ctx.graph.updateNode(instanceId, {
|
||||
componentPropertyAssignments: previousAssignments,
|
||||
overrides: previousOverrides
|
||||
})
|
||||
const restoredTarget = propertyTarget(ctx, live, propertyId)
|
||||
if (restoredTarget?.field === 'TEXT' && restoredTarget.node.type === 'TEXT') {
|
||||
ctx.graph.updateNode(restoredTarget.node.id, { text: previousValue })
|
||||
} else if (restoredTarget?.field === 'VISIBLE') {
|
||||
ctx.graph.updateNode(restoredTarget.node.id, { visible: previousValue === 'true' })
|
||||
} else if (restoredTarget?.field === 'INSTANCE_SWAP') {
|
||||
const componentId = swapTargetId(ctx, previousValue)
|
||||
if (componentId && restoredTarget.node.type === 'INSTANCE') {
|
||||
ctx.graph.swapInstanceComponent(restoredTarget.node.id, componentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.requestRender()
|
||||
}
|
||||
})
|
||||
ctx.requestRender()
|
||||
}
|
||||
|
||||
return {
|
||||
getInstanceComponentPropertyDefinitions,
|
||||
getInstanceComponentPropertyValue,
|
||||
reapplyInstanceComponentProperties: (instanceId: string) =>
|
||||
reapplyInstanceComponentProperties(ctx, instanceId),
|
||||
setInstanceComponentProperty
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import type {
|
|||
SharedStyleType,
|
||||
VectorNetwork,
|
||||
ComponentPropertyDefinition,
|
||||
ComponentPropertyReference,
|
||||
ComponentPropertyType,
|
||||
SymbolLink,
|
||||
VariantPropSpec
|
||||
|
|
@ -635,6 +636,8 @@ export function nodeChangeToProps(
|
|||
clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true,
|
||||
componentId: extractSymbolId(nc),
|
||||
componentPropertyDefinitions: extractComponentPropertyDefs(nc),
|
||||
componentPropertyReferences: extractComponentPropertyRefs(nc),
|
||||
componentPropertyAssignments: extractComponentPropertyAssignments(nc),
|
||||
componentPropertyValues: extractComponentPropertyValues(nc),
|
||||
...extractComponentMetadata(nc)
|
||||
}
|
||||
|
|
@ -668,6 +671,35 @@ interface RawComponentPropDef {
|
|||
name?: string
|
||||
type?: string
|
||||
initialValue?: unknown
|
||||
preferredValues?: {
|
||||
stringValues?: string[]
|
||||
instanceSwapValues?: Array<{ key?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
interface RawComponentPropRef {
|
||||
defID?: GUID
|
||||
componentPropNodeField?: string | number
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
interface RawComponentPropValue {
|
||||
boolValue?: boolean
|
||||
textValue?: string | { characters?: string }
|
||||
guidValue?: GUID
|
||||
}
|
||||
|
||||
interface RawComponentPropAssignment {
|
||||
defID?: GUID
|
||||
value?: RawComponentPropValue
|
||||
varValue?: {
|
||||
value?: {
|
||||
boolValue?: boolean
|
||||
textValue?: string
|
||||
textDataValue?: { characters?: string }
|
||||
symbolIdValue?: { guid?: GUID }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface RawSymbolData {
|
||||
|
|
@ -687,12 +719,65 @@ function extractComponentPropertyDefs(nc: NodeChange): ComponentPropertyDefiniti
|
|||
name: def.name,
|
||||
type: propType,
|
||||
defaultValue: componentPropValueToString(def.initialValue),
|
||||
variantOptions: propType === 'VARIANT' ? undefined : undefined
|
||||
variantOptions: propType === 'VARIANT' ? def.preferredValues?.stringValues : undefined,
|
||||
preferredValues:
|
||||
propType === 'INSTANCE_SWAP'
|
||||
? def.preferredValues?.instanceSwapValues
|
||||
?.map((value) => value.key)
|
||||
.filter((value): value is string => value !== undefined)
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function extractComponentPropertyRefs(nc: NodeChange): ComponentPropertyReference[] {
|
||||
const refs = nc.componentPropRefs as RawComponentPropRef[] | undefined
|
||||
if (!refs?.length) return []
|
||||
const fieldMap: Record<string, ComponentPropertyReference['field'] | undefined> = {
|
||||
'0': 'VISIBLE',
|
||||
'1': 'TEXT',
|
||||
'2': 'INSTANCE_SWAP',
|
||||
VISIBLE: 'VISIBLE',
|
||||
TEXT_DATA: 'TEXT',
|
||||
OVERRIDDEN_SYMBOL_ID: 'INSTANCE_SWAP'
|
||||
}
|
||||
return refs.flatMap((ref) => {
|
||||
const field = fieldMap[String(ref.componentPropNodeField)]
|
||||
return ref.defID && field && !ref.isDeleted
|
||||
? [{ propertyId: guidToString(ref.defID), field }]
|
||||
: []
|
||||
})
|
||||
}
|
||||
|
||||
function componentPropertyAssignmentValue(assignment: RawComponentPropAssignment): string {
|
||||
if (
|
||||
assignment.value &&
|
||||
(assignment.value.boolValue !== undefined ||
|
||||
assignment.value.textValue !== undefined ||
|
||||
assignment.value.guidValue !== undefined)
|
||||
) {
|
||||
return componentPropValueToString(assignment.value)
|
||||
}
|
||||
const variableValue = assignment.varValue?.value
|
||||
if (variableValue?.symbolIdValue?.guid) return guidToString(variableValue.symbolIdValue.guid)
|
||||
if (variableValue?.boolValue !== undefined) return String(variableValue.boolValue)
|
||||
if (variableValue?.textValue !== undefined) return variableValue.textValue
|
||||
return variableValue?.textDataValue?.characters ?? ''
|
||||
}
|
||||
|
||||
function extractComponentPropertyAssignments(nc: NodeChange): Record<string, string> {
|
||||
const assignments = nc.componentPropAssignments as RawComponentPropAssignment[] | undefined
|
||||
if (!assignments?.length) return {}
|
||||
return Object.fromEntries(
|
||||
assignments.flatMap((assignment) =>
|
||||
assignment.defID
|
||||
? [[guidToString(assignment.defID), componentPropertyAssignmentValue(assignment)]]
|
||||
: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function extractVariantPropSpecs(nc: NodeChange): VariantPropSpec[] {
|
||||
const specs = nc.variantPropSpecs as Array<{ propDefId?: GUID; value?: string }> | undefined
|
||||
if (!specs?.length) return []
|
||||
|
|
@ -881,6 +966,7 @@ export const FIGMA_RAW_NODE_FIELD_KEYS = [
|
|||
'styleIdForEffect',
|
||||
'styleIdForGrid',
|
||||
'styleType',
|
||||
'componentPropAssignments',
|
||||
'backgroundPaints',
|
||||
'layoutGrids',
|
||||
'exportSettings',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
|
||||
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
|
||||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
import type {
|
||||
ComponentPropertyDefinition,
|
||||
ComponentPropertyReferenceField,
|
||||
SceneGraph,
|
||||
SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type { Color, GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
/* eslint-disable max-lines */
|
||||
|
|
@ -122,16 +127,21 @@ function createStrokePaints(context: SceneNodeToKiwiContext, node: SceneNode): P
|
|||
)
|
||||
}
|
||||
|
||||
function componentPropertyValue(value: string) {
|
||||
return { textValue: { characters: value } }
|
||||
}
|
||||
|
||||
function componentPropertyTypeForKiwi(type: string) {
|
||||
if (type === 'BOOLEAN') return 'BOOL'
|
||||
if (type === 'VARIANT') return 'TEXT'
|
||||
return type
|
||||
}
|
||||
|
||||
function componentPropertyValue(type: string, value: string, graph: SceneGraph) {
|
||||
if (type === 'BOOLEAN') return { boolValue: value === 'true' }
|
||||
if (type === 'INSTANCE_SWAP') {
|
||||
const target = graph.getNode(value)
|
||||
const guid = parseGuidOrNull(target?.source.id ?? value)
|
||||
return guid ? { guidValue: guid } : { textValue: { characters: value } }
|
||||
}
|
||||
return { textValue: { characters: value } }
|
||||
}
|
||||
|
||||
function parseGuidOrNull(value: string) {
|
||||
return /^\d+:\d+$/.test(value) ? stringToGuid(value) : null
|
||||
}
|
||||
|
|
@ -503,7 +513,37 @@ function applyInstancePayload(
|
|||
}
|
||||
}
|
||||
|
||||
function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
||||
function componentPropertyPreferredValues(definition: ComponentPropertyDefinition) {
|
||||
if (definition.type === 'INSTANCE_SWAP' && definition.preferredValues?.length) {
|
||||
return {
|
||||
instanceSwapValues: definition.preferredValues.map((key) => ({ type: 'COMPONENT', key }))
|
||||
}
|
||||
}
|
||||
if (definition.type === 'VARIANT' && definition.variantOptions?.length) {
|
||||
return { stringValues: [...definition.variantOptions] }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function componentPropertyNodeField(field: ComponentPropertyReferenceField): string {
|
||||
if (field === 'TEXT') return 'TEXT_DATA'
|
||||
if (field === 'INSTANCE_SWAP') return 'OVERRIDDEN_SYMBOL_ID'
|
||||
return 'VISIBLE'
|
||||
}
|
||||
|
||||
function findComponentPropertyDefinition(graph: SceneGraph, id: string) {
|
||||
for (const candidate of graph.getAllNodes()) {
|
||||
const definition = candidate.componentPropertyDefinitions.find((item) => item.id === id)
|
||||
if (definition) return definition
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function applyComponentMetadata(
|
||||
context: SceneNodeToKiwiContext,
|
||||
node: SceneNode,
|
||||
nc: KiwiNodeChange
|
||||
): void {
|
||||
if (node.componentKey) nc.componentKey = node.componentKey
|
||||
if (node.sourceLibraryKey) nc.sourceLibraryKey = node.sourceLibraryKey
|
||||
const publishId = node.publishId ? parseGuidOrNull(node.publishId) : null
|
||||
|
|
@ -526,13 +566,39 @@ function applyComponentMetadata(node: SceneNode, nc: KiwiNodeChange): void {
|
|||
id,
|
||||
name: def.name,
|
||||
type: componentPropertyTypeForKiwi(def.type),
|
||||
initialValue: componentPropertyValue(def.defaultValue)
|
||||
initialValue: componentPropertyValue(def.type, def.defaultValue, context.graph),
|
||||
preferredValues: componentPropertyPreferredValues(def)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((def): def is NonNullable<typeof def> => def !== null)
|
||||
if (componentPropDefs.length > 0) nc.componentPropDefs = componentPropDefs
|
||||
|
||||
const componentPropRefs = node.componentPropertyReferences
|
||||
.map((ref) => {
|
||||
const defID = parseGuidOrNull(ref.propertyId)
|
||||
if (!defID) return null
|
||||
return { defID, componentPropNodeField: componentPropertyNodeField(ref.field) }
|
||||
})
|
||||
.filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
||||
if (componentPropRefs.length > 0) nc.componentPropRefs = componentPropRefs
|
||||
|
||||
const componentPropAssignments = Object.entries(node.componentPropertyAssignments)
|
||||
.map(([propertyId, value]) => {
|
||||
const defID = parseGuidOrNull(propertyId)
|
||||
const definition = findComponentPropertyDefinition(context.graph, propertyId)
|
||||
return defID && definition
|
||||
? {
|
||||
defID,
|
||||
value: componentPropertyValue(definition.type, value, context.graph)
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((assignment): assignment is NonNullable<typeof assignment> => assignment !== null)
|
||||
if (componentPropAssignments.length > 0) {
|
||||
nc.componentPropAssignments = componentPropAssignments
|
||||
}
|
||||
|
||||
const variantPropSpecs = node.variantPropSpecs
|
||||
.map((spec) => {
|
||||
const propDefId = parseGuidOrNull(spec.propDefId)
|
||||
|
|
@ -726,7 +792,7 @@ export function sceneNodeToKiwiWithContext(
|
|||
if (node.locked) nc.locked = true
|
||||
|
||||
applyNodeVisualProps(context, node, nc)
|
||||
applyComponentMetadata(node, nc)
|
||||
applyComponentMetadata(context, node, nc)
|
||||
applyInstancePayload(context, node, nc, localIdCounter)
|
||||
if (node.type === 'COMPONENT_SET') upsertPluginData(node, NODE_TYPE_PLUGIN_KEY, node.type)
|
||||
if (nc.type === 'CANVAS') nc.pageType = 'DESIGN'
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -107,7 +107,11 @@ function syncChildren(
|
|||
const instChildMap = new Map<string, SceneNode>()
|
||||
for (const childId of instParent.childIds) {
|
||||
const child = graph.nodes.get(childId)
|
||||
if (child?.componentId) instChildMap.set(child.componentId, child)
|
||||
if (!child) continue
|
||||
const sourceComponentId = overrides[`${child.id}:sourceComponentId`]
|
||||
const mappedComponentId =
|
||||
typeof sourceComponentId === 'string' ? sourceComponentId : child.componentId
|
||||
if (mappedComponentId) instChildMap.set(mappedComponentId, child)
|
||||
}
|
||||
|
||||
for (const compChildId of compParent.childIds) {
|
||||
|
|
@ -146,7 +150,7 @@ function syncChildren(
|
|||
copyProp(instChild, compChild, key)
|
||||
}
|
||||
|
||||
if (compChild.childIds.length > 0) {
|
||||
if (compChild.childIds.length > 0 && !(`${instChild.id}:componentId` in overrides)) {
|
||||
syncChildren(graph, compChildId, instChild.id, overrides)
|
||||
}
|
||||
}
|
||||
|
|
@ -155,8 +159,12 @@ function syncChildren(
|
|||
instParent.childIds.sort((a, b) => {
|
||||
const nodeA = graph.nodes.get(a)
|
||||
const nodeB = graph.nodes.get(b)
|
||||
const idxA = nodeA?.componentId ? compChildOrder.indexOf(nodeA.componentId) : -1
|
||||
const idxB = nodeB?.componentId ? compChildOrder.indexOf(nodeB.componentId) : -1
|
||||
const sourceA = nodeA ? overrides[`${nodeA.id}:sourceComponentId`] : undefined
|
||||
const sourceB = nodeB ? overrides[`${nodeB.id}:sourceComponentId`] : undefined
|
||||
const mappedA = typeof sourceA === 'string' ? sourceA : nodeA?.componentId
|
||||
const mappedB = typeof sourceB === 'string' ? sourceB : nodeB?.componentId
|
||||
const idxA = mappedA ? compChildOrder.indexOf(mappedA) : -1
|
||||
const idxB = mappedB ? compChildOrder.indexOf(mappedB) : -1
|
||||
return idxA - idxB
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ export function createDefaultNode(
|
|||
componentId: null,
|
||||
overrides: {},
|
||||
componentPropertyDefinitions: [],
|
||||
componentPropertyReferences: [],
|
||||
componentPropertyAssignments: {},
|
||||
componentPropertyValues: {},
|
||||
componentKey: null,
|
||||
sourceLibraryKey: null,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ const RAW_SIZE_KEYS = new Set(['width', 'height'])
|
|||
|
||||
const RAW_TRANSFORM_KEYS = new Set(['x', 'y', 'rotation', 'flipX', 'flipY'])
|
||||
|
||||
const STYLE_RAW_FIELDS: Partial<Record<string, string>> = {
|
||||
const EDITED_RAW_FIELDS: Partial<Record<string, string>> = {
|
||||
fillStyleId: 'styleIdForFill',
|
||||
strokeStyleId: 'styleIdForStrokeFill',
|
||||
textStyleId: 'styleIdForText',
|
||||
effectStyleId: 'styleIdForEffect',
|
||||
gridStyleId: 'styleIdForGrid'
|
||||
gridStyleId: 'styleIdForGrid',
|
||||
componentPropertyDefinitions: 'componentPropDefs',
|
||||
componentPropertyReferences: 'componentPropRefs',
|
||||
componentPropertyAssignments: 'componentPropAssignments'
|
||||
}
|
||||
|
||||
const RAW_NODE_FIELD_KEYS = new Set([
|
||||
|
|
@ -93,11 +96,11 @@ const RAW_NODE_FIELD_KEYS = new Set([
|
|||
])
|
||||
|
||||
export function clearEditedSourceMetadata(node: SceneNode, changeKeys: string[]): void {
|
||||
const styleRawFields = changeKeys
|
||||
.map((key) => STYLE_RAW_FIELDS[key])
|
||||
const editedRawFields = changeKeys
|
||||
.map((key) => EDITED_RAW_FIELDS[key])
|
||||
.filter((field): field is string => field !== undefined)
|
||||
if (styleRawFields.length > 0) {
|
||||
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, styleRawFields)
|
||||
if (editedRawFields.length > 0) {
|
||||
node.source.fig.rawNodeFields = omit(node.source.fig.rawNodeFields, editedRawFields)
|
||||
}
|
||||
if (changeKeys.some((key) => RAW_SIZE_KEYS.has(key))) node.source.fig.rawSize = null
|
||||
if (changeKeys.some((key) => RAW_TRANSFORM_KEYS.has(key))) node.source.fig.rawTransform = null
|
||||
|
|
|
|||
|
|
@ -482,6 +482,8 @@ export interface SceneNode {
|
|||
componentId: string | null
|
||||
overrides: Record<string, unknown>
|
||||
componentPropertyDefinitions: ComponentPropertyDefinition[]
|
||||
componentPropertyReferences: ComponentPropertyReference[]
|
||||
componentPropertyAssignments: Record<string, string>
|
||||
componentPropertyValues: Record<string, string>
|
||||
componentKey: string | null
|
||||
sourceLibraryKey: string | null
|
||||
|
|
@ -512,12 +514,20 @@ export interface SceneNode {
|
|||
|
||||
export type ComponentPropertyType = 'VARIANT' | 'TEXT' | 'BOOLEAN' | 'INSTANCE_SWAP'
|
||||
|
||||
export type ComponentPropertyReferenceField = 'VISIBLE' | 'TEXT' | 'INSTANCE_SWAP'
|
||||
|
||||
export interface ComponentPropertyReference {
|
||||
propertyId: string
|
||||
field: ComponentPropertyReferenceField
|
||||
}
|
||||
|
||||
export interface ComponentPropertyDefinition {
|
||||
id: string
|
||||
name: string
|
||||
type: ComponentPropertyType
|
||||
defaultValue: string
|
||||
variantOptions?: string[]
|
||||
preferredValues?: string[]
|
||||
}
|
||||
|
||||
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ These are the main APIs most SDK consumers should start with.
|
|||
- `usePosition()`
|
||||
- `useLayout()`
|
||||
- `useConstraints()`
|
||||
- `useComponentProperties()`
|
||||
- `useAppearance()`
|
||||
- `useSharedStyleBinding()`
|
||||
- `useColorModel()`
|
||||
|
|
|
|||
2
packages/vue/src/controls/component-props/index.ts
Normal file
2
packages/vue/src/controls/component-props/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './model'
|
||||
export * from './use'
|
||||
62
packages/vue/src/controls/component-props/model.ts
Normal file
62
packages/vue/src/controls/component-props/model.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type {
|
||||
ComponentPropertyDefinition,
|
||||
ComponentPropertyType,
|
||||
SceneNode
|
||||
} from '@open-pencil/scene-graph'
|
||||
|
||||
import { MIXED, type MixedValue } from '#vue/controls/node-props/helpers'
|
||||
|
||||
export interface ComponentPropertyOption {
|
||||
value: string
|
||||
label: string
|
||||
missing?: boolean
|
||||
}
|
||||
|
||||
export interface ComponentPropertyControl {
|
||||
id: string
|
||||
name: string
|
||||
type: ComponentPropertyType
|
||||
value: MixedValue<string>
|
||||
options: ComponentPropertyOption[]
|
||||
}
|
||||
|
||||
export function compatibleComponentPropertyDefinitions(
|
||||
definitions: ComponentPropertyDefinition[][]
|
||||
): ComponentPropertyDefinition[] {
|
||||
if (definitions.length === 0) return []
|
||||
const first = definitions[0]
|
||||
const signature = (items: ComponentPropertyDefinition[]) =>
|
||||
items.map((item) => `${item.id}:${item.type}`).join('\u0000')
|
||||
const expected = signature(first)
|
||||
return definitions.every((items) => signature(items) === expected) ? first : []
|
||||
}
|
||||
|
||||
export function mergedComponentPropertyValue(values: string[]): MixedValue<string> {
|
||||
const first = values[0] ?? ''
|
||||
return values.every((value) => value === first) ? first : MIXED
|
||||
}
|
||||
|
||||
export function instanceSwapOptions(
|
||||
components: SceneNode[],
|
||||
definition: ComponentPropertyDefinition,
|
||||
value: string
|
||||
): ComponentPropertyOption[] {
|
||||
const preferred = new Set(definition.preferredValues)
|
||||
const options: ComponentPropertyOption[] = components
|
||||
.filter((node) => node.type === 'COMPONENT')
|
||||
.map((node) => ({
|
||||
value: node.id,
|
||||
label: node.name,
|
||||
preferred:
|
||||
preferred.has(node.componentKey ?? '') || preferred.has(node.sourceLibraryKey ?? '')
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(right.preferred) - Number(left.preferred) || left.label.localeCompare(right.label)
|
||||
)
|
||||
.map(({ value: optionValue, label }) => ({ value: optionValue, label }))
|
||||
if (value && !options.some((option) => option.value === value)) {
|
||||
options.push({ value, label: value, missing: true })
|
||||
}
|
||||
return options
|
||||
}
|
||||
89
packages/vue/src/controls/component-props/use.ts
Normal file
89
packages/vue/src/controls/component-props/use.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { computed } from 'vue'
|
||||
|
||||
import type { SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import {
|
||||
compatibleComponentPropertyDefinitions,
|
||||
instanceSwapOptions,
|
||||
mergedComponentPropertyValue,
|
||||
type ComponentPropertyControl,
|
||||
type ComponentPropertyOption
|
||||
} from '#vue/controls/component-props/model'
|
||||
import { MIXED } from '#vue/controls/node-props/helpers'
|
||||
import { useEditor } from '#vue/editor/context'
|
||||
import { useSceneComputed } from '#vue/internal/scene-computed/use'
|
||||
|
||||
function variantOptions(editor: ReturnType<typeof useEditor>, instance: SceneNode, name: string) {
|
||||
const component = instance.componentId ? editor.graph.getNode(instance.componentId) : null
|
||||
const parent = component?.parentId ? editor.graph.getNode(component.parentId) : null
|
||||
const values =
|
||||
parent?.type === 'COMPONENT_SET' ? editor.collectVariantOptions(parent.id).get(name) : null
|
||||
return [...(values ?? [])].map((value) => ({ value, label: value }))
|
||||
}
|
||||
|
||||
export function useComponentProperties() {
|
||||
const editor = useEditor()
|
||||
const instances = useSceneComputed(() => {
|
||||
void editor.state.sceneVersion
|
||||
return editor.getSelectedNodes().filter((node) => node.type === 'INSTANCE')
|
||||
})
|
||||
const selectedCount = computed(() => editor.state.selectedIds.size)
|
||||
const definitionSets = useSceneComputed(() => {
|
||||
void editor.state.sceneVersion
|
||||
return instances.value.map((instance) =>
|
||||
editor.getInstanceComponentPropertyDefinitions(instance.id)
|
||||
)
|
||||
})
|
||||
const definitions = computed(() => compatibleComponentPropertyDefinitions(definitionSets.value))
|
||||
const active = computed(
|
||||
() =>
|
||||
instances.value.length > 0 &&
|
||||
instances.value.length === selectedCount.value &&
|
||||
definitions.value.length > 0
|
||||
)
|
||||
const controls = useSceneComputed<ComponentPropertyControl[]>(() => {
|
||||
void editor.state.sceneVersion
|
||||
if (!active.value || instances.value.length === 0) return []
|
||||
const firstInstance = instances.value[0]
|
||||
return definitions.value.map((definition) => {
|
||||
const values = instances.value.map((instance) =>
|
||||
editor.getInstanceComponentPropertyValue(instance.id, definition)
|
||||
)
|
||||
const value = mergedComponentPropertyValue(values)
|
||||
let options: ComponentPropertyOption[] = []
|
||||
if (definition.type === 'VARIANT') {
|
||||
options = variantOptions(editor, firstInstance, definition.name)
|
||||
} else if (definition.type === 'INSTANCE_SWAP') {
|
||||
options = instanceSwapOptions(
|
||||
[...editor.graph.getAllNodes()],
|
||||
definition,
|
||||
value === MIXED ? '' : value
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
type: definition.type,
|
||||
value,
|
||||
options
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function setValue(propertyId: string, value: string) {
|
||||
if (!active.value) return
|
||||
const targets = [...instances.value]
|
||||
const definition = definitions.value.find((item) => item.id === propertyId)
|
||||
if (!definition) return
|
||||
const label = `Change ${definition.name}`
|
||||
const run = () => {
|
||||
for (const instance of targets) {
|
||||
editor.setInstanceComponentProperty(instance.id, propertyId, value)
|
||||
}
|
||||
}
|
||||
if (targets.length > 1) editor.undo.runBatch(label, run)
|
||||
else run()
|
||||
}
|
||||
|
||||
return { active, controls, setValue }
|
||||
}
|
||||
|
|
@ -138,6 +138,7 @@
|
|||
"strokeMiterLimit": "Gehrungsgrenze",
|
||||
"add": "Hinzufügen",
|
||||
"variants": "Varianten",
|
||||
"componentProperties": "Komponenteneigenschaften",
|
||||
"gapAuto": "Automatischer Abstand",
|
||||
"horizontalGap": "Horizontaler Abstand",
|
||||
"verticalGap": "Vertikaler Abstand",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@
|
|||
"strokeMiterLimit": "Limite mitra",
|
||||
"add": "Aggiungi",
|
||||
"variants": "Varianti",
|
||||
"componentProperties": "Proprietà del componente",
|
||||
"gapAuto": "Spaziatura auto",
|
||||
"horizontalGap": "Spaziatura orizzontale",
|
||||
"verticalGap": "Spaziatura verticale",
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@
|
|||
"strokeMiterLimit": "マイター制限",
|
||||
"add": "追加",
|
||||
"variants": "バリアント",
|
||||
"componentProperties": "コンポーネントのプロパティ",
|
||||
"gapAuto": "間隔自動",
|
||||
"horizontalGap": "水平方向の間隔",
|
||||
"verticalGap": "垂直方向の間隔",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@
|
|||
"strokeMiterLimit": "Предел острого соединения",
|
||||
"add": "Добавить",
|
||||
"variants": "Варианты",
|
||||
"componentProperties": "Свойства компонента",
|
||||
"gapAuto": "Авто отступ",
|
||||
"horizontalGap": "Горизонтальный отступ",
|
||||
"verticalGap": "Вертикальный отступ",
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@
|
|||
"strokeMiterLimit": "尖角限制",
|
||||
"add": "添加",
|
||||
"variants": "变体",
|
||||
"componentProperties": "组件属性",
|
||||
"gapAuto": "自动间距",
|
||||
"horizontalGap": "水平间距",
|
||||
"verticalGap": "垂直间距",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import SelectionActionsControl from './properties/SelectionActionsControl.vue'
|
|||
import StrokeSection from './properties/StrokeSection.vue'
|
||||
import TypographySection from './properties/TypographySection.vue'
|
||||
import VariablesSection from './properties/VariablesSection.vue'
|
||||
import VariantSection from './properties/VariantSection.vue'
|
||||
import ComponentPropertiesSection from './properties/component-properties/ComponentPropertiesSection.vue'
|
||||
|
||||
const variablesOpen = ref(false)
|
||||
const { selectedNode: node, selectedCount: multiCount } = useSelectionState()
|
||||
|
|
@ -55,6 +55,7 @@ const { panels } = useI18n()
|
|||
<SelectionActionsControl :show-boolean-operations="showBooleanOperations" />
|
||||
</template>
|
||||
</PanelHeader>
|
||||
<ComponentPropertiesSection />
|
||||
<PositionSection />
|
||||
<ConstraintsSection />
|
||||
<AppearanceSection />
|
||||
|
|
@ -105,7 +106,7 @@ const { panels } = useI18n()
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<VariantSection v-if="node.type === 'INSTANCE'" />
|
||||
<ComponentPropertiesSection v-if="node.type === 'INSTANCE'" />
|
||||
|
||||
<PositionSection />
|
||||
<ConstraintsSection />
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useI18n, useSelectionState } from '@open-pencil/vue'
|
||||
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
import { useEditorStore } from '@/app/editor/active-store'
|
||||
|
||||
const editor = useEditorStore()
|
||||
const { selectedNode: node } = useSelectionState()
|
||||
const { panels } = useI18n()
|
||||
|
||||
const instanceComponent = computed(() => {
|
||||
if (!node.value || node.value.type !== 'INSTANCE' || !node.value.componentId) return null
|
||||
return editor.graph.getNode(node.value.componentId) ?? null
|
||||
})
|
||||
|
||||
const componentSetId = computed(() => {
|
||||
const comp = instanceComponent.value
|
||||
if (!comp) return null
|
||||
const parent = comp.parentId ? editor.graph.getNode(comp.parentId) : null
|
||||
return parent?.type === 'COMPONENT_SET' ? parent.id : null
|
||||
})
|
||||
|
||||
const variantOptions = computed(() => {
|
||||
const csId = componentSetId.value
|
||||
if (!csId) return new Map<string, Set<string>>()
|
||||
return editor.collectVariantOptions(csId)
|
||||
})
|
||||
|
||||
const currentValues = computed(() => {
|
||||
return instanceComponent.value?.componentPropertyValues ?? {}
|
||||
})
|
||||
|
||||
const hasVariants = computed(() => variantOptions.value.size > 0)
|
||||
|
||||
function switchVariant(propertyName: string, newValue: string) {
|
||||
if (!node.value) return
|
||||
editor.switchInstanceVariant(node.value.id, propertyName, newValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSection v-if="hasVariants" :label="panels.variants" :ui="{ title: 'text-component' }">
|
||||
<div class="flex flex-col gap-panel">
|
||||
<PanelFieldGroup
|
||||
v-for="[propName, options] in variantOptions"
|
||||
:key="propName"
|
||||
:label="propName"
|
||||
>
|
||||
<AppSelect
|
||||
:label="propName"
|
||||
:model-value="currentValues[propName] ?? ''"
|
||||
:options="[...options].map((value) => ({ value, label: value }))"
|
||||
:data-property="propName"
|
||||
@update:model-value="switchVariant(propName, $event)"
|
||||
/>
|
||||
</PanelFieldGroup>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { MIXED, useComponentProperties, useI18n } from '@open-pencil/vue'
|
||||
|
||||
import ComponentPropertyTextField from './ComponentPropertyTextField.vue'
|
||||
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import PanelFieldGroup from '@/components/ui/panel/PanelFieldGroup.vue'
|
||||
import PanelSection from '@/components/ui/panel/PanelSection.vue'
|
||||
|
||||
const { active, controls, setValue } = useComponentProperties()
|
||||
const { panels } = useI18n()
|
||||
const componentSectionUI = { title: 'text-component' }
|
||||
|
||||
function selectOptions(control: (typeof controls.value)[number]) {
|
||||
return control.value === MIXED
|
||||
? [{ value: 'MIXED', label: panels.value.mixed }, ...control.options]
|
||||
: control.options
|
||||
}
|
||||
|
||||
function selectValue(control: (typeof controls.value)[number]) {
|
||||
return control.value === MIXED ? 'MIXED' : control.value
|
||||
}
|
||||
|
||||
function updateSelect(propertyId: string, value: string) {
|
||||
if (value !== 'MIXED') setValue(propertyId, value)
|
||||
}
|
||||
|
||||
function booleanValue(control: (typeof controls.value)[number]) {
|
||||
return control.value !== MIXED && control.value === 'true'
|
||||
}
|
||||
|
||||
const sectionLabel = computed(() =>
|
||||
controls.value.every((control) => control.type === 'VARIANT')
|
||||
? panels.value.variants
|
||||
: panels.value.componentProperties
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PanelSection v-if="active" :label="sectionLabel" :ui="componentSectionUI">
|
||||
<div class="flex flex-col gap-panel">
|
||||
<PanelFieldGroup v-for="control in controls" :key="control.id" :label="control.name">
|
||||
<ComponentPropertyTextField
|
||||
v-if="control.type === 'TEXT'"
|
||||
:value="control.value"
|
||||
:label="control.name"
|
||||
:data-property="control.id"
|
||||
@commit="setValue(control.id, $event)"
|
||||
/>
|
||||
<div v-else-if="control.type === 'BOOLEAN'" class="flex h-field items-center">
|
||||
<AppSwitch
|
||||
:model-value="booleanValue(control)"
|
||||
:label="control.name"
|
||||
:state="control.value === MIXED ? 'mixed' : 'idle'"
|
||||
:data-property="control.id"
|
||||
@update:model-value="setValue(control.id, String($event))"
|
||||
/>
|
||||
</div>
|
||||
<AppSelect
|
||||
v-else
|
||||
:label="control.name"
|
||||
:model-value="selectValue(control)"
|
||||
:options="selectOptions(control)"
|
||||
:data-property="control.id"
|
||||
@update:model-value="updateSelect(control.id, $event)"
|
||||
/>
|
||||
</PanelFieldGroup>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { MIXED, type MixedValue } from '@open-pencil/vue'
|
||||
|
||||
import AppInput from '@/components/ui/AppInput.vue'
|
||||
|
||||
const { value, label } = defineProps<{ value: MixedValue<string>; label: string }>()
|
||||
const emit = defineEmits<{ commit: [value: string] }>()
|
||||
const draft = ref('')
|
||||
|
||||
watch(
|
||||
() => value,
|
||||
(next) => {
|
||||
draft.value = next === MIXED ? '' : next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppInput
|
||||
v-model="draft"
|
||||
tone="panel"
|
||||
size="sm"
|
||||
:state="value === MIXED ? 'mixed' : 'idle'"
|
||||
:placeholder="value === MIXED ? '—' : undefined"
|
||||
:aria-label="label"
|
||||
@change="emit('commit', draft)"
|
||||
/>
|
||||
</template>
|
||||
36
src/components/ui/AppSwitch.vue
Normal file
36
src/components/ui/AppSwitch.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import type { ComponentUI } from '@/components/ui/types'
|
||||
import type { SwitchTheme } from '@/theme/switch'
|
||||
|
||||
export type AppSwitchUI = ComponentUI<SwitchTheme>
|
||||
|
||||
export interface AppSwitchProps {
|
||||
label: string
|
||||
size?: keyof SwitchTheme['variants']['size']
|
||||
state?: keyof SwitchTheme['variants']['state']
|
||||
ui?: AppSwitchUI
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { tv } from 'tailwind-variants'
|
||||
|
||||
import theme from '@/theme/switch'
|
||||
|
||||
const { label, size = 'sm', state = 'idle', ui } = defineProps<AppSwitchProps>()
|
||||
const modelValue = defineModel<boolean>({ required: true })
|
||||
const styles = computed(() => tv(theme)({ size, state }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="modelValue"
|
||||
:aria-label="label"
|
||||
:data-mixed="state === 'mixed' || undefined"
|
||||
:class="styles.root({ class: ui?.root })"
|
||||
>
|
||||
<SwitchThumb :class="styles.thumb({ class: ui?.thumb })" />
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
24
src/theme/switch.ts
Normal file
24
src/theme/switch.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const switchTheme = {
|
||||
slots: {
|
||||
root: 'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-border bg-panel-field outline-none transition-colors hover:border-border-strong focus-visible:ring-1 focus-visible:ring-panel-focus data-[state=checked]:border-accent data-[state=checked]:bg-accent',
|
||||
thumb:
|
||||
'pointer-events-none block rounded-full bg-muted shadow-sm transition-transform data-[state=checked]:bg-white'
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
sm: { root: 'h-4 w-7 p-0.5', thumb: 'size-3 data-[state=checked]:translate-x-3' },
|
||||
md: { root: 'h-5 w-9 p-0.5', thumb: 'size-4 data-[state=checked]:translate-x-4' }
|
||||
},
|
||||
state: {
|
||||
idle: {},
|
||||
mixed: { root: 'border-accent/60 bg-accent/20', thumb: 'translate-x-1.5 bg-accent' }
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'sm' as const,
|
||||
state: 'idle' as const
|
||||
}
|
||||
}
|
||||
|
||||
export type SwitchTheme = typeof switchTheme
|
||||
export default switchTheme
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
159
tests/e2e/properties/component-properties.spec.ts
Normal file
159
tests/e2e/properties/component-properties.spec.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { CanvasHelper } from '#tests/helpers/canvas'
|
||||
import { propertySection } from '#tests/helpers/properties'
|
||||
|
||||
let page: Page
|
||||
let canvas: CanvasHelper
|
||||
let instanceId = ''
|
||||
|
||||
async function selectOption(label: string, option: string) {
|
||||
const section = propertySection(page, 'Component properties')
|
||||
await section.getByRole('combobox', { name: label }).click()
|
||||
await page.getByRole('option', { name: option, exact: true }).click()
|
||||
await canvas.waitForRender()
|
||||
}
|
||||
|
||||
async function instanceState(id = instanceId) {
|
||||
return page.evaluate((nodeId) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const instance = store.graph.getNode(nodeId)
|
||||
if (!instance) return null
|
||||
const children = store.graph.getChildren(nodeId)
|
||||
return {
|
||||
componentId: instance.componentId,
|
||||
assignments: instance.componentPropertyAssignments,
|
||||
label: children.find((node) => node.name === 'Label')?.text,
|
||||
badgeVisible: children.find((node) => node.name === 'Badge')?.visible,
|
||||
iconName: children.find((node) => node.type === 'INSTANCE')?.name
|
||||
}
|
||||
}, id)
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage()
|
||||
await page.goto('/')
|
||||
canvas = new CanvasHelper(page)
|
||||
await canvas.waitForInit()
|
||||
instanceId = await page.evaluate(() => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const pageId = store.state.currentPageId
|
||||
const iconA = store.graph.createNode('COMPONENT', pageId, { name: 'Icon A' })
|
||||
store.graph.createNode('RECTANGLE', iconA.id, { name: 'A shape' })
|
||||
const iconB = store.graph.createNode('COMPONENT', pageId, { name: 'Icon B' })
|
||||
store.graph.createNode('ELLIPSE', iconB.id, { name: 'B shape' })
|
||||
const componentSet = store.graph.createNode('COMPONENT_SET', pageId, {
|
||||
name: 'Card',
|
||||
componentPropertyDefinitions: [
|
||||
{ id: '30:1', name: 'State', type: 'VARIANT', defaultValue: 'Default' },
|
||||
{ id: '30:2', name: 'Label', type: 'TEXT', defaultValue: 'Default label' },
|
||||
{ id: '30:3', name: 'Show badge', type: 'BOOLEAN', defaultValue: 'true' },
|
||||
{ id: '30:4', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: iconA.id }
|
||||
]
|
||||
})
|
||||
const createVariant = (name: string, state: string, x: number) => {
|
||||
const component = store.graph.createNode('COMPONENT', componentSet.id, {
|
||||
name,
|
||||
x,
|
||||
width: 220,
|
||||
height: 100,
|
||||
componentPropertyValues: { State: state }
|
||||
})
|
||||
store.graph.createNode('TEXT', component.id, {
|
||||
name: 'Label',
|
||||
text: `${state} label`,
|
||||
componentPropertyReferences: [{ propertyId: '30:2', field: 'TEXT' }]
|
||||
})
|
||||
store.graph.createNode('FRAME', component.id, {
|
||||
name: 'Badge',
|
||||
componentPropertyReferences: [{ propertyId: '30:3', field: 'VISIBLE' }]
|
||||
})
|
||||
const icon = store.graph.createInstance(iconA.id, component.id, { name: 'Icon' })
|
||||
if (!icon) throw new Error('Expected nested icon')
|
||||
store.graph.updateNode(icon.id, {
|
||||
componentPropertyReferences: [{ propertyId: '30:4', field: 'INSTANCE_SWAP' }]
|
||||
})
|
||||
return component
|
||||
}
|
||||
const primary = createVariant('Default card', 'Default', 0)
|
||||
createVariant('Hover card', 'Hover', 260)
|
||||
const instance = store.graph.createInstance(primary.id, pageId, { x: 200, y: 300 })
|
||||
if (!instance) throw new Error('Expected instance')
|
||||
store.select([instance.id])
|
||||
return instance.id
|
||||
})
|
||||
await canvas.waitForRender()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test('renders and applies all component property control types', async () => {
|
||||
const section = propertySection(page, 'Component properties')
|
||||
await expect(section).toBeVisible()
|
||||
await expect(section.getByRole('combobox', { name: 'State' })).toBeVisible()
|
||||
await expect(section.getByRole('textbox', { name: 'Label' })).toBeVisible()
|
||||
await expect(section.getByRole('switch', { name: 'Show badge' })).toBeVisible()
|
||||
await expect(section.getByRole('combobox', { name: 'Icon' })).toBeVisible()
|
||||
|
||||
const text = section.getByRole('textbox', { name: 'Label' })
|
||||
await text.fill('Custom label')
|
||||
await text.blur()
|
||||
await canvas.waitForRender()
|
||||
expect(await instanceState()).toMatchObject({ label: 'Custom label' })
|
||||
|
||||
await section.getByRole('switch', { name: 'Show badge' }).click()
|
||||
await canvas.waitForRender()
|
||||
expect(await instanceState()).toMatchObject({ badgeVisible: false })
|
||||
|
||||
await selectOption('Icon', 'Icon B')
|
||||
expect(await instanceState()).toMatchObject({ iconName: 'Icon B' })
|
||||
|
||||
await selectOption('State', 'Hover')
|
||||
expect(await instanceState()).toMatchObject({ label: 'Custom label' })
|
||||
await expect(section).toHaveScreenshot('component-properties-controls.png')
|
||||
})
|
||||
|
||||
test('batches compatible mixed selection and undo', async () => {
|
||||
const secondId = await page.evaluate((firstId) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const first = store.graph.getNode(firstId)
|
||||
if (!first?.componentId) throw new Error('Missing first instance')
|
||||
const second = store.graph.createInstance(first.componentId, store.state.currentPageId, {
|
||||
x: 500,
|
||||
y: 300
|
||||
})
|
||||
if (!second) throw new Error('Expected second instance')
|
||||
store.select([firstId, second.id])
|
||||
return second.id
|
||||
}, instanceId)
|
||||
await canvas.waitForRender()
|
||||
const toggle = propertySection(page, 'Component properties').getByRole('switch', {
|
||||
name: 'Show badge'
|
||||
})
|
||||
await expect(toggle).toHaveAttribute('data-mixed', 'true')
|
||||
await toggle.click()
|
||||
await canvas.waitForRender()
|
||||
expect(await instanceState(instanceId)).toMatchObject({ badgeVisible: true })
|
||||
expect(await instanceState(secondId)).toMatchObject({ badgeVisible: true })
|
||||
|
||||
await canvas.pressKey('Meta+z')
|
||||
await canvas.waitForRender()
|
||||
expect(await instanceState(instanceId)).toMatchObject({ badgeVisible: false })
|
||||
expect(await instanceState(secondId)).toMatchObject({ badgeVisible: true })
|
||||
|
||||
await page.evaluate((id) => {
|
||||
const store = window.openPencil?.getStore?.()
|
||||
if (!store) throw new Error('OpenPencil store not initialized')
|
||||
const rectangle = store.graph.createNode('RECTANGLE', store.state.currentPageId)
|
||||
store.select([id, rectangle.id])
|
||||
}, instanceId)
|
||||
await canvas.waitForRender()
|
||||
await expect(propertySection(page, 'Component properties')).toHaveCount(0)
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
133
tests/engine/editor/components/properties.test.ts
Normal file
133
tests/engine/editor/components/properties.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { createEditor } from '@open-pencil/core/editor'
|
||||
|
||||
function childByName(editor: ReturnType<typeof createEditor>, parentId: string, name: string) {
|
||||
return editor.graph.getChildren(parentId).find((node) => node.name === name)
|
||||
}
|
||||
|
||||
function setupComponentProperties() {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.state.currentPageId
|
||||
const iconA = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon A' })
|
||||
editor.graph.createNode('RECTANGLE', iconA.id, { name: 'A shape' })
|
||||
const iconB = editor.graph.createNode('COMPONENT', pageId, { name: 'Icon B' })
|
||||
editor.graph.createNode('ELLIPSE', iconB.id, { name: 'B shape' })
|
||||
const component = editor.graph.createNode('COMPONENT', pageId, {
|
||||
name: 'Card',
|
||||
componentPropertyDefinitions: [
|
||||
{ id: '10:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
|
||||
{ id: '10:2', name: 'Show icon', type: 'BOOLEAN', defaultValue: 'true' },
|
||||
{ id: '10:3', name: 'Icon', type: 'INSTANCE_SWAP', defaultValue: iconA.id }
|
||||
]
|
||||
})
|
||||
editor.graph.createNode('TEXT', component.id, {
|
||||
name: 'Label',
|
||||
text: 'Default',
|
||||
componentPropertyReferences: [{ propertyId: '10:1', field: 'TEXT' }]
|
||||
})
|
||||
editor.graph.createNode('FRAME', component.id, {
|
||||
name: 'Badge',
|
||||
componentPropertyReferences: [{ propertyId: '10:2', field: 'VISIBLE' }]
|
||||
})
|
||||
const sourceIcon = editor.graph.createInstance(iconA.id, component.id, { name: 'Icon' })
|
||||
if (!sourceIcon) throw new Error('Expected nested source instance')
|
||||
editor.graph.updateNode(sourceIcon.id, {
|
||||
componentPropertyReferences: [{ propertyId: '10:3', field: 'INSTANCE_SWAP' }]
|
||||
})
|
||||
const instance = editor.graph.createInstance(component.id, pageId)
|
||||
if (!instance) throw new Error('Expected card instance')
|
||||
return { editor, component, instance, iconA, iconB }
|
||||
}
|
||||
|
||||
describe('component property actions', () => {
|
||||
test('applies and undoes text, boolean, and instance-swap properties', () => {
|
||||
const { editor, instance, iconA, iconB } = setupComponentProperties()
|
||||
|
||||
editor.setInstanceComponentProperty(instance.id, '10:1', 'Changed')
|
||||
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Changed')
|
||||
expect(editor.graph.getNode(instance.id)?.componentPropertyAssignments['10:1']).toBe('Changed')
|
||||
editor.undo.undo()
|
||||
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Default')
|
||||
|
||||
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
|
||||
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
|
||||
editor.undo.undo()
|
||||
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(true)
|
||||
|
||||
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
|
||||
const swapped = childByName(editor, instance.id, 'Icon B')
|
||||
expect(swapped?.componentId).toBe(iconB.id)
|
||||
expect(swapped?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['B shape'])
|
||||
editor.setInstanceComponentProperty(instance.id, '10:3', iconA.id)
|
||||
editor.undo.undo()
|
||||
expect(childByName(editor, instance.id, 'Icon B')?.componentId).toBe(iconB.id)
|
||||
editor.undo.undo()
|
||||
const restored = childByName(editor, instance.id, 'Icon A')
|
||||
expect(restored?.componentId).toBe(iconA.id)
|
||||
expect(restored?.childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual(['A shape'])
|
||||
})
|
||||
|
||||
test('preserves assignments when the main component synchronizes', () => {
|
||||
const { editor, component, instance, iconB } = setupComponentProperties()
|
||||
editor.setInstanceComponentProperty(instance.id, '10:1', 'Custom')
|
||||
editor.setInstanceComponentProperty(instance.id, '10:2', 'false')
|
||||
editor.setInstanceComponentProperty(instance.id, '10:3', iconB.id)
|
||||
|
||||
const sourceLabel = childByName(editor, component.id, 'Label')
|
||||
if (!sourceLabel) throw new Error('Expected source label')
|
||||
editor.graph.updateNode(sourceLabel.id, { text: 'Updated default' })
|
||||
editor.graph.syncInstances(component.id)
|
||||
|
||||
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
|
||||
expect(childByName(editor, instance.id, 'Badge')?.visible).toBe(false)
|
||||
const nestedInstances = editor.graph
|
||||
.getChildren(instance.id)
|
||||
.filter((node) => node.type === 'INSTANCE')
|
||||
expect(nestedInstances).toHaveLength(1)
|
||||
expect(nestedInstances[0].name).toBe('Icon B')
|
||||
expect(nestedInstances[0].childIds.map((id) => editor.graph.getNode(id)?.name)).toEqual([
|
||||
'B shape'
|
||||
])
|
||||
})
|
||||
|
||||
test('reapplies non-variant assignments after variant swaps and undo', () => {
|
||||
const editor = createEditor()
|
||||
const pageId = editor.state.currentPageId
|
||||
const componentSet = editor.graph.createNode('COMPONENT_SET', pageId, {
|
||||
componentPropertyDefinitions: [
|
||||
{ id: '20:1', name: 'State', type: 'VARIANT', defaultValue: 'A' },
|
||||
{ id: '20:2', name: 'Label', type: 'TEXT', defaultValue: 'Default' }
|
||||
]
|
||||
})
|
||||
const variantA = editor.graph.createNode('COMPONENT', componentSet.id, {
|
||||
name: 'State=A',
|
||||
componentPropertyValues: { State: 'A' }
|
||||
})
|
||||
editor.graph.createNode('TEXT', variantA.id, {
|
||||
name: 'Label',
|
||||
text: 'A default',
|
||||
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
|
||||
})
|
||||
const variantB = editor.graph.createNode('COMPONENT', componentSet.id, {
|
||||
name: 'State=B',
|
||||
componentPropertyValues: { State: 'B' }
|
||||
})
|
||||
editor.graph.createNode('TEXT', variantB.id, {
|
||||
name: 'Label',
|
||||
text: 'B default',
|
||||
componentPropertyReferences: [{ propertyId: '20:2', field: 'TEXT' }]
|
||||
})
|
||||
const instance = editor.graph.createInstance(variantA.id, pageId)
|
||||
if (!instance) throw new Error('Expected variant instance')
|
||||
|
||||
editor.setInstanceComponentProperty(instance.id, '20:2', 'Custom')
|
||||
editor.setInstanceComponentProperty(instance.id, '20:1', 'B')
|
||||
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantB.id)
|
||||
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
|
||||
|
||||
editor.undo.undo()
|
||||
expect(editor.graph.getNode(instance.id)?.componentId).toBe(variantA.id)
|
||||
expect(childByName(editor, instance.id, 'Label')?.text).toBe('Custom')
|
||||
})
|
||||
})
|
||||
69
tests/engine/io/fig/export/component-properties.test.ts
Normal file
69
tests/engine/io/fig/export/component-properties.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { beforeAll, describe, expect, test } from 'bun:test'
|
||||
|
||||
import { exportFigFile, initCodec } from '@open-pencil/core'
|
||||
import { parseFigBuffer } from '@open-pencil/kiwi/fig/parse'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { importNodeChanges } from '#core/kiwi/fig/import'
|
||||
|
||||
describe('Figma component property roundtrip', () => {
|
||||
beforeAll(async () => {
|
||||
await initCodec()
|
||||
})
|
||||
|
||||
test('retains typed definitions, refs, and assignments', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const page = graph.getPages()[0]
|
||||
const component = graph.createNode('COMPONENT', page.id, {
|
||||
name: 'Card',
|
||||
componentPropertyDefinitions: [
|
||||
{ id: '30:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
|
||||
{ id: '30:2', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' }
|
||||
]
|
||||
})
|
||||
component.source.id = '10:1'
|
||||
const label = graph.createNode('TEXT', component.id, {
|
||||
name: 'Label',
|
||||
text: 'Default',
|
||||
componentPropertyReferences: [
|
||||
{ propertyId: '30:1', field: 'TEXT' },
|
||||
{ propertyId: '30:2', field: 'VISIBLE' }
|
||||
]
|
||||
})
|
||||
label.source.id = '10:2'
|
||||
const instance = graph.createInstance(component.id, page.id, {
|
||||
name: 'Card instance',
|
||||
componentPropertyAssignments: { '30:1': 'Custom', '30:2': 'false' }
|
||||
})
|
||||
if (!instance) throw new Error('Expected instance')
|
||||
instance.source.id = '20:1'
|
||||
|
||||
const bytes = await exportFigFile(graph)
|
||||
const parsed = parseFigBuffer(
|
||||
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
||||
)
|
||||
const imported = importNodeChanges(parsed.nodeChanges, parsed.blobs, undefined, {
|
||||
populate: 'all'
|
||||
})
|
||||
const importedComponent = [...imported.getAllNodes()].find((node) => node.name === 'Card')
|
||||
const importedInstance = [...imported.getAllNodes()].find(
|
||||
(node) => node.name === 'Card instance'
|
||||
)
|
||||
const importedLabel = [...imported.getAllNodes()].find(
|
||||
(node) => node.name === 'Label' && node.parentId === importedComponent?.id
|
||||
)
|
||||
|
||||
expect(importedComponent?.componentPropertyDefinitions).toEqual([
|
||||
{ id: '30:1', name: 'Label', type: 'TEXT', defaultValue: 'Default' },
|
||||
{ id: '30:2', name: 'Visible', type: 'BOOLEAN', defaultValue: 'true' }
|
||||
])
|
||||
expect(importedLabel?.componentPropertyReferences).toEqual([
|
||||
{ propertyId: '30:1', field: 'TEXT' },
|
||||
{ propertyId: '30:2', field: 'VISIBLE' }
|
||||
])
|
||||
expect(importedInstance?.componentPropertyAssignments).toEqual({
|
||||
'30:1': 'Custom',
|
||||
'30:2': 'false'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 } } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
57
tests/engine/vue/controls/component-properties.test.ts
Normal file
57
tests/engine/vue/controls/component-properties.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
MIXED,
|
||||
compatibleComponentPropertyDefinitions,
|
||||
instanceSwapOptions,
|
||||
mergedComponentPropertyValue
|
||||
} from '@open-pencil/vue'
|
||||
|
||||
describe('component property control model', () => {
|
||||
test('requires identical ordered property IDs and types', () => {
|
||||
const definitions = [
|
||||
{ id: '1:1', name: 'Label', type: 'TEXT' as const, defaultValue: 'Default' },
|
||||
{ id: '1:2', name: 'Visible', type: 'BOOLEAN' as const, defaultValue: 'true' }
|
||||
]
|
||||
expect(
|
||||
compatibleComponentPropertyDefinitions([definitions, structuredClone(definitions)])
|
||||
).toBe(definitions)
|
||||
expect(
|
||||
compatibleComponentPropertyDefinitions([
|
||||
definitions,
|
||||
[{ ...definitions[0], type: 'BOOLEAN' as const }]
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('models mixed values and preferred instance swap options', () => {
|
||||
expect(mergedComponentPropertyValue(['A', 'A'])).toBe('A')
|
||||
expect(mergedComponentPropertyValue(['A', 'B'])).toBe(MIXED)
|
||||
|
||||
const graph = new SceneGraph()
|
||||
const pageId = graph.getPages()[0].id
|
||||
const secondary = graph.createNode('COMPONENT', pageId, { name: 'Secondary' })
|
||||
const preferred = graph.createNode('COMPONENT', pageId, {
|
||||
name: 'Preferred',
|
||||
componentKey: 'preferred-key'
|
||||
})
|
||||
expect(
|
||||
instanceSwapOptions(
|
||||
[secondary, preferred],
|
||||
{
|
||||
id: '1:3',
|
||||
name: 'Icon',
|
||||
type: 'INSTANCE_SWAP',
|
||||
defaultValue: secondary.id,
|
||||
preferredValues: ['preferred-key']
|
||||
},
|
||||
'missing-id'
|
||||
)
|
||||
).toEqual([
|
||||
{ value: preferred.id, label: 'Preferred' },
|
||||
{ value: secondary.id, label: 'Secondary' },
|
||||
{ value: 'missing-id', label: 'missing-id', missing: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue