feat: bind Design JSX scalar props to variables
This commit is contained in:
parent
59c31c74bf
commit
98e0ba9563
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- Bind Design JSX spacing, sizing, corners, and typography directly to numeric document variables.
|
||||
- Save AI conversations and attachment previews locally, switch between chats, rename or delete them, and browse saved transcripts across documents. Choose whether reasoning stays collapsed, expands while thinking, or stays expanded, with animated disclosure controls that respect reduced motion.
|
||||
|
||||
- Add a searchable command palette for editor and application actions.
|
||||
|
|
|
|||
|
|
@ -17,9 +17,10 @@ import { computeAllLayouts } from '#core/layout'
|
|||
import { randomHex } from '#core/random'
|
||||
|
||||
import { applySizeOverrides, propsToOverrides } from './props-overrides'
|
||||
import { prepareScalarBindings } from './scalar-bindings'
|
||||
import { isTreeNode } from './tree'
|
||||
import type { TreeNode } from './tree'
|
||||
import { isVariable, type DesignVariable } from './vars'
|
||||
import { isVariable, resolveVariableId, type DesignVariable } from './vars'
|
||||
|
||||
const TYPE_MAP: Partial<Record<string, NodeType>> = {
|
||||
frame: 'FRAME',
|
||||
|
|
@ -93,17 +94,8 @@ 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
|
||||
if (variable.value !== undefined && typeof variable.value !== 'number') return variable.value
|
||||
const variableId = resolveVariableId(graph, variable)
|
||||
return variableId ? graph.resolveColorVariable(variableId) : undefined
|
||||
}
|
||||
|
|
@ -141,7 +133,8 @@ function bindStyleVariableProp(
|
|||
function preparePropsForRender(
|
||||
graph: SceneGraph,
|
||||
source: Record<string, unknown>,
|
||||
isText: boolean
|
||||
isText: boolean,
|
||||
parentId: string
|
||||
): PreparedProps {
|
||||
const props = { ...source }
|
||||
const bindings: Record<string, string> = {}
|
||||
|
|
@ -173,6 +166,8 @@ function preparePropsForRender(
|
|||
props.style = style
|
||||
}
|
||||
|
||||
prepareScalarBindings(graph, props, bindings, isText, parentId)
|
||||
|
||||
if (isObjectRecord(props.bind)) {
|
||||
for (const [field, value] of Object.entries(props.bind)) {
|
||||
if (isVariable(value)) {
|
||||
|
|
@ -413,7 +408,7 @@ async function renderInstanceNode(
|
|||
): Promise<SceneNode> {
|
||||
const parent = graph.getNode(parentId)
|
||||
const parentLayout = parent?.layoutMode ?? 'NONE'
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, false)
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, false, parentId)
|
||||
const component = resolveComponent(graph, props)
|
||||
if (!component) {
|
||||
const ref = props.component ?? props.componentId ?? props.of
|
||||
|
|
@ -482,7 +477,7 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string):
|
|||
const parentLayout = parent?.layoutMode ?? 'NONE'
|
||||
|
||||
const isText = nodeType === 'TEXT'
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, isText)
|
||||
const { props, bindings } = preparePropsForRender(graph, tree.props, isText, parentId)
|
||||
const overrides = propsToOverrides(props, isText, parentLayout)
|
||||
|
||||
if (isText) {
|
||||
|
|
|
|||
77
packages/core/src/design-jsx/scalar-bindings.ts
Normal file
77
packages/core/src/design-jsx/scalar-bindings.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import type { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { isVariable, resolveVariableId } from './vars'
|
||||
|
||||
// Shorthands precede their longhands, matching propsToOverrides precedence.
|
||||
const PADDING_FIELDS = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft']
|
||||
const SCALAR_PROPS: ReadonlyArray<readonly [string, readonly string[]]> = [
|
||||
['w', ['width']],
|
||||
['h', ['height']],
|
||||
['p', PADDING_FIELDS],
|
||||
['px', ['paddingLeft', 'paddingRight']],
|
||||
['py', ['paddingTop', 'paddingBottom']],
|
||||
['pt', ['paddingTop']],
|
||||
['pr', ['paddingRight']],
|
||||
['pb', ['paddingBottom']],
|
||||
['pl', ['paddingLeft']],
|
||||
['rounded', ['cornerRadius']],
|
||||
['roundedTL', ['topLeftRadius']],
|
||||
['roundedTR', ['topRightRadius']],
|
||||
['roundedBL', ['bottomLeftRadius']],
|
||||
['roundedBR', ['bottomRightRadius']],
|
||||
['strokeWidth', ['strokeWeight']],
|
||||
['opacity', ['opacity']]
|
||||
]
|
||||
|
||||
/** Resolve numeric props before sizing inference; retain real graph bindings afterward. */
|
||||
export function prepareScalarBindings(
|
||||
graph: SceneGraph,
|
||||
props: Record<string, unknown>,
|
||||
bindings: Record<string, string>,
|
||||
isText: boolean,
|
||||
parentId: string
|
||||
): void {
|
||||
const entries = [...SCALAR_PROPS]
|
||||
const scalarBindings = new Map<string, string>()
|
||||
if (props.grid) {
|
||||
// Existing grid authoring gives gap precedence over the axis-specific props.
|
||||
entries.push(
|
||||
['columnGap', ['gridColumnGap']],
|
||||
['rowGap', ['gridRowGap']],
|
||||
['gap', ['gridColumnGap', 'gridRowGap']]
|
||||
)
|
||||
} else {
|
||||
entries.push(['gap', ['itemSpacing']])
|
||||
if (props.wrap) entries.push(['rowGap', ['counterAxisSpacing']])
|
||||
}
|
||||
if (isText) {
|
||||
entries.push(
|
||||
[props.size !== undefined ? 'size' : 'fontSize', ['fontSize']],
|
||||
['lineHeight', ['lineHeight']],
|
||||
['letterSpacing', ['letterSpacing']]
|
||||
)
|
||||
}
|
||||
|
||||
for (const [key, fields] of entries) {
|
||||
const value = props[key]
|
||||
if (value === undefined) continue
|
||||
// A literal longhand must also override a bound shorthand on that edge.
|
||||
for (const field of fields) scalarBindings.delete(field)
|
||||
if (!isVariable(value)) continue
|
||||
|
||||
const variableId = resolveVariableId(graph, value)
|
||||
const variable = variableId ? graph.variables.get(variableId) : undefined
|
||||
if (variable?.type !== 'FLOAT') {
|
||||
throw new Error(`Expected a FLOAT variable for ${key}: ${value.name}`)
|
||||
}
|
||||
const resolved =
|
||||
graph.resolveNumberVariableForNode(parentId, variable.id) ??
|
||||
(typeof value.value === 'number' ? value.value : undefined)
|
||||
if (resolved === undefined || !Number.isFinite(resolved)) {
|
||||
throw new Error(`Cannot resolve numeric variable for ${key}: ${value.name}`)
|
||||
}
|
||||
props[key] = resolved
|
||||
for (const field of fields) scalarBindings.set(field, variable.id)
|
||||
}
|
||||
Object.assign(bindings, Object.fromEntries(scalarBindings))
|
||||
}
|
||||
|
|
@ -92,9 +92,9 @@ export type StyleProps = {
|
|||
flex?: 'row' | 'col' | 'column'
|
||||
flow?: 'auto' | 'ltr' | 'rtl'
|
||||
dir?: 'auto' | 'ltr' | 'rtl'
|
||||
gap?: number
|
||||
gap?: number | DesignVariable
|
||||
wrap?: boolean
|
||||
rowGap?: number
|
||||
rowGap?: number | DesignVariable
|
||||
justify?: 'start' | 'end' | 'center' | 'between'
|
||||
justifyContent?: 'start' | 'end' | 'center' | 'between'
|
||||
items?: 'start' | 'end' | 'center' | 'stretch'
|
||||
|
|
@ -102,8 +102,8 @@ export type StyleProps = {
|
|||
alignItems?: 'start' | 'end' | 'center' | 'stretch'
|
||||
grow?: number
|
||||
|
||||
w?: number | 'fill' | 'hug'
|
||||
h?: number | 'fill' | 'hug'
|
||||
w?: number | 'fill' | 'hug' | DesignVariable
|
||||
h?: number | 'fill' | 'hug' | DesignVariable
|
||||
minW?: number
|
||||
maxW?: number
|
||||
minH?: number
|
||||
|
|
@ -112,28 +112,28 @@ export type StyleProps = {
|
|||
x?: number
|
||||
y?: number
|
||||
|
||||
p?: number
|
||||
px?: number
|
||||
py?: number
|
||||
pt?: number
|
||||
pr?: number
|
||||
pb?: number
|
||||
pl?: number
|
||||
p?: number | DesignVariable
|
||||
px?: number | DesignVariable
|
||||
py?: number | DesignVariable
|
||||
pt?: number | DesignVariable
|
||||
pr?: number | DesignVariable
|
||||
pb?: number | DesignVariable
|
||||
pl?: number | DesignVariable
|
||||
|
||||
bg?: PaintProp
|
||||
fill?: PaintProp
|
||||
fills?: PaintProp[]
|
||||
stroke?: PaintProp
|
||||
strokeWidth?: number
|
||||
strokeWidth?: number | DesignVariable
|
||||
strokeAlign?: 'inside' | 'outside' | 'center'
|
||||
strokeDash?: number[] | boolean
|
||||
rounded?: number
|
||||
roundedTL?: number
|
||||
roundedTR?: number
|
||||
roundedBL?: number
|
||||
roundedBR?: number
|
||||
rounded?: number | DesignVariable
|
||||
roundedTL?: number | DesignVariable
|
||||
roundedTR?: number | DesignVariable
|
||||
roundedBL?: number | DesignVariable
|
||||
roundedBR?: number | DesignVariable
|
||||
cornerSmoothing?: number
|
||||
opacity?: number
|
||||
opacity?: number | DesignVariable
|
||||
blendMode?: string
|
||||
mask?: boolean | 'alpha' | 'luminance' | 'vector'
|
||||
rotate?: number
|
||||
|
|
@ -143,8 +143,8 @@ export type StyleProps = {
|
|||
blur?: number
|
||||
effects?: Effect[]
|
||||
|
||||
size?: number
|
||||
fontSize?: number
|
||||
size?: number | DesignVariable
|
||||
fontSize?: number | DesignVariable
|
||||
font?: string
|
||||
fontFamily?: string
|
||||
weight?: number | 'bold' | 'medium' | 'normal'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
import type { Color, SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
const VAR_SYMBOL = Symbol.for('open-pencil.variable')
|
||||
|
||||
|
|
@ -7,14 +7,23 @@ export type VarDef =
|
|||
| {
|
||||
id?: string
|
||||
name?: string
|
||||
value?: string | Color
|
||||
value?: string | Color | number
|
||||
}
|
||||
|
||||
export interface DesignVariable {
|
||||
[VAR_SYMBOL]: true
|
||||
id?: string
|
||||
name: string
|
||||
value?: string | Color
|
||||
value?: string | Color | number
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function isVariable(value: unknown): value is DesignVariable {
|
||||
|
|
@ -33,14 +42,9 @@ export function defineVars<T extends Record<string, VarDef>>(
|
|||
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 {
|
||||
export function designVar(def: VarDef): DesignVariable
|
||||
export function designVar(idOrName: string, value?: DesignVariable['value']): DesignVariable
|
||||
export function designVar(def: VarDef, value?: DesignVariable['value']): DesignVariable {
|
||||
if (typeof def === 'string') {
|
||||
return {
|
||||
[VAR_SYMBOL]: true,
|
||||
|
|
|
|||
148
tests/engine/render/jsx/scalar-variables.test.ts
Normal file
148
tests/engine/render/jsx/scalar-variables.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
|
||||
import {
|
||||
Frame,
|
||||
Text,
|
||||
Rectangle,
|
||||
defineVars,
|
||||
designVar,
|
||||
renderTree,
|
||||
renderJSX
|
||||
} from '@open-pencil/core/design-jsx'
|
||||
|
||||
import { getNodeOrThrow } from '#tests/helpers/assert'
|
||||
import { addTestColorVariable, makeSceneGraph } from '#tests/helpers/scene'
|
||||
|
||||
function setup() {
|
||||
const graph = makeSceneGraph()
|
||||
graph.addCollection({
|
||||
id: 'spacing',
|
||||
name: 'Spacing',
|
||||
modes: [
|
||||
{ modeId: 'comfortable', name: 'Comfortable' },
|
||||
{ modeId: 'compact', name: 'Compact' }
|
||||
],
|
||||
defaultModeId: 'comfortable',
|
||||
variableIds: []
|
||||
})
|
||||
graph.addVariable({
|
||||
id: 'space',
|
||||
name: 'Space/medium',
|
||||
type: 'FLOAT',
|
||||
collectionId: 'spacing',
|
||||
valuesByMode: { comfortable: 16, compact: 8 },
|
||||
description: '',
|
||||
hiddenFromPublishing: false
|
||||
})
|
||||
return { graph, token: designVar('space') }
|
||||
}
|
||||
|
||||
describe('Design JSX scalar variables', () => {
|
||||
it('binds layout shorthands and computes Hug sizing from their values', async () => {
|
||||
const { graph, token } = setup()
|
||||
const result = await renderTree(
|
||||
graph,
|
||||
Frame({
|
||||
flex: 'col',
|
||||
w: 100,
|
||||
h: 'hug',
|
||||
gap: token,
|
||||
p: token,
|
||||
rounded: token,
|
||||
children: [Rectangle({ w: 20, h: 20 }), Rectangle({ w: 20, h: 20 })]
|
||||
})
|
||||
)
|
||||
const node = getNodeOrThrow(graph, result.id)
|
||||
expect(node.height).toBe(88)
|
||||
expect(node.itemSpacing).toBe(16)
|
||||
expect(node.boundVariables).toMatchObject({
|
||||
itemSpacing: 'space',
|
||||
paddingTop: 'space',
|
||||
paddingRight: 'space',
|
||||
paddingBottom: 'space',
|
||||
paddingLeft: 'space',
|
||||
cornerRadius: 'space'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves literal longhand precedence over a bound shorthand', async () => {
|
||||
const { graph, token } = setup()
|
||||
const result = await renderTree(graph, Frame({ w: 100, h: 100, p: token, px: 4, pt: 2 }))
|
||||
const node = getNodeOrThrow(graph, result.id)
|
||||
expect([node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft]).toEqual([
|
||||
2, 4, 16, 4
|
||||
])
|
||||
expect(node.boundVariables).toEqual({ paddingBottom: 'space' })
|
||||
})
|
||||
|
||||
it('preserves existing grid gap precedence with scalar bindings', async () => {
|
||||
const { graph, token } = setup()
|
||||
const result = await renderTree(
|
||||
graph,
|
||||
Frame({ grid: true, columns: 2, w: 100, h: 100, gap: token, columnGap: 3 })
|
||||
)
|
||||
const node = getNodeOrThrow(graph, result.id)
|
||||
expect(node.gridColumnGap).toBe(16)
|
||||
expect(node.gridRowGap).toBe(16)
|
||||
expect(node.boundVariables).toEqual({ gridRowGap: 'space', gridColumnGap: 'space' })
|
||||
})
|
||||
|
||||
it('supports numeric fallback types, name lookup, typography, and fixed sizes', async () => {
|
||||
const { graph } = setup()
|
||||
const vars = defineVars({ space: { name: 'Space/medium', value: 16 } })
|
||||
const result = await renderTree(
|
||||
graph,
|
||||
Text({
|
||||
w: vars.space,
|
||||
size: vars.space,
|
||||
lineHeight: vars.space,
|
||||
letterSpacing: vars.space,
|
||||
children: 'A'
|
||||
})
|
||||
)
|
||||
const node = getNodeOrThrow(graph, result.id)
|
||||
expect(node.boundVariables).toMatchObject({
|
||||
width: 'space',
|
||||
fontSize: 'space',
|
||||
lineHeight: 'space',
|
||||
letterSpacing: 'space'
|
||||
})
|
||||
expect(node.fontSize).toBe(16)
|
||||
})
|
||||
|
||||
it('binds through JSX strings in the agent authoring path', async () => {
|
||||
const { graph } = setup()
|
||||
const [result] = await renderJSX(
|
||||
graph,
|
||||
'<Frame w={100} h="hug" flex="col" p={designVar("space", 16)} />'
|
||||
)
|
||||
expect(result).toBeDefined()
|
||||
expect(getNodeOrThrow(graph, result.id).boundVariables.paddingTop).toBe('space')
|
||||
})
|
||||
|
||||
it('inherits the parent collection mode', async () => {
|
||||
const { graph, token } = setup()
|
||||
const parent = graph.createNode('FRAME', graph.getPages()[0]?.id ?? '', {
|
||||
variableModes: { spacing: 'compact' }
|
||||
})
|
||||
const result = await renderTree(
|
||||
graph,
|
||||
Frame({ w: 100, h: 'hug', flex: 'col', p: token, children: Rectangle({ w: 20, h: 20 }) }),
|
||||
{ parentId: parent.id }
|
||||
)
|
||||
const node = getNodeOrThrow(graph, result.id)
|
||||
expect(graph.resolveNumberVariableForNode(node.id, 'space')).toBe(8)
|
||||
expect(node.height).toBe(36)
|
||||
})
|
||||
|
||||
it('rejects missing and nonnumeric variables instead of storing objects in numeric fields', async () => {
|
||||
const { graph } = setup()
|
||||
addTestColorVariable(graph, 'color', 'Color')
|
||||
await expect(renderTree(graph, Frame({ gap: designVar('color') }))).rejects.toThrow(
|
||||
'Expected a FLOAT variable'
|
||||
)
|
||||
await expect(renderTree(graph, Frame({ gap: designVar('missing', 16) }))).rejects.toThrow(
|
||||
'Expected a FLOAT variable'
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue