feat(design-jsx): support components and instances
This commit is contained in:
parent
31a1e17105
commit
05e4a29a08
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ export {
|
|||
Vector,
|
||||
Group,
|
||||
Section,
|
||||
Component,
|
||||
ComponentSet,
|
||||
Instance,
|
||||
View,
|
||||
Rect,
|
||||
Component,
|
||||
Instance,
|
||||
Page,
|
||||
INTRINSIC_ELEMENTS
|
||||
} from './components'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export type BaseProps = StyleProps & {
|
|||
name?: string
|
||||
key?: string | number
|
||||
children?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type TextProps = BaseProps
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue