feat(design-jsx): support variable refs
This commit is contained in:
parent
7303a40964
commit
4b65e64c18
|
|
@ -5,6 +5,7 @@
|
|||
### Changed
|
||||
|
||||
- Add JSX authoring support for components, component sets, and instances.
|
||||
- Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings.
|
||||
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
|
||||
- Add `unbind_variable` MCP tool for removing variable bindings.
|
||||
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export {
|
|||
type BaseProps,
|
||||
type TextProps,
|
||||
type StyleProps,
|
||||
type PaintProp,
|
||||
isTreeNode,
|
||||
node,
|
||||
resolveToTree
|
||||
|
|
@ -30,6 +31,8 @@ export {
|
|||
|
||||
export { renderTree, type RenderResult } from './renderer'
|
||||
|
||||
export { defineVars, designVar, isVariable, type DesignVariable, type VarDef } from './vars'
|
||||
|
||||
export { createElement } from './mini-react'
|
||||
|
||||
export { renderJSX, renderTreeNode, buildComponent } from './render'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { colorToFill, parseColor } from '#core/color'
|
||||
import { TRANSPARENT } from '#core/constants'
|
||||
import type { GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
|
||||
import type { JsonObject } from '#core/types'
|
||||
import type { Color, JsonObject } from '#core/types'
|
||||
|
||||
const WEIGHT_MAP: Record<string, number> = {
|
||||
normal: 400,
|
||||
|
|
@ -60,8 +60,8 @@ function parseDirection(value: unknown): SceneNode['textDirection'] | undefined
|
|||
return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO'
|
||||
}
|
||||
|
||||
function parseStroke(value: string, width: number): Stroke {
|
||||
const color = parseColor(value)
|
||||
function parseStroke(value: string | Color, width: number): Stroke {
|
||||
const color = typeof value === 'string' ? parseColor(value) : value
|
||||
return {
|
||||
color,
|
||||
opacity: color.a,
|
||||
|
|
@ -159,14 +159,25 @@ function applyFillSizing(
|
|||
}
|
||||
}
|
||||
|
||||
function isColor(value: unknown): value is Color {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'r' in value &&
|
||||
'g' in value &&
|
||||
'b' in value &&
|
||||
'a' in value
|
||||
)
|
||||
}
|
||||
|
||||
function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
const bg = props.bg ?? props.fill ?? props.background ?? props.backgroundColor
|
||||
if (typeof bg === 'string') o.fills = [colorToFill(bg)]
|
||||
if (typeof bg === 'string' || isColor(bg)) o.fills = [colorToFill(bg)]
|
||||
}
|
||||
|
||||
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
const stroke = props.stroke ?? props.border ?? props.borderColor
|
||||
if (typeof stroke !== 'string') return
|
||||
if (typeof stroke !== 'string' && !isColor(stroke)) return
|
||||
const strokeWidth =
|
||||
(props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined) ?? 1
|
||||
o.strokes = [parseStroke(stroke, strokeWidth)]
|
||||
|
|
@ -418,7 +429,7 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
|
|||
o.fontWeight = WEIGHT_MAP[weight] ?? 400
|
||||
}
|
||||
|
||||
if (typeof props.color === 'string') {
|
||||
if (typeof props.color === 'string' || isColor(props.color)) {
|
||||
o.fills = [colorToFill(props.color)]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ const SUPPORTED_PROPS = new Set([
|
|||
'innerRadius',
|
||||
'label',
|
||||
'style',
|
||||
'bind',
|
||||
'component',
|
||||
'componentId',
|
||||
'of'
|
||||
|
|
@ -149,6 +150,13 @@ export function buildComponent(jsxString: string): React.ComponentType {
|
|||
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
|
||||
const Component = 'component', ComponentSet = 'component-set', Instance = 'instance'
|
||||
const Icon = 'icon'
|
||||
const __varSymbol = Symbol.for('open-pencil.variable')
|
||||
const designVar = (def, value) => typeof def === 'string'
|
||||
? ({ [__varSymbol]: true, id: def, name: def, value })
|
||||
: ({ [__varSymbol]: true, id: def.id, name: def.name ?? def.id ?? '', value: def.value })
|
||||
const defineVars = (vars) => Object.fromEntries(
|
||||
Object.entries(vars).map(([key, def]) => [key, designVar(def)])
|
||||
)
|
||||
`
|
||||
const opts = {
|
||||
transforms: ['typescript', 'jsx'] as Array<'typescript' | 'jsx'>,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ import type {
|
|||
SceneNode,
|
||||
NodeType
|
||||
} from '#core/scene-graph'
|
||||
import type { Color } from '#core/types'
|
||||
|
||||
import { applySizeOverrides, propsToOverrides } from './props-overrides'
|
||||
import { isTreeNode } from './tree'
|
||||
import type { TreeNode } from './tree'
|
||||
import { isVariable, type DesignVariable } from './vars'
|
||||
|
||||
const TYPE_MAP: Partial<Record<string, NodeType>> = {
|
||||
frame: 'FRAME',
|
||||
|
|
@ -78,6 +80,106 @@ export async function renderTree(
|
|||
}
|
||||
}
|
||||
|
||||
interface PreparedProps {
|
||||
props: Record<string, unknown>
|
||||
bindings: Record<string, string>
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function resolveVariableId(graph: SceneGraph, variable: DesignVariable): string | undefined {
|
||||
if (variable.id && graph.variables.has(variable.id)) return variable.id
|
||||
if (variable.id && !variable.name) return variable.id
|
||||
for (const candidate of graph.variables.values()) {
|
||||
if (candidate.name === variable.name || candidate.id === variable.name) return candidate.id
|
||||
}
|
||||
return variable.id
|
||||
}
|
||||
|
||||
function variableFallback(graph: SceneGraph, variable: DesignVariable): string | Color | undefined {
|
||||
if (variable.value !== undefined) return variable.value
|
||||
const variableId = resolveVariableId(graph, variable)
|
||||
return variableId ? graph.resolveColorVariable(variableId) : undefined
|
||||
}
|
||||
|
||||
function bindVariableProp(
|
||||
graph: SceneGraph,
|
||||
props: Record<string, unknown>,
|
||||
bindings: Record<string, string>,
|
||||
key: string,
|
||||
field: string
|
||||
): void {
|
||||
const value = props[key]
|
||||
if (!isVariable(value)) return
|
||||
const variableId = resolveVariableId(graph, value)
|
||||
if (variableId) bindings[field] = variableId
|
||||
const fallback = variableFallback(graph, value)
|
||||
if (fallback !== undefined) props[key] = fallback
|
||||
}
|
||||
|
||||
function bindStyleVariableProp(
|
||||
graph: SceneGraph,
|
||||
style: Record<string, unknown>,
|
||||
bindings: Record<string, string>,
|
||||
key: string,
|
||||
field: string
|
||||
): void {
|
||||
const value = style[key]
|
||||
if (!isVariable(value)) return
|
||||
const variableId = resolveVariableId(graph, value)
|
||||
if (variableId) bindings[field] = variableId
|
||||
const fallback = variableFallback(graph, value)
|
||||
if (fallback !== undefined) style[key] = fallback
|
||||
}
|
||||
|
||||
function preparePropsForRender(
|
||||
graph: SceneGraph,
|
||||
source: Record<string, unknown>,
|
||||
isText: boolean
|
||||
): PreparedProps {
|
||||
const props = { ...source }
|
||||
const bindings: Record<string, string> = {}
|
||||
|
||||
for (const key of ['bg', 'fill', 'background', 'backgroundColor']) {
|
||||
bindVariableProp(graph, props, bindings, key, 'fills/0/color')
|
||||
}
|
||||
if (isText) bindVariableProp(graph, props, bindings, 'color', 'fills/0/color')
|
||||
for (const key of ['stroke', 'border', 'borderColor']) {
|
||||
bindVariableProp(graph, props, bindings, key, 'strokes/0/color')
|
||||
}
|
||||
|
||||
if (isObjectRecord(props.style)) {
|
||||
const style = { ...props.style }
|
||||
for (const key of ['background', 'backgroundColor']) {
|
||||
bindStyleVariableProp(graph, style, bindings, key, 'fills/0/color')
|
||||
}
|
||||
if (isText) bindStyleVariableProp(graph, style, bindings, 'color', 'fills/0/color')
|
||||
bindStyleVariableProp(graph, style, bindings, 'borderColor', 'strokes/0/color')
|
||||
props.style = style
|
||||
}
|
||||
|
||||
if (isObjectRecord(props.bind)) {
|
||||
for (const [field, value] of Object.entries(props.bind)) {
|
||||
if (isVariable(value)) {
|
||||
const variableId = resolveVariableId(graph, value)
|
||||
if (variableId) bindings[field] = variableId
|
||||
} else if (typeof value === 'string') {
|
||||
bindings[field] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { props, bindings }
|
||||
}
|
||||
|
||||
function applyBindings(graph: SceneGraph, nodeId: string, bindings: Record<string, string>): void {
|
||||
for (const [field, variableId] of Object.entries(bindings)) {
|
||||
graph.bindVariable(nodeId, field, variableId)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderIconNode(
|
||||
graph: SceneGraph,
|
||||
tree: TreeNode,
|
||||
|
|
@ -220,16 +322,18 @@ async function renderInstanceNode(
|
|||
): Promise<SceneNode> {
|
||||
const parent = graph.getNode(parentId)
|
||||
const parentLayout = parent?.layoutMode ?? 'NONE'
|
||||
const component = resolveComponent(graph, tree.props)
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, false)
|
||||
const component = resolveComponent(graph, props)
|
||||
if (!component) {
|
||||
const ref = tree.props.component ?? tree.props.componentId ?? tree.props.of
|
||||
const ref = props.component ?? props.componentId ?? 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 (
|
||||
const overrides = propsToOverrides(props, false, parentLayout)
|
||||
const instance =
|
||||
graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId)
|
||||
)
|
||||
applyBindings(graph, instance.id, bindings)
|
||||
return instance
|
||||
}
|
||||
|
||||
async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise<SceneNode> {
|
||||
|
|
@ -243,22 +347,19 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
|
|||
const parentLayout = parent?.layoutMode ?? 'NONE'
|
||||
|
||||
const isText = nodeType === 'TEXT'
|
||||
const overrides = propsToOverrides(tree.props, isText, parentLayout)
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, isText)
|
||||
const overrides = propsToOverrides(props, isText, parentLayout)
|
||||
|
||||
if (isText) {
|
||||
const childText = tree.children.filter((c): c is string => typeof c === 'string').join('')
|
||||
const propText =
|
||||
tree.props.text ??
|
||||
tree.props.characters ??
|
||||
tree.props.content ??
|
||||
tree.props.label ??
|
||||
tree.props.value ??
|
||||
tree.props.title
|
||||
props.text ?? props.characters ?? props.content ?? props.label ?? props.value ?? props.title
|
||||
if (childText) overrides.text = childText
|
||||
else if (typeof propText === 'string') overrides.text = propText
|
||||
}
|
||||
|
||||
const node = graph.createNode(nodeType, parentId, overrides)
|
||||
applyBindings(graph, node.id, bindings)
|
||||
|
||||
for (const child of tree.children) {
|
||||
if (typeof child === 'string') continue
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import type { Color } from '#core/types'
|
||||
|
||||
import type { DesignVariable } from './vars'
|
||||
|
||||
export interface TreeNode {
|
||||
type: string
|
||||
props: Record<string, unknown>
|
||||
|
|
@ -81,6 +85,8 @@ export function node(
|
|||
return { type, props: rest, children: processed }
|
||||
}
|
||||
|
||||
export type PaintProp = string | Color | DesignVariable
|
||||
|
||||
export type StyleProps = {
|
||||
flex?: 'row' | 'col' | 'column'
|
||||
flow?: 'auto' | 'ltr' | 'rtl'
|
||||
|
|
@ -113,9 +119,9 @@ export type StyleProps = {
|
|||
pb?: number
|
||||
pl?: number
|
||||
|
||||
bg?: string
|
||||
fill?: string
|
||||
stroke?: string
|
||||
bg?: PaintProp
|
||||
fill?: PaintProp
|
||||
stroke?: PaintProp
|
||||
strokeWidth?: number
|
||||
strokeAlign?: 'inside' | 'outside' | 'center'
|
||||
strokeDash?: number[] | boolean
|
||||
|
|
@ -139,7 +145,7 @@ export type StyleProps = {
|
|||
fontFamily?: string
|
||||
weight?: number | 'bold' | 'medium' | 'normal'
|
||||
fontWeight?: number | 'bold' | 'medium' | 'normal'
|
||||
color?: string
|
||||
color?: PaintProp
|
||||
text?: string
|
||||
characters?: string
|
||||
textAlign?: 'left' | 'center' | 'right' | 'justified'
|
||||
|
|
@ -154,6 +160,7 @@ export type BaseProps = StyleProps & {
|
|||
name?: string
|
||||
key?: string | number
|
||||
children?: unknown
|
||||
bind?: Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
|
|
|
|||
59
packages/core/src/design-jsx/vars.ts
Normal file
59
packages/core/src/design-jsx/vars.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { Color } from '#core/types'
|
||||
|
||||
const VAR_SYMBOL = Symbol.for('open-pencil.variable')
|
||||
|
||||
export type VarDef =
|
||||
| string
|
||||
| {
|
||||
id?: string
|
||||
name?: string
|
||||
value?: string | Color
|
||||
}
|
||||
|
||||
export interface DesignVariable {
|
||||
[VAR_SYMBOL]: true
|
||||
id?: string
|
||||
name: string
|
||||
value?: string | Color
|
||||
}
|
||||
|
||||
export function isVariable(value: unknown): value is DesignVariable {
|
||||
return typeof value === 'object' && value !== null && VAR_SYMBOL in value
|
||||
}
|
||||
|
||||
export function defineVars<T extends Record<string, VarDef>>(
|
||||
vars: T
|
||||
): { [K in keyof T]: DesignVariable } {
|
||||
const result = {} as { [K in keyof T]: DesignVariable }
|
||||
|
||||
for (const [key, def] of Object.entries(vars)) {
|
||||
result[key as keyof T] = designVar(def)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function designVar(
|
||||
def: string | { id?: string; name?: string; value?: string | Color }
|
||||
): DesignVariable
|
||||
export function designVar(idOrName: string, value?: string | Color): DesignVariable
|
||||
export function designVar(
|
||||
def: string | { id?: string; name?: string; value?: string | Color },
|
||||
value?: string | Color
|
||||
): DesignVariable {
|
||||
if (typeof def === 'string') {
|
||||
return {
|
||||
[VAR_SYMBOL]: true,
|
||||
id: def,
|
||||
name: def,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[VAR_SYMBOL]: true,
|
||||
id: def.id,
|
||||
name: def.name ?? def.id ?? '',
|
||||
value: def.value
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +309,9 @@ export {
|
|||
renderJSX,
|
||||
renderTreeNode,
|
||||
buildComponent,
|
||||
defineVars,
|
||||
designVar,
|
||||
isVariable,
|
||||
Frame,
|
||||
Text,
|
||||
Rectangle,
|
||||
|
|
@ -336,6 +339,9 @@ export {
|
|||
type BaseProps,
|
||||
type TextProps,
|
||||
type StyleProps,
|
||||
type PaintProp,
|
||||
type DesignVariable,
|
||||
type VarDef,
|
||||
type RenderResult,
|
||||
sceneNodeToJSX,
|
||||
selectionToJSX,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import {
|
|||
Section,
|
||||
Component,
|
||||
ComponentSet,
|
||||
Instance
|
||||
Instance,
|
||||
defineVars,
|
||||
designVar
|
||||
} from '@open-pencil/core'
|
||||
|
||||
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
|
||||
|
|
@ -79,6 +81,63 @@ describe('renderTree', () => {
|
|||
expect(heading.fills.length).toBe(1)
|
||||
})
|
||||
|
||||
it('binds variable refs used as style values', async () => {
|
||||
const g = makeSceneGraph()
|
||||
g.addCollection({
|
||||
id: 'colors',
|
||||
name: 'Colors',
|
||||
modes: [{ modeId: 'light', name: 'Light' }],
|
||||
defaultModeId: 'light',
|
||||
variableIds: []
|
||||
})
|
||||
g.addVariable({
|
||||
id: 'var-bg',
|
||||
name: 'Background',
|
||||
type: 'COLOR',
|
||||
collectionId: 'colors',
|
||||
valuesByMode: { light: { r: 1, g: 0, b: 0, a: 1 } },
|
||||
description: '',
|
||||
hiddenFromPublishing: false
|
||||
})
|
||||
|
||||
const vars = defineVars({ bg: { id: 'var-bg', name: 'Background' } })
|
||||
const result = await renderTree(g, Frame({ name: 'Bound', w: 100, h: 100, fill: vars.bg }))
|
||||
const node = getNodeOrThrow(g, result.id)
|
||||
|
||||
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
|
||||
expect(node.fills[0]?.type).toBe('SOLID')
|
||||
})
|
||||
|
||||
it('supports explicit variable bindings for arbitrary paths', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const variable = designVar('var-shadow', '#000000')
|
||||
const result = await renderTree(
|
||||
g,
|
||||
Frame({
|
||||
name: 'Bound explicit',
|
||||
w: 100,
|
||||
h: 100,
|
||||
bind: { 'effects/0/color': variable, 'fills/0/color': 'var-bg' }
|
||||
})
|
||||
)
|
||||
const node = getNodeOrThrow(g, result.id)
|
||||
|
||||
expect(node.boundVariables['effects/0/color']).toBe('var-shadow')
|
||||
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
|
||||
})
|
||||
|
||||
it('binds variables from JSX strings', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const [result] = await renderJSX(
|
||||
g,
|
||||
`<Frame name="Bound JSX" w={100} h={100} fill={designVar('var-bg', '#ffffff')} />`
|
||||
)
|
||||
const node = getNodeOrThrow(g, result.id)
|
||||
|
||||
expect(node.boundVariables['fills/0/color']).toBe('var-bg')
|
||||
expect(node.fills[0]?.type).toBe('SOLID')
|
||||
})
|
||||
|
||||
it('renders components and instances', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const component = await renderTree(
|
||||
|
|
|
|||
Loading…
Reference in a new issue