feat(design-jsx): support components and instances

This commit is contained in:
Danila Poyarkov 2026-05-31 18:33:56 +03:00
parent 31a1e17105
commit 05e4a29a08
9 changed files with 226 additions and 9 deletions

View file

@ -2,6 +2,10 @@
## Unreleased
### Changed
- Add JSX authoring support for components, component sets, and instances.
### Fixes
- Harden MCP calls with bounded page-tree responses, oversized-result errors, JSON HTTP responses, and stale WebSocket cleanup.

View file

@ -55,10 +55,23 @@ export function Section(props: BaseProps, ...children: Child[]): TreeNode {
return withChildren('section', props, children)
}
export function Component(props: BaseProps, ...children: Child[]): TreeNode {
return withChildren('component', props, children)
}
export function ComponentSet(props: BaseProps, ...children: Child[]): TreeNode {
return withChildren('component-set', props, children)
}
export function Instance(
props: BaseProps & { component?: string; componentId?: string; of?: string },
...children: Child[]
): TreeNode {
return withChildren('instance', props, children)
}
export const View = Frame
export const Rect = Rectangle
export const Component = Frame
export const Instance = Frame
export const Page = Frame
export const INTRINSIC_ELEMENTS = [
@ -71,5 +84,8 @@ export const INTRINSIC_ELEMENTS = [
'polygon',
'vector',
'group',
'section'
'section',
'component',
'component-set',
'instance'
] as const

View file

@ -9,10 +9,11 @@ export {
Vector,
Group,
Section,
Component,
ComponentSet,
Instance,
View,
Rect,
Component,
Instance,
Page,
INTRINSIC_ELEMENTS
} from './components'

View file

@ -28,6 +28,9 @@ export namespace JSX {
vector: BaseProps
group: BaseProps
section: BaseProps
component: BaseProps
'component-set': BaseProps
instance: BaseProps & { component?: string; componentId?: string; of?: string }
}
export interface ElementChildrenAttribute {

View file

@ -111,7 +111,10 @@ const SUPPORTED_PROPS = new Set([
'pointCount',
'innerRadius',
'label',
'style'
'style',
'component',
'componentId',
'of'
])
function stripHtmlComments(jsxString: string): string {
@ -144,7 +147,7 @@ export function buildComponent(jsxString: string): React.ComponentType {
const Frame = 'frame', Text = 'text', Rectangle = 'rectangle', Ellipse = 'ellipse'
const Line = 'line', Star = 'star', Polygon = 'polygon', Vector = 'vector'
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
const Component = 'component', Instance = 'frame'
const Component = 'component', ComponentSet = 'component-set', Instance = 'instance'
const Icon = 'icon'
`
const opts = {

View file

@ -3,7 +3,13 @@ import type { RenderOptions } from '#core/design-jsx/types'
import { fetchIcons } from '#core/icons'
import { createIconFromPaths } from '#core/icons/render'
import { computeAllLayouts } from '#core/layout'
import type { SceneGraph, SceneNode, NodeType } from '#core/scene-graph'
import { randomHex } from '#core/random'
import type {
ComponentPropertyDefinition,
SceneGraph,
SceneNode,
NodeType
} from '#core/scene-graph'
import { applySizeOverrides, propsToOverrides } from './props-overrides'
import { isTreeNode } from './tree'
@ -23,6 +29,8 @@ const TYPE_MAP: Partial<Record<string, NodeType>> = {
group: 'GROUP',
section: 'SECTION',
component: 'COMPONENT',
'component-set': 'COMPONENT_SET',
componentset: 'COMPONENT_SET',
div: 'FRAME',
main: 'FRAME',
header: 'FRAME',
@ -100,8 +108,133 @@ async function renderIconNode(
return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides)
}
function parseVariantValues(name: string): Record<string, string> {
const entries = name
.split(',')
.map((part) => part.trim())
.filter(Boolean)
const values: Record<string, string> = {}
for (const entry of entries) {
const [key = '', ...rest] = entry.split('=')
const property = key.trim()
const value = rest.join('=').trim()
if (property && value) values[property] = value
}
return values
}
function inferComponentSetProperties(graph: SceneGraph, componentSetId: string): void {
const componentSet = graph.getNode(componentSetId)
if (componentSet?.type !== 'COMPONENT_SET') return
if (componentSet.componentPropertyDefinitions.length > 0) return
const variants = graph.getChildren(componentSetId).filter((node) => node.type === 'COMPONENT')
const options = new Map<string, Set<string>>()
const valuesById = new Map<string, Record<string, string>>()
for (const variant of variants) {
const values = parseVariantValues(variant.name)
valuesById.set(variant.id, values)
for (const [property, value] of Object.entries(values)) {
let set = options.get(property)
if (!set) {
set = new Set()
options.set(property, set)
}
set.add(value)
}
}
const definitions: ComponentPropertyDefinition[] = [...options.entries()].map(
([name, values]) => {
const variantOptions = [...values]
return {
id: `prop:${randomHex(8)}`,
name,
type: 'VARIANT',
defaultValue: variantOptions[0] ?? '',
variantOptions
}
}
)
if (definitions.length === 0) return
for (const [id, values] of valuesById) {
graph.updateNode(id, { componentPropertyValues: values })
}
graph.updateNode(componentSetId, { componentPropertyDefinitions: definitions })
}
function findComponentByName(graph: SceneGraph, name: string): SceneNode | undefined {
for (const node of graph.getAllNodes()) {
if (node.type === 'COMPONENT' && node.name === name) return node
}
return undefined
}
function findVariantInSet(
graph: SceneGraph,
componentSet: SceneNode,
props: Record<string, unknown>
) {
const requested = Object.fromEntries(
Object.entries(props)
.filter(([key]) => !['component', 'componentId', 'of', 'name', 'children'].includes(key))
.map(([key, value]) => [key, String(value)])
)
const variants = graph.getChildren(componentSet.id).filter((node) => node.type === 'COMPONENT')
return (
variants.find((variant) =>
Object.entries(requested).every(
([key, value]) => variant.componentPropertyValues[key] === value
)
) ?? variants[0]
)
}
function resolveComponent(
graph: SceneGraph,
props: Record<string, unknown>
): SceneNode | undefined {
const ref = props.component ?? props.componentId ?? props.of
if (typeof ref !== 'string') return undefined
const byId = graph.getNode(ref)
if (byId?.type === 'COMPONENT') return byId
if (byId?.type === 'COMPONENT_SET') return findVariantInSet(graph, byId, props)
const byName = findComponentByName(graph, ref)
if (byName) return byName
for (const node of graph.getAllNodes()) {
if (node.type === 'COMPONENT_SET' && node.name === ref)
return findVariantInSet(graph, node, props)
}
return undefined
}
async function renderInstanceNode(
graph: SceneGraph,
tree: TreeNode,
parentId: string
): Promise<SceneNode> {
const parent = graph.getNode(parentId)
const parentLayout = parent?.layoutMode ?? 'NONE'
const component = resolveComponent(graph, tree.props)
if (!component) {
const ref = tree.props.component ?? tree.props.componentId ?? tree.props.of
const label = typeof ref === 'string' || typeof ref === 'number' ? String(ref) : ''
throw new Error(`<Instance> component not found: ${label}`)
}
const overrides = propsToOverrides(tree.props, false, parentLayout)
return (
graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId)
)
}
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
if (tree.type === 'icon') return renderIconNode(graph, tree, parentId)
if (tree.type === 'instance') return renderInstanceNode(graph, tree, parentId)
const nodeType = TYPE_MAP[tree.type]
if (!nodeType) throw new Error(`Unknown element: <${tree.type}>`)
@ -134,5 +267,7 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
}
}
if (node.type === 'COMPONENT_SET') inferComponentSetProperties(graph, node.id)
return node
}

View file

@ -154,6 +154,7 @@ export type BaseProps = StyleProps & {
name?: string
key?: string | number
children?: unknown
[key: string]: unknown
}
export type TextProps = BaseProps

View file

@ -322,7 +322,11 @@ export {
Section,
View,
Rect as RectNode,
Component,
Component as ComponentNode,
ComponentSet,
ComponentSet as ComponentSetNode,
Instance,
Instance as InstanceNode,
Page as PageNode,
INTRINSIC_ELEMENTS,

View file

@ -11,7 +11,10 @@ import {
Line,
Star,
Group,
Section
Section,
Component,
ComponentSet,
Instance
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
@ -76,6 +79,53 @@ describe('renderTree', () => {
expect(heading.fills.length).toBe(1)
})
it('renders components and instances', async () => {
const g = makeSceneGraph()
const component = await renderTree(
g,
Component({
name: 'Badge',
w: 64,
h: 24,
children: [Text({ name: 'Label', color: '#000', children: 'Live' })]
})
)
const instance = await renderTree(
g,
Instance({ component: component.id, name: 'Badge Instance' })
)
const node = getNodeOrThrow(g, instance.id)
expect(component.type).toBe('COMPONENT')
expect(node.type).toBe('INSTANCE')
expect(node.componentId).toBe(component.id)
expect(node.childIds.length).toBe(1)
})
it('renders component sets and variant instances', async () => {
const g = makeSceneGraph()
const set = await renderTree(
g,
ComponentSet({
name: 'Button',
children: [
Component({ name: 'variant=Primary', w: 120, h: 40, bg: '#2563EB' }),
Component({ name: 'variant=Secondary', w: 120, h: 40, bg: '#FFFFFF' })
]
})
)
const setNode = getNodeOrThrow(g, set.id)
expect(setNode.type).toBe('COMPONENT_SET')
expect(setNode.componentPropertyDefinitions[0]?.name).toBe('variant')
const instance = await renderTree(g, Instance({ of: set.id, variant: 'Secondary' }))
const node = getNodeOrThrow(g, instance.id)
expect(node.type).toBe('INSTANCE')
expect(getNodeOrThrow(g, node.componentId ?? '').name).toBe('variant=Secondary')
})
it('renders nested structure', async () => {
const g = makeSceneGraph()
const tree = Frame({