feat(design-jsx): export every property the renderer accepts (#814)
* feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash.
This commit is contained in:
parent
749d7baec9
commit
67dbd7156f
|
|
@ -16,6 +16,7 @@
|
|||
### Added
|
||||
|
||||
- See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them.
|
||||
- Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them.
|
||||
- Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it.
|
||||
- Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought.
|
||||
- Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back.
|
||||
|
|
@ -37,6 +38,8 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0.
|
||||
- Apply `strokeAlign`, `strokeDash`, `minH`, and `maxH` in design JSX, which were accepted but ignored, and make `minW` and `maxW` set the layer's minimum and maximum width rather than only clamping its initial width.
|
||||
- Render the canvas with the Vue SDK's `CanvasRoot` and `CanvasSurface`; CanvasKit never started there and the canvas stayed blank.
|
||||
- Keep the view centered on what you were looking at when zooming to 100% or another fixed level, instead of jumping elsewhere whenever the zoom changes.
|
||||
- Draw collaborators' names on their cursors with proper letter spacing and fallback fonts, and end long names with an ellipsis.
|
||||
|
|
|
|||
|
|
@ -114,6 +114,14 @@ export function object(entries: (readonly [string, SyntaxNode])[]): SyntaxNode {
|
|||
|
||||
export const array = (elements: SyntaxNode[]): SyntaxNode => ({ type: 'ArrayExpression', elements })
|
||||
|
||||
/** A call of a function by name, such as a helper inside an exported prop value. */
|
||||
export const call = (callee: string, args: SyntaxNode[]): SyntaxNode => ({
|
||||
type: 'CallExpression',
|
||||
callee: identifier(callee),
|
||||
arguments: args,
|
||||
optional: false
|
||||
})
|
||||
|
||||
export function stringUnionType(values: string[]): SyntaxNode {
|
||||
const literals = values.map((value) => ({ type: 'TSLiteralType', literal: string(value) }))
|
||||
return literals.length === 1 && literals[0]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { describe, expect, it } from 'bun:test'
|
||||
import { describe, expect, it, test } from 'bun:test'
|
||||
import { pick } from 'es-toolkit'
|
||||
|
||||
import { renderJSX } from '@open-pencil/core/design-jsx'
|
||||
import { sceneNodeToJSX } from '@open-pencil/design-jsx'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import { SceneGraph, type Fill, type SceneNode, type Stroke } from '@open-pencil/scene-graph'
|
||||
|
||||
import { getNodeOrThrow } from '../helpers/assert'
|
||||
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
|
||||
|
||||
describe('attribute string round-trip', () => {
|
||||
it.each([
|
||||
|
|
@ -40,3 +41,357 @@ describe('text content round-trip', () => {
|
|||
expect(getNodeOrThrow(g, result.id).text).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
const RED = { r: 1, g: 0, b: 0, a: 1 }
|
||||
const BLUE = { r: 0, g: 0, b: 1, a: 1 }
|
||||
/** Alphas that survive the two-digit hex of exported colors exactly. */
|
||||
const SHADE = { r: 0, g: 0, b: 0, a: 0.2 }
|
||||
|
||||
function solidFill(color = RED, fields: Partial<Fill> = {}): Fill {
|
||||
return { type: 'SOLID', color, opacity: color.a, visible: true, ...fields }
|
||||
}
|
||||
|
||||
function stroke(fields: Partial<Stroke> = {}): Stroke {
|
||||
return { color: BLUE, weight: 1, opacity: 1, visible: true, align: 'INSIDE', ...fields }
|
||||
}
|
||||
|
||||
interface RoundTripCase {
|
||||
name: string
|
||||
/** Builds the exported root under `pageId` and returns the name of the node to compare. */
|
||||
build(graph: SceneGraph, pageId: string): { rootId: string; target: string }
|
||||
fields: (keyof SceneNode)[]
|
||||
}
|
||||
|
||||
function frame(graph: SceneGraph, parentId: string, fields: Partial<SceneNode> = {}) {
|
||||
return graph.createNode('FRAME', parentId, { name: 'Root', width: 300, height: 200, ...fields })
|
||||
}
|
||||
|
||||
function child(
|
||||
graph: SceneGraph,
|
||||
parentId: string,
|
||||
type: SceneNode['type'],
|
||||
fields: Partial<SceneNode>
|
||||
) {
|
||||
return graph.createNode(type, parentId, { name: 'Target', width: 40, height: 30, ...fields })
|
||||
}
|
||||
|
||||
/** A leaf under a plain frame, compared on `fields`. */
|
||||
function leafCase(
|
||||
name: string,
|
||||
type: SceneNode['type'],
|
||||
fields: Partial<SceneNode>,
|
||||
compared = Object.keys(fields) as (keyof SceneNode)[]
|
||||
): RoundTripCase {
|
||||
return {
|
||||
name,
|
||||
build(graph, pageId) {
|
||||
const root = frame(graph, pageId)
|
||||
child(graph, root.id, type, fields)
|
||||
return { rootId: root.id, target: 'Target' }
|
||||
},
|
||||
fields: compared
|
||||
}
|
||||
}
|
||||
|
||||
const CASES: RoundTripCase[] = [
|
||||
leafCase('hidden layer', 'RECTANGLE', { visible: false }),
|
||||
leafCase('locked layer', 'RECTANGLE', { locked: true }),
|
||||
leafCase('constraints', 'RECTANGLE', {
|
||||
horizontalConstraint: 'MAX',
|
||||
verticalConstraint: 'STRETCH'
|
||||
}),
|
||||
leafCase('scale and center constraints', 'RECTANGLE', {
|
||||
horizontalConstraint: 'SCALE',
|
||||
verticalConstraint: 'CENTER'
|
||||
}),
|
||||
leafCase('size limits', 'FRAME', {
|
||||
minWidth: 20,
|
||||
maxWidth: 400,
|
||||
minHeight: 10,
|
||||
maxHeight: 300
|
||||
}),
|
||||
leafCase('position and rotation', 'RECTANGLE', { x: 12, y: 34, rotation: 15 }),
|
||||
leafCase('opacity and blend mode', 'RECTANGLE', { opacity: 0.5, blendMode: 'MULTIPLY' }),
|
||||
// Figma leaves the uniform radius at 0 when corners differ.
|
||||
leafCase('independent corners and smoothing', 'RECTANGLE', {
|
||||
cornerRadius: 0,
|
||||
independentCorners: true,
|
||||
topLeftRadius: 4,
|
||||
topRightRadius: 8,
|
||||
bottomRightRadius: 12,
|
||||
bottomLeftRadius: 16,
|
||||
cornerSmoothing: 0.6
|
||||
}),
|
||||
leafCase('single solid fill', 'RECTANGLE', { fills: [solidFill()] }),
|
||||
leafCase('stacked, hidden, and blended fills', 'RECTANGLE', {
|
||||
fills: [
|
||||
solidFill(RED, { visible: false }),
|
||||
solidFill(BLUE, { opacity: 0.5, blendMode: 'SCREEN' })
|
||||
]
|
||||
}),
|
||||
leafCase('gradient fill with transform', 'RECTANGLE', {
|
||||
fills: [
|
||||
{
|
||||
type: 'GRADIENT_LINEAR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
gradientStops: [
|
||||
{ color: RED, position: 0 },
|
||||
{ color: BLUE, position: 1 }
|
||||
],
|
||||
gradientTransform: { m00: 0, m01: 1, m02: 0, m10: -1, m11: 0, m12: 1 }
|
||||
}
|
||||
]
|
||||
}),
|
||||
leafCase('gradient fading to transparent', 'RECTANGLE', {
|
||||
fills: [
|
||||
{
|
||||
type: 'GRADIENT_RADIAL',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
gradientStops: [
|
||||
{ color: SHADE, position: 0 },
|
||||
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 }
|
||||
],
|
||||
gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
}
|
||||
]
|
||||
}),
|
||||
leafCase('image fill', 'RECTANGLE', {
|
||||
fills: [
|
||||
{
|
||||
type: 'IMAGE',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
imageHash: 'abc123',
|
||||
imageScaleMode: 'FIT'
|
||||
}
|
||||
]
|
||||
}),
|
||||
leafCase('single stroke with every option', 'RECTANGLE', {
|
||||
strokes: [
|
||||
stroke({ weight: 3, align: 'OUTSIDE', dashPattern: [4, 2], cap: 'ROUND', join: 'BEVEL' })
|
||||
]
|
||||
}),
|
||||
leafCase('node-level stroke cap and join under a stroke without its own', 'RECTANGLE', {
|
||||
strokes: [stroke({ weight: 2 })],
|
||||
strokeCap: 'ROUND',
|
||||
strokeJoin: 'BEVEL'
|
||||
}),
|
||||
leafCase('stroke cap that differs from the node cap', 'RECTANGLE', {
|
||||
strokes: [stroke({ cap: 'SQUARE' })],
|
||||
strokeCap: 'ROUND'
|
||||
}),
|
||||
leafCase('node-level dash pattern under a stroke without its own', 'RECTANGLE', {
|
||||
strokes: [stroke()],
|
||||
dashPattern: [6, 3]
|
||||
}),
|
||||
leafCase('stroke opacity that differs from its colour alpha', 'RECTANGLE', {
|
||||
strokes: [stroke({ opacity: 0.5 })]
|
||||
}),
|
||||
leafCase('fill opacity that differs from its colour alpha', 'RECTANGLE', {
|
||||
fills: [solidFill(RED, { opacity: 0.5 })]
|
||||
}),
|
||||
leafCase('size limits without a fixed width', 'FRAME', {
|
||||
layoutMode: 'VERTICAL',
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'HUG',
|
||||
minWidth: 40,
|
||||
maxWidth: 400
|
||||
}),
|
||||
leafCase('several strokes, one hidden', 'RECTANGLE', {
|
||||
strokes: [stroke({ weight: 2 }), stroke({ color: RED, align: 'CENTER', visible: false })]
|
||||
}),
|
||||
leafCase('per-side stroke weights', 'RECTANGLE', {
|
||||
strokes: [stroke()],
|
||||
independentStrokeWeights: true,
|
||||
borderTopWeight: 0,
|
||||
borderRightWeight: 1,
|
||||
borderBottomWeight: 2,
|
||||
borderLeftWeight: 0
|
||||
}),
|
||||
leafCase('drop shadow and layer blur shorthands', 'RECTANGLE', {
|
||||
effects: [
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: SHADE,
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
spread: 0,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
type: 'LAYER_BLUR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: 4,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
}),
|
||||
leafCase('inner shadow, spread, hidden shadow, background blur', 'RECTANGLE', {
|
||||
effects: [
|
||||
{
|
||||
type: 'INNER_SHADOW',
|
||||
color: SHADE,
|
||||
offset: { x: 1, y: 2 },
|
||||
radius: 3,
|
||||
spread: 0,
|
||||
visible: true
|
||||
},
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: SHADE,
|
||||
offset: { x: 0, y: 8 },
|
||||
radius: 16,
|
||||
spread: 4,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
type: 'BACKGROUND_BLUR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: 12,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
}),
|
||||
leafCase('mask', 'RECTANGLE', { isMask: true, maskType: 'LUMINANCE' }),
|
||||
leafCase('clipping', 'FRAME', { clipsContent: true }),
|
||||
leafCase('star geometry', 'STAR', { pointCount: 7, starInnerRadius: 0.5 }),
|
||||
leafCase('text style', 'TEXT', {
|
||||
text: 'Hello',
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
italic: true,
|
||||
textAlignHorizontal: 'CENTER',
|
||||
textAlignVertical: 'BOTTOM',
|
||||
textAutoResize: 'NONE',
|
||||
letterSpacing: 1,
|
||||
lineHeight: 28,
|
||||
textCase: 'UPPER',
|
||||
textDecoration: 'UNDERLINE'
|
||||
}),
|
||||
leafCase('text that wraps at a fixed width', 'TEXT', {
|
||||
text: 'Wrapped',
|
||||
textAutoResize: 'HEIGHT'
|
||||
}),
|
||||
leafCase('text that hugs its content', 'TEXT', {
|
||||
text: 'Hug',
|
||||
textAutoResize: 'WIDTH_AND_HEIGHT'
|
||||
}),
|
||||
leafCase('truncated text', 'TEXT', {
|
||||
text: 'Clipped',
|
||||
textAutoResize: 'TRUNCATE',
|
||||
textTruncation: 'ENDING'
|
||||
}),
|
||||
{
|
||||
name: 'absolute child of an auto-layout frame',
|
||||
build(graph, pageId) {
|
||||
const root = frame(graph, pageId, { layoutMode: 'VERTICAL', itemSpacing: 8 })
|
||||
child(graph, root.id, 'RECTANGLE', { name: 'Flow' })
|
||||
child(graph, root.id, 'RECTANGLE', {
|
||||
layoutPositioning: 'ABSOLUTE',
|
||||
x: 10,
|
||||
y: 20,
|
||||
horizontalConstraint: 'MAX'
|
||||
})
|
||||
return { rootId: root.id, target: 'Target' }
|
||||
},
|
||||
fields: ['layoutPositioning', 'x', 'y', 'horizontalConstraint']
|
||||
},
|
||||
{
|
||||
name: 'auto layout',
|
||||
build(graph, pageId) {
|
||||
const root = frame(graph, pageId, {
|
||||
layoutMode: 'HORIZONTAL',
|
||||
itemSpacing: 12,
|
||||
paddingTop: 4,
|
||||
paddingRight: 8,
|
||||
paddingBottom: 4,
|
||||
paddingLeft: 8,
|
||||
primaryAxisAlign: 'SPACE_BETWEEN',
|
||||
counterAxisAlign: 'CENTER',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
child(graph, root.id, 'RECTANGLE', { name: 'A' })
|
||||
return { rootId: root.id, target: 'Root' }
|
||||
},
|
||||
fields: [
|
||||
'layoutMode',
|
||||
'itemSpacing',
|
||||
'paddingTop',
|
||||
'paddingRight',
|
||||
'paddingBottom',
|
||||
'paddingLeft',
|
||||
'primaryAxisAlign',
|
||||
'counterAxisAlign',
|
||||
'width',
|
||||
'height'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'variable bindings',
|
||||
build(graph, pageId) {
|
||||
const collection = graph.createCollection('Tokens')
|
||||
const brand = graph.createVariable('Brand', 'COLOR', collection.id, RED)
|
||||
const space = graph.createVariable('Space', 'FLOAT', collection.id, 16)
|
||||
const root = frame(graph, pageId, {
|
||||
layoutMode: 'VERTICAL',
|
||||
itemSpacing: 16,
|
||||
fills: [solidFill()]
|
||||
})
|
||||
graph.bindVariable(root.id, 'fills/0/color', brand.id)
|
||||
graph.bindVariable(root.id, 'itemSpacing', space.id)
|
||||
return { rootId: root.id, target: 'Root' }
|
||||
},
|
||||
fields: ['boundVariables']
|
||||
}
|
||||
]
|
||||
|
||||
function findByName(graph: SceneGraph, rootId: string, name: string): SceneNode {
|
||||
const root = expectDefined(graph.getNode(rootId), 'rendered root')
|
||||
if (root.name === name) return root
|
||||
for (const childId of root.childIds) {
|
||||
const found = graph.getNode(childId)?.name === name ? graph.getNode(childId) : undefined
|
||||
if (found) return found
|
||||
}
|
||||
throw new Error(`No node named ${name} under ${root.name}`)
|
||||
}
|
||||
|
||||
test('size limits are limits, not a width', async () => {
|
||||
const graph = new SceneGraph()
|
||||
const [defaultWidth] = await renderJSX(graph, '<Frame h={10} />')
|
||||
const [limited] = await renderJSX(graph, '<Frame h={10} maxW={400} />')
|
||||
const width = (id: string | undefined) => graph.getNode(expectDefined(id, 'frame'))?.width
|
||||
expect(graph.getNode(expectDefined(limited, 'frame').id)?.maxWidth).toBe(400)
|
||||
expect(width(limited?.id)).toBe(width(defaultWidth?.id))
|
||||
})
|
||||
|
||||
describe('design JSX export round trip', () => {
|
||||
test.each(CASES.map((testCase) => [testCase.name, testCase] as const))(
|
||||
'%s',
|
||||
async (_, testCase) => {
|
||||
const graph = new SceneGraph()
|
||||
const source = expectDefined(graph.getPages()[0], 'source page')
|
||||
const target = graph.addPage('Rendered')
|
||||
const { rootId, target: targetName } = testCase.build(graph, source.id)
|
||||
|
||||
const jsx = sceneNodeToJSX(rootId, graph)
|
||||
const [rendered] = await renderJSX(graph, jsx, { parentId: target.id })
|
||||
const renderedRoot = expectDefined(rendered, 'render result').id
|
||||
|
||||
const original = findByName(graph, rootId, targetName)
|
||||
const copy = findByName(graph, renderedRoot, targetName)
|
||||
expect(pick(copy, testCase.fields)).toEqual(pick(original, testCase.fields))
|
||||
// Everything the export wrote comes back, so exporting the copy changes nothing.
|
||||
expect(sceneNodeToJSX(renderedRoot, graph)).toBe(jsx)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,37 +1,10 @@
|
|||
import type {
|
||||
SceneGraph,
|
||||
SceneNode,
|
||||
Fill,
|
||||
Stroke,
|
||||
Effect,
|
||||
Color,
|
||||
GridTrack
|
||||
} from '@open-pencil/scene-graph'
|
||||
import type { SceneGraph, SceneNode, Effect, Color, GridTrack } from '@open-pencil/scene-graph'
|
||||
import { colorToHex8 } from '@open-pencil/scene-graph/color'
|
||||
|
||||
export function formatColor(color: Color, opacity = 1): string {
|
||||
return colorToHex8(color, opacity)
|
||||
}
|
||||
|
||||
export function solidFillColor(fills: Fill[]): string | null {
|
||||
const visible = fills.filter((f) => f.visible && f.type === 'SOLID')
|
||||
if (visible.length !== 1) return null
|
||||
return formatColor(visible[0].color, visible[0].opacity)
|
||||
}
|
||||
|
||||
export function solidStroke(
|
||||
strokes: Stroke[]
|
||||
): { color: string; weight: number; dash: number[] | null } | null {
|
||||
const visible = strokes.filter((s) => s.visible)
|
||||
if (visible.length !== 1) return null
|
||||
const s = visible[0]
|
||||
return {
|
||||
color: formatColor(s.color, s.opacity),
|
||||
weight: s.weight,
|
||||
dash: s.dashPattern && s.dashPattern.length > 0 ? [...s.dashPattern] : null
|
||||
}
|
||||
}
|
||||
|
||||
export function formatShadow(e: Effect): string | null {
|
||||
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
|
||||
return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}`
|
||||
|
|
@ -76,15 +49,16 @@ export interface CornerRadii {
|
|||
}
|
||||
|
||||
export function collectCornerRadii(node: SceneNode): CornerRadii | null {
|
||||
if (node.cornerRadius <= 0) return null
|
||||
if (node.independentCorners) {
|
||||
return {
|
||||
const corners = {
|
||||
tl: node.topLeftRadius,
|
||||
tr: node.topRightRadius,
|
||||
br: node.bottomRightRadius,
|
||||
bl: node.bottomLeftRadius
|
||||
}
|
||||
return Object.values(corners).some((radius) => radius > 0) ? corners : null
|
||||
}
|
||||
if (node.cornerRadius <= 0) return null
|
||||
const r = node.cornerRadius
|
||||
return { tl: r, tr: r, br: r, bl: r }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,18 @@ import { jsx, type SyntaxNode } from '@open-pencil/codegen'
|
|||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import { collectProps, NODE_TYPE_TO_TAG, type JSXProp } from './props'
|
||||
import { valueSyntax } from './value'
|
||||
|
||||
function propValue(value: JSXProp[1]): SyntaxNode | null {
|
||||
if (value === true) return null
|
||||
if (typeof value === 'string') return jsx.stringValue(value)
|
||||
if (Array.isArray(value)) {
|
||||
return jsx.container({
|
||||
type: 'ArrayExpression',
|
||||
elements: value.map((item) => jsx.literal(item))
|
||||
})
|
||||
}
|
||||
return jsx.container(jsx.literal(value))
|
||||
return jsx.container(valueSyntax(value))
|
||||
}
|
||||
|
||||
/** A prop as a JSX attribute: `true` prints the bare name, other values as typed literals. */
|
||||
/**
|
||||
* A prop as a JSX attribute: `true` prints the bare name, strings as attribute strings, and
|
||||
* other values as expressions, with paint and effect helpers as calls.
|
||||
*/
|
||||
function propAttribute([name, value]: JSXProp): SyntaxNode {
|
||||
return jsx.attribute(name, propValue(value))
|
||||
}
|
||||
|
|
@ -27,9 +25,9 @@ function nodeToJSX(node: SceneNode, graph: SceneGraph, depth: number): SyntaxNod
|
|||
if (node.type === 'TEXT') {
|
||||
return jsx.element(tag, attributes, node.text ? [jsx.text(node.text)] : [], depth, true)
|
||||
}
|
||||
// Hidden children export with `visible={false}` rather than disappearing.
|
||||
const children = graph
|
||||
.getChildren(node.id)
|
||||
.filter((child) => child.visible)
|
||||
.flatMap((child) => nodeToJSX(child, graph, depth + 1) ?? [])
|
||||
return jsx.element(tag, attributes, children, depth)
|
||||
}
|
||||
|
|
|
|||
222
packages/design-jsx/src/export/paint.ts
Normal file
222
packages/design-jsx/src/export/paint.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import type { Effect, Fill, GradientTransform, SceneNode, Stroke } from '@open-pencil/scene-graph'
|
||||
|
||||
import { formatColor, formatShadow } from './helpers'
|
||||
import { helperCall, plainValue, type HelperCall, type JSXProp, type JSXValue } from './value'
|
||||
|
||||
const GRADIENT_HELPERS: Partial<Record<Fill['type'], string>> = {
|
||||
GRADIENT_LINEAR: 'linearGradient',
|
||||
GRADIENT_RADIAL: 'radialGradient',
|
||||
GRADIENT_ANGULAR: 'angularGradient',
|
||||
GRADIENT_DIAMOND: 'diamondGradient'
|
||||
}
|
||||
|
||||
const IDENTITY_TRANSFORM: GradientTransform = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
|
||||
function isIdentity(transform: GradientTransform | undefined): boolean {
|
||||
if (!transform) return true
|
||||
return (Object.keys(IDENTITY_TRANSFORM) as (keyof GradientTransform)[]).every(
|
||||
(key) => transform[key] === IDENTITY_TRANSFORM[key]
|
||||
)
|
||||
}
|
||||
|
||||
/** Paints and effects imported from Figma often spell out the default blend mode. */
|
||||
function blendMode(mode: Fill['blendMode']): Fill['blendMode'] {
|
||||
return mode === 'NORMAL' || mode === 'PASS_THROUGH' ? undefined : mode
|
||||
}
|
||||
|
||||
type Options = Record<string, JSXValue | undefined>
|
||||
|
||||
/** Helper options with defaults left out, or `undefined` when none differ. */
|
||||
function options(entries: Options): Options | undefined {
|
||||
return Object.values(entries).some((value) => value !== undefined) ? entries : undefined
|
||||
}
|
||||
|
||||
function fillValue(fill: Fill): JSXValue {
|
||||
const visible = fill.visible ? undefined : false
|
||||
if (fill.type === 'SOLID') {
|
||||
const color = formatColor(fill.color, fill.color.a)
|
||||
const opacity = fill.opacity === fill.color.a ? undefined : fill.opacity
|
||||
return helperCall(
|
||||
'solid',
|
||||
color,
|
||||
options({ opacity, visible, blendMode: blendMode(fill.blendMode) })
|
||||
)
|
||||
}
|
||||
const helper = GRADIENT_HELPERS[fill.type]
|
||||
if (helper && fill.gradientStops) {
|
||||
const stops = fill.gradientStops.map((stop) => [
|
||||
formatColor(stop.color, stop.color.a),
|
||||
stop.position
|
||||
])
|
||||
return helperCall(
|
||||
helper,
|
||||
stops,
|
||||
options({
|
||||
opacity: fill.opacity === 1 ? undefined : fill.opacity,
|
||||
visible,
|
||||
blendMode: blendMode(fill.blendMode),
|
||||
transform: isIdentity(fill.gradientTransform)
|
||||
? undefined
|
||||
: plainValue(fill.gradientTransform)
|
||||
})
|
||||
)
|
||||
}
|
||||
// Image, pattern, and other paints have no helper; they render back from their fields.
|
||||
return plainValue(fill)
|
||||
}
|
||||
|
||||
/** A single visible solid paint fits the `bg` or text `color` shorthand. */
|
||||
function solidShorthand(fills: Fill[]): string | null {
|
||||
if (fills.length !== 1) return null
|
||||
const [fill] = fills
|
||||
if (fill.type !== 'SOLID' || !fill.visible || blendMode(fill.blendMode)) return null
|
||||
// The shorthand has one alpha; a fill whose opacity differs from its colour's needs `solid`.
|
||||
if (fill.opacity !== fill.color.a) return null
|
||||
return formatColor(fill.color, fill.color.a)
|
||||
}
|
||||
|
||||
export function collectFillProps(node: SceneNode, props: JSXProp[]): void {
|
||||
if (node.fills.length === 0) return
|
||||
const shorthand = solidShorthand(node.fills)
|
||||
if (shorthand) props.push([node.type === 'TEXT' ? 'color' : 'bg', shorthand])
|
||||
else props.push(['fills', node.fills.map(fillValue)])
|
||||
}
|
||||
|
||||
interface StrokeValue {
|
||||
[key: string]: JSXValue | undefined
|
||||
color: string
|
||||
/** Only when it differs from the colour's alpha, which the colour already carries. */
|
||||
opacity?: number
|
||||
weight?: number
|
||||
align?: string
|
||||
dash?: number[]
|
||||
cap?: string
|
||||
join?: string
|
||||
visible?: false
|
||||
}
|
||||
|
||||
function strokeValue(stroke: Stroke): StrokeValue {
|
||||
return {
|
||||
color: formatColor(stroke.color, stroke.color.a),
|
||||
opacity: stroke.opacity === stroke.color.a ? undefined : stroke.opacity,
|
||||
weight: stroke.weight === 1 ? undefined : stroke.weight,
|
||||
align: stroke.align === 'INSIDE' ? undefined : stroke.align.toLowerCase(),
|
||||
dash: stroke.dashPattern && stroke.dashPattern.length > 0 ? [...stroke.dashPattern] : undefined,
|
||||
cap: stroke.cap?.toLowerCase(),
|
||||
join: stroke.join?.toLowerCase(),
|
||||
visible: stroke.visible ? undefined : false
|
||||
}
|
||||
}
|
||||
|
||||
const STROKE_SHORTHAND: Record<string, string> = {
|
||||
color: 'stroke',
|
||||
weight: 'strokeWidth',
|
||||
align: 'strokeAlign',
|
||||
dash: 'strokeDash'
|
||||
}
|
||||
|
||||
/** Node-level cap and join apply to strokes that do not set their own. */
|
||||
const DEFAULT_STROKE_CAP = 'NONE'
|
||||
const DEFAULT_STROKE_JOIN = 'MITER'
|
||||
|
||||
/**
|
||||
* The shorthand props describe one stroke, and `strokeCap`/`strokeJoin` also set the node's
|
||||
* cap and join; they fit only when the stroke agrees with the node on both.
|
||||
*/
|
||||
function fitsStrokeShorthand(node: SceneNode, stroke: StrokeValue): boolean {
|
||||
if (stroke.visible !== undefined || stroke.opacity !== undefined) return false
|
||||
const nodeCap = node.strokeCap.toLowerCase()
|
||||
const nodeJoin = node.strokeJoin.toLowerCase()
|
||||
return (stroke.cap ?? nodeCap) === nodeCap && (stroke.join ?? nodeJoin) === nodeJoin
|
||||
}
|
||||
|
||||
export function collectStrokeProps(node: SceneNode, props: JSXProp[]): void {
|
||||
const strokes = node.strokes.map(strokeValue)
|
||||
const [only] = strokes
|
||||
const shorthand = strokes.length === 1 && fitsStrokeShorthand(node, only)
|
||||
if (shorthand) {
|
||||
for (const [key, value] of Object.entries(only)) {
|
||||
if (value !== undefined && key !== 'cap' && key !== 'join') {
|
||||
props.push([STROKE_SHORTHAND[key], value])
|
||||
}
|
||||
}
|
||||
} else if (strokes.length > 0) {
|
||||
props.push(['strokes', strokes])
|
||||
}
|
||||
if (node.strokeCap !== DEFAULT_STROKE_CAP) props.push(['strokeCap', node.strokeCap.toLowerCase()])
|
||||
if (node.strokeJoin !== DEFAULT_STROKE_JOIN) {
|
||||
props.push(['strokeJoin', node.strokeJoin.toLowerCase()])
|
||||
}
|
||||
if (node.dashPattern.length > 0) props.push(['dashPattern', [...node.dashPattern]])
|
||||
if (node.independentStrokeWeights) {
|
||||
props.push([
|
||||
'strokeWeights',
|
||||
{
|
||||
top: node.borderTopWeight,
|
||||
right: node.borderRightWeight,
|
||||
bottom: node.borderBottomWeight,
|
||||
left: node.borderLeftWeight
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const EFFECT_HELPERS: Record<Effect['type'], string> = {
|
||||
DROP_SHADOW: 'dropShadow',
|
||||
INNER_SHADOW: 'innerShadow',
|
||||
LAYER_BLUR: 'layerBlur',
|
||||
BACKGROUND_BLUR: 'backgroundBlur',
|
||||
FOREGROUND_BLUR: 'foregroundBlur'
|
||||
}
|
||||
|
||||
function effectValue(effect: Effect): HelperCall {
|
||||
const visible = effect.visible ? undefined : false
|
||||
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
|
||||
return helperCall(
|
||||
EFFECT_HELPERS[effect.type],
|
||||
options({
|
||||
x: effect.offset.x,
|
||||
y: effect.offset.y,
|
||||
radius: effect.radius,
|
||||
spread: effect.spread === 0 ? undefined : effect.spread,
|
||||
color: formatColor(effect.color, effect.color.a),
|
||||
visible,
|
||||
blendMode: blendMode(effect.blendMode),
|
||||
showShadowBehindNode: effect.showShadowBehindNode
|
||||
})
|
||||
)
|
||||
}
|
||||
return helperCall(
|
||||
EFFECT_HELPERS[effect.type],
|
||||
visible === undefined ? effect.radius : { radius: effect.radius, visible }
|
||||
)
|
||||
}
|
||||
|
||||
function plainDropShadow(effect: Effect): string | null {
|
||||
const plain =
|
||||
effect.type === 'DROP_SHADOW' &&
|
||||
effect.spread === 0 &&
|
||||
!blendMode(effect.blendMode) &&
|
||||
effect.showShadowBehindNode === undefined
|
||||
return plain ? formatShadow(effect) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* `shadow` and `blur` describe a plain drop shadow followed by a layer blur, each optional,
|
||||
* in the order the renderer appends them; anything else needs `effects`.
|
||||
*/
|
||||
function effectShorthand(effects: Effect[]): JSXProp[] | null {
|
||||
if (effects.some((effect) => !effect.visible)) return null
|
||||
const shadow = effects.length > 0 ? plainDropShadow(effects[0]) : null
|
||||
const rest = shadow ? effects.slice(1) : effects
|
||||
if (rest.length > 1 || rest.some((effect) => effect.type !== 'LAYER_BLUR')) return null
|
||||
const props: JSXProp[] = []
|
||||
if (shadow) props.push(['shadow', shadow])
|
||||
for (const blur of rest) props.push(['blur', blur.radius])
|
||||
return props
|
||||
}
|
||||
|
||||
export function collectEffectProps(node: SceneNode, props: JSXProp[]): void {
|
||||
if (node.effects.length === 0) return
|
||||
props.push(...(effectShorthand(node.effects) ?? [['effects', node.effects.map(effectValue)]]))
|
||||
}
|
||||
|
|
@ -6,15 +6,14 @@ import {
|
|||
collectCornerRadii,
|
||||
collectPadding,
|
||||
emitPadding,
|
||||
formatShadow,
|
||||
formatTracks,
|
||||
getNodeContext,
|
||||
solidFillColor,
|
||||
solidStroke
|
||||
getNodeContext
|
||||
} from './helpers'
|
||||
import { collectEffectProps, collectFillProps, collectStrokeProps } from './paint'
|
||||
import { collectStateProps } from './state'
|
||||
import type { JSXProp } from './value'
|
||||
|
||||
/** A prop name and its value: a string, number, boolean, or number list. */
|
||||
export type JSXProp = [name: string, value: string | number | boolean | number[]]
|
||||
export type { JSXProp } from './value'
|
||||
|
||||
/** The element that represents each node type; other types are not exported. */
|
||||
export const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
|
||||
|
|
@ -125,16 +124,8 @@ function collectCornerRadiiProps(node: SceneNode, props: JSXProp[]): void {
|
|||
}
|
||||
|
||||
function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void {
|
||||
const bg = solidFillColor(node.fills)
|
||||
if (bg) props.push(['bg', bg])
|
||||
|
||||
const stroke = solidStroke(node.strokes)
|
||||
if (stroke) {
|
||||
props.push(['stroke', stroke.color])
|
||||
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
|
||||
if (stroke.dash) props.push(['strokeDash', stroke.dash])
|
||||
}
|
||||
|
||||
collectFillProps(node, props)
|
||||
collectStrokeProps(node, props)
|
||||
collectCornerRadiiProps(node, props)
|
||||
|
||||
if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing])
|
||||
|
|
@ -144,16 +135,7 @@ function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void {
|
|||
props.push(['blendMode', node.blendMode.toLowerCase()])
|
||||
}
|
||||
if (node.clipsContent) props.push(['overflow', 'hidden'])
|
||||
|
||||
for (const effect of node.effects) {
|
||||
if (!effect.visible) continue
|
||||
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
|
||||
const shadow = formatShadow(effect)
|
||||
if (shadow) props.push(['shadow', shadow])
|
||||
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') {
|
||||
props.push(['blur', effect.radius])
|
||||
}
|
||||
}
|
||||
collectEffectProps(node, props)
|
||||
}
|
||||
|
||||
function collectPositionProps(
|
||||
|
|
@ -161,7 +143,12 @@ function collectPositionProps(
|
|||
ctx: ReturnType<typeof getNodeContext>,
|
||||
props: JSXProp[]
|
||||
): void {
|
||||
if (ctx.parentIsAutoLayout || ctx.parentIsGrid) return
|
||||
if (ctx.parentIsAutoLayout || ctx.parentIsGrid) {
|
||||
// Absolute children of a layout keep their position; the rest are placed by it.
|
||||
if (node.layoutPositioning !== 'ABSOLUTE') return
|
||||
props.push(['position', 'absolute'], ['x', node.x], ['y', node.y])
|
||||
return
|
||||
}
|
||||
if (node.x !== 0) props.push(['x', node.x])
|
||||
if (node.y !== 0) props.push(['y', node.y])
|
||||
}
|
||||
|
|
@ -225,9 +212,13 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void {
|
|||
else props.push(['weight', node.fontWeight])
|
||||
}
|
||||
if (direction === 'RTL') props.push(['dir', 'rtl'])
|
||||
if (node.italic) props.push(['italic', true])
|
||||
if (node.textAlignHorizontal !== 'LEFT') {
|
||||
props.push(['textAlign', node.textAlignHorizontal.toLowerCase()])
|
||||
}
|
||||
if (node.textAlignVertical !== 'TOP') {
|
||||
props.push(['textAlignVertical', node.textAlignVertical.toLowerCase()])
|
||||
}
|
||||
if (node.lineHeight != null) props.push(['lineHeight', node.lineHeight])
|
||||
if (node.letterSpacing !== 0) props.push(['letterSpacing', node.letterSpacing])
|
||||
if (node.textDecoration !== 'NONE')
|
||||
|
|
@ -235,11 +226,26 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void {
|
|||
if (node.textCase !== 'ORIGINAL') props.push(['textCase', node.textCase.toLowerCase()])
|
||||
if (node.maxLines != null) props.push(['maxLines', node.maxLines])
|
||||
if (node.textTruncation === 'ENDING' && node.maxLines == null) props.push(['truncate', true])
|
||||
const textColor = solidFillColor(node.fills)
|
||||
if (textColor) {
|
||||
const bgIdx = props.findIndex(([k]) => k === 'bg')
|
||||
if (bgIdx !== -1) props.splice(bgIdx, 1)
|
||||
props.push(['color', textColor])
|
||||
}
|
||||
|
||||
const TEXT_AUTO_RESIZE: Record<SceneNode['textAutoResize'], string> = {
|
||||
NONE: 'none',
|
||||
WIDTH_AND_HEIGHT: 'width',
|
||||
HEIGHT: 'height',
|
||||
TRUNCATE: 'truncate'
|
||||
}
|
||||
|
||||
/** The renderer infers auto-resize from the width props; say so only when it differs. */
|
||||
function collectTextAutoResizeProp(
|
||||
node: SceneNode,
|
||||
ctx: ReturnType<typeof getNodeContext>,
|
||||
props: JSXProp[]
|
||||
): void {
|
||||
const hasWidth = props.some(([key]) => key === 'w')
|
||||
const grows = ctx.parentIsAutoLayout && node.layoutGrow > 0
|
||||
const inferred = hasWidth || grows ? 'HEIGHT' : 'WIDTH_AND_HEIGHT'
|
||||
if (node.textAutoResize !== inferred) {
|
||||
props.push(['textAutoResize', TEXT_AUTO_RESIZE[node.textAutoResize]])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -266,8 +272,12 @@ export function collectProps(node: SceneNode, graph: SceneGraph): JSXProp[] {
|
|||
if (ctx.isFlex) collectFlexAlignmentProps(node, props)
|
||||
if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props)
|
||||
collectAppearanceProps(node, props)
|
||||
if (node.type === 'TEXT') collectTextNodeProps(node, props)
|
||||
if (node.type === 'TEXT') {
|
||||
collectTextNodeProps(node, props)
|
||||
collectTextAutoResizeProp(node, ctx, props)
|
||||
}
|
||||
collectShapeNodeProps(node, props)
|
||||
collectStateProps(node, ctx, graph, props)
|
||||
|
||||
return props
|
||||
}
|
||||
|
|
|
|||
65
packages/design-jsx/src/export/state.ts
Normal file
65
packages/design-jsx/src/export/state.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
import type { getNodeContext } from './helpers'
|
||||
import { helperCall, type JSXProp, type JSXValue } from './value'
|
||||
|
||||
/** Constraints apply where a parent does not lay its children out. */
|
||||
function hasConstraints(node: SceneNode, ctx: ReturnType<typeof getNodeContext>): boolean {
|
||||
return !ctx.parentIsAutoLayout || node.layoutPositioning === 'ABSOLUTE'
|
||||
}
|
||||
|
||||
function collectConstraintProps(
|
||||
node: SceneNode,
|
||||
ctx: ReturnType<typeof getNodeContext>,
|
||||
props: JSXProp[]
|
||||
): void {
|
||||
if (!hasConstraints(node, ctx)) return
|
||||
const horizontal = node.horizontalConstraint === 'MIN' ? undefined : node.horizontalConstraint
|
||||
const vertical = node.verticalConstraint === 'MIN' ? undefined : node.verticalConstraint
|
||||
if (!horizontal && !vertical) return
|
||||
props.push([
|
||||
'constraints',
|
||||
{ horizontal: horizontal?.toLowerCase(), vertical: vertical?.toLowerCase() }
|
||||
])
|
||||
}
|
||||
|
||||
function collectSizeLimitProps(node: SceneNode, props: JSXProp[]): void {
|
||||
if (node.minWidth != null) props.push(['minW', node.minWidth])
|
||||
if (node.maxWidth != null) props.push(['maxW', node.maxWidth])
|
||||
if (node.minHeight != null) props.push(['minH', node.minHeight])
|
||||
if (node.maxHeight != null) props.push(['maxH', node.maxHeight])
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables are named when the name is unique in the document, which is how `designVar`
|
||||
* resolves them, and referenced by ID otherwise.
|
||||
*/
|
||||
function variableRef(graph: SceneGraph, variableId: string): JSXValue {
|
||||
const variable = graph.variables.get(variableId)
|
||||
if (!variable) return variableId
|
||||
const sameName = [...graph.variables.values()].filter((v) => v.name === variable.name)
|
||||
return sameName.length === 1 ? helperCall('designVar', variable.name) : variableId
|
||||
}
|
||||
|
||||
function collectBindingProps(node: SceneNode, graph: SceneGraph, props: JSXProp[]): void {
|
||||
const bindings = Object.entries(node.boundVariables).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
if (bindings.length === 0) return
|
||||
props.push([
|
||||
'bind',
|
||||
Object.fromEntries(bindings.map(([field, id]) => [field, variableRef(graph, id)]))
|
||||
])
|
||||
}
|
||||
|
||||
export function collectStateProps(
|
||||
node: SceneNode,
|
||||
ctx: ReturnType<typeof getNodeContext>,
|
||||
graph: SceneGraph,
|
||||
props: JSXProp[]
|
||||
): void {
|
||||
collectConstraintProps(node, ctx, props)
|
||||
collectSizeLimitProps(node, props)
|
||||
if (node.isMask) props.push(['mask', node.maskType.toLowerCase()])
|
||||
if (!node.visible) props.push(['visible', false])
|
||||
if (node.locked) props.push(['locked', true])
|
||||
collectBindingProps(node, graph, props)
|
||||
}
|
||||
68
packages/design-jsx/src/export/value.ts
Normal file
68
packages/design-jsx/src/export/value.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { es, jsx, type SyntaxNode } from '@open-pencil/codegen'
|
||||
|
||||
/** A paint, effect, or variable helper call inside a prop value, such as `solid("#FF0000")`. */
|
||||
export class HelperCall {
|
||||
constructor(
|
||||
readonly helper: string,
|
||||
readonly args: JSXValue[]
|
||||
) {}
|
||||
}
|
||||
|
||||
/** A prop value: plain data, possibly holding helper calls. Undefined object fields are left out. */
|
||||
export type JSXValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| HelperCall
|
||||
| JSXValue[]
|
||||
| { [key: string]: JSXValue | undefined }
|
||||
|
||||
/** A prop name and its value. */
|
||||
export type JSXProp = [name: string, value: JSXValue]
|
||||
|
||||
/** A call with trailing undefined arguments dropped, so defaults stay implicit. */
|
||||
export function helperCall(helper: string, ...args: (JSXValue | undefined)[]): HelperCall {
|
||||
const last = args.findLastIndex((arg) => arg !== undefined)
|
||||
return new HelperCall(
|
||||
helper,
|
||||
args.slice(0, last + 1).map((arg) => arg ?? null)
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain scene data, such as a paint with no helper, as a prop value. Throws on values JSX
|
||||
* cannot write back, such as binary data.
|
||||
*/
|
||||
export function plainValue(value: unknown): JSXValue {
|
||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (Array.isArray(value)) return value.map(plainValue)
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) =>
|
||||
item === undefined ? [] : [[key, plainValue(item)]]
|
||||
)
|
||||
)
|
||||
}
|
||||
throw new TypeError(`Cannot write a ${typeof value} value as JSX`)
|
||||
}
|
||||
|
||||
/** The expression for a value, printed through the codegen builders. */
|
||||
export function valueSyntax(value: JSXValue): SyntaxNode {
|
||||
if (value === null) return { type: 'Literal', value: null }
|
||||
if (value instanceof HelperCall) return es.call(value.helper, value.args.map(valueSyntax))
|
||||
if (Array.isArray(value)) return es.array(value.map(valueSyntax))
|
||||
if (typeof value === 'object') {
|
||||
return es.object(
|
||||
Object.entries(value).flatMap(([key, item]) =>
|
||||
item === undefined ? [] : [[key, valueSyntax(item)] as const]
|
||||
)
|
||||
)
|
||||
}
|
||||
return jsx.literal(value)
|
||||
}
|
||||
51
packages/design-jsx/src/overrides/effects.ts
Normal file
51
packages/design-jsx/src/overrides/effects.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { Effect, SceneNode } from '@open-pencil/scene-graph'
|
||||
import { parseColor } from '@open-pencil/scene-graph/color'
|
||||
import { TRANSPARENT } from '@open-pencil/scene-graph/constants'
|
||||
|
||||
function isEffect(value: unknown): value is Effect {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
'radius' in value &&
|
||||
'visible' in value
|
||||
)
|
||||
}
|
||||
|
||||
function shadowShorthand(value: string): Effect | null {
|
||||
const parts = value.split(/\s+/)
|
||||
if (parts.length < 4) return null
|
||||
return {
|
||||
type: 'DROP_SHADOW',
|
||||
color: parseColor(parts.slice(3).join(' ')),
|
||||
offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) },
|
||||
radius: Number.parseFloat(parts[2]),
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
}
|
||||
|
||||
/** `effects` takes structured effects; `shadow` and `blur` append one drop shadow or layer blur. */
|
||||
export function applyEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
if (Array.isArray(props.effects)) {
|
||||
const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect))
|
||||
if (effects.length > 0) o.effects = effects
|
||||
}
|
||||
|
||||
const shadow = typeof props.shadow === 'string' ? shadowShorthand(props.shadow) : null
|
||||
if (shadow) o.effects = [...(o.effects ?? []), shadow]
|
||||
|
||||
if (typeof props.blur === 'number') {
|
||||
o.effects = [
|
||||
...(o.effects ?? []),
|
||||
{
|
||||
type: 'LAYER_BLUR',
|
||||
radius: props.blur,
|
||||
visible: true,
|
||||
color: { ...TRANSPARENT },
|
||||
offset: { x: 0, y: 0 },
|
||||
spread: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
65
packages/design-jsx/src/overrides/state.ts
Normal file
65
packages/design-jsx/src/overrides/state.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { ConstraintType, SceneNode } from '@open-pencil/scene-graph'
|
||||
|
||||
const CONSTRAINT_VALUES: Record<string, ConstraintType> = {
|
||||
min: 'MIN',
|
||||
center: 'CENTER',
|
||||
max: 'MAX',
|
||||
stretch: 'STRETCH',
|
||||
scale: 'SCALE'
|
||||
}
|
||||
|
||||
const MASK_TYPES: Record<string, SceneNode['maskType']> = {
|
||||
alpha: 'ALPHA',
|
||||
luminance: 'LUMINANCE',
|
||||
vector: 'VECTOR'
|
||||
}
|
||||
|
||||
function constraintValue(value: unknown): ConstraintType | undefined {
|
||||
return typeof value === 'string' ? CONSTRAINT_VALUES[value.toLowerCase()] : undefined
|
||||
}
|
||||
|
||||
/** The `constraints` prop, shaped like Figma's `constraints` object. */
|
||||
interface ConstraintsValue {
|
||||
horizontal?: unknown
|
||||
vertical?: unknown
|
||||
}
|
||||
|
||||
function isConstraintsValue(value: unknown): value is ConstraintsValue {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function applyConstraints(value: unknown, o: Partial<SceneNode>): void {
|
||||
if (!isConstraintsValue(value)) return
|
||||
const h = constraintValue(value.horizontal)
|
||||
const v = constraintValue(value.vertical)
|
||||
if (h) o.horizontalConstraint = h
|
||||
if (v) o.verticalConstraint = v
|
||||
}
|
||||
|
||||
function sizeLimit(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility, locking, mask, constraint, and size-limit props. Constraints follow Figma's
|
||||
* `constraints` object with its values in lowercase, as `blendMode` is.
|
||||
*/
|
||||
export function applyStateOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
if (typeof props.visible === 'boolean') o.visible = props.visible
|
||||
if (typeof props.locked === 'boolean') o.locked = props.locked
|
||||
applyConstraints(props.constraints, o)
|
||||
|
||||
const minWidth = sizeLimit(props.minW)
|
||||
const maxWidth = sizeLimit(props.maxW)
|
||||
const minHeight = sizeLimit(props.minH)
|
||||
const maxHeight = sizeLimit(props.maxH)
|
||||
if (minWidth !== undefined) o.minWidth = minWidth
|
||||
if (maxWidth !== undefined) o.maxWidth = maxWidth
|
||||
if (minHeight !== undefined) o.minHeight = minHeight
|
||||
if (maxHeight !== undefined) o.maxHeight = maxHeight
|
||||
|
||||
if (props.mask) {
|
||||
o.isMask = true
|
||||
o.maskType = (typeof props.mask === 'string' && MASK_TYPES[props.mask]) || 'ALPHA'
|
||||
}
|
||||
}
|
||||
129
packages/design-jsx/src/overrides/strokes.ts
Normal file
129
packages/design-jsx/src/overrides/strokes.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { SceneNode, Stroke, StrokeCap, StrokeJoin } from '@open-pencil/scene-graph'
|
||||
import { parseColor } from '@open-pencil/scene-graph/color'
|
||||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
const ALIGN_VALUES: Record<string, Stroke['align']> = {
|
||||
inside: 'INSIDE',
|
||||
center: 'CENTER',
|
||||
outside: 'OUTSIDE'
|
||||
}
|
||||
|
||||
const CAP_VALUES: Record<string, StrokeCap> = {
|
||||
none: 'NONE',
|
||||
round: 'ROUND',
|
||||
square: 'SQUARE',
|
||||
arrow_lines: 'ARROW_LINES',
|
||||
arrow_equilateral: 'ARROW_EQUILATERAL'
|
||||
}
|
||||
|
||||
const JOIN_VALUES: Record<string, StrokeJoin> = {
|
||||
miter: 'MITER',
|
||||
bevel: 'BEVEL',
|
||||
round: 'ROUND'
|
||||
}
|
||||
|
||||
/** One entry of the `strokes` prop; enums are lowercase, as `blendMode` is. */
|
||||
export interface StrokeValue {
|
||||
color: string | Color
|
||||
/** Defaults to the colour's alpha. */
|
||||
opacity?: number
|
||||
weight?: number
|
||||
align?: string
|
||||
dash?: number[]
|
||||
cap?: string
|
||||
join?: string
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
function isColor(value: unknown): value is Color {
|
||||
return value !== null && typeof value === 'object' && 'r' in value && 'a' in value
|
||||
}
|
||||
|
||||
function isStrokeValue(value: unknown): value is StrokeValue {
|
||||
if (value === null || typeof value !== 'object' || !('color' in value)) return false
|
||||
return typeof value.color === 'string' || isColor(value.color)
|
||||
}
|
||||
|
||||
function enumValue<T>(values: Record<string, T>, value: unknown): T | undefined {
|
||||
return typeof value === 'string' ? values[value.toLowerCase()] : undefined
|
||||
}
|
||||
|
||||
function dashValue(value: unknown): number[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const dash = value.filter((item): item is number => typeof item === 'number')
|
||||
return dash.length > 0 ? dash : undefined
|
||||
}
|
||||
|
||||
function toStroke(value: StrokeValue): Stroke {
|
||||
const color = typeof value.color === 'string' ? parseColor(value.color) : value.color
|
||||
const stroke: Stroke = {
|
||||
color,
|
||||
opacity: typeof value.opacity === 'number' ? value.opacity : color.a,
|
||||
visible: value.visible ?? true,
|
||||
weight: value.weight ?? 1,
|
||||
align: enumValue(ALIGN_VALUES, value.align) ?? 'INSIDE'
|
||||
}
|
||||
const dash = dashValue(value.dash)
|
||||
if (dash) stroke.dashPattern = dash
|
||||
const cap = enumValue(CAP_VALUES, value.cap)
|
||||
if (cap) stroke.cap = cap
|
||||
const join = enumValue(JOIN_VALUES, value.join)
|
||||
if (join) stroke.join = join
|
||||
return stroke
|
||||
}
|
||||
|
||||
/** `strokeWeights` names each side; a side it leaves out has no stroke. */
|
||||
interface StrokeWeights {
|
||||
top?: unknown
|
||||
right?: unknown
|
||||
bottom?: unknown
|
||||
left?: unknown
|
||||
}
|
||||
|
||||
function isStrokeWeights(value: unknown): value is StrokeWeights {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function sideWeight(value: unknown): number {
|
||||
return typeof value === 'number' ? value : 0
|
||||
}
|
||||
|
||||
function applyStrokeWeights(value: unknown, o: Partial<SceneNode>): void {
|
||||
if (!isStrokeWeights(value)) return
|
||||
o.independentStrokeWeights = true
|
||||
o.borderTopWeight = sideWeight(value.top)
|
||||
o.borderRightWeight = sideWeight(value.right)
|
||||
o.borderBottomWeight = sideWeight(value.bottom)
|
||||
o.borderLeftWeight = sideWeight(value.left)
|
||||
}
|
||||
|
||||
/** `strokes` takes structured strokes; `stroke` and its sibling props describe a single one. */
|
||||
export function applyStrokeOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
applyStrokeWeights(props.strokeWeights, o)
|
||||
// Node-level cap and join, which strokes without their own use.
|
||||
const nodeCap = enumValue(CAP_VALUES, props.strokeCap)
|
||||
if (nodeCap) o.strokeCap = nodeCap
|
||||
const nodeJoin = enumValue(JOIN_VALUES, props.strokeJoin)
|
||||
if (nodeJoin) o.strokeJoin = nodeJoin
|
||||
// The node's own dash pattern, separate from the stroke-local dash `strokeDash` sets.
|
||||
const nodeDash = dashValue(props.dashPattern)
|
||||
if (nodeDash) o.dashPattern = nodeDash
|
||||
|
||||
if (Array.isArray(props.strokes)) {
|
||||
const strokes = props.strokes.filter(isStrokeValue).map(toStroke)
|
||||
if (strokes.length > 0) o.strokes = strokes
|
||||
return
|
||||
}
|
||||
|
||||
const color = props.stroke ?? props.border ?? props.borderColor
|
||||
if (typeof color !== 'string' && !isColor(color)) return
|
||||
o.strokes = [
|
||||
toStroke({
|
||||
color,
|
||||
weight:
|
||||
(props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined),
|
||||
align: props.strokeAlign as string | undefined,
|
||||
dash: props.strokeDash as number[] | undefined
|
||||
})
|
||||
]
|
||||
}
|
||||
|
|
@ -1,17 +1,11 @@
|
|||
/* eslint-disable max-lines -- JSX property conversion stays together to preserve shared coercion rules */
|
||||
|
||||
import type {
|
||||
Effect,
|
||||
Fill,
|
||||
GridTrack,
|
||||
LayoutMode,
|
||||
SceneNode,
|
||||
Stroke
|
||||
} from '@open-pencil/scene-graph'
|
||||
import { colorToFill, parseColor } from '@open-pencil/scene-graph/color'
|
||||
import { TRANSPARENT } from '@open-pencil/scene-graph/constants'
|
||||
import type { Fill, GridTrack, LayoutMode, SceneNode } from '@open-pencil/scene-graph'
|
||||
import { colorToFill } from '@open-pencil/scene-graph/color'
|
||||
import type { Color, JSONObject } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { applyEffectOverrides } from './overrides/effects'
|
||||
import { applyStateOverrides } from './overrides/state'
|
||||
import { applyStrokeOverrides } from './overrides/strokes'
|
||||
|
||||
const WEIGHT_MAP: Record<string, number> = {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
|
|
@ -55,7 +49,8 @@ const TEXT_ALIGN_ALIAS_MAP: Record<string, SceneNode['textAlignHorizontal']> = {
|
|||
const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
|
||||
none: 'NONE',
|
||||
width: 'WIDTH_AND_HEIGHT',
|
||||
height: 'HEIGHT'
|
||||
height: 'HEIGHT',
|
||||
truncate: 'TRUNCATE'
|
||||
}
|
||||
|
||||
const DIRECTION_MAP: Record<string, SceneNode['textDirection']> = {
|
||||
|
|
@ -69,17 +64,6 @@ function parseDirection(value: unknown): SceneNode['textDirection'] | undefined
|
|||
return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO'
|
||||
}
|
||||
|
||||
function parseStroke(value: string | Color, width: number): Stroke {
|
||||
const color = typeof value === 'string' ? parseColor(value) : value
|
||||
return {
|
||||
color,
|
||||
opacity: color.a,
|
||||
visible: true,
|
||||
weight: width,
|
||||
align: 'INSIDE'
|
||||
}
|
||||
}
|
||||
|
||||
function numberFromPx(value: unknown): number | undefined {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
|
@ -208,14 +192,6 @@ function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>
|
|||
if (isFillValue(bg)) o.fills = [fillFromValue(bg)]
|
||||
}
|
||||
|
||||
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
const stroke = props.stroke ?? props.border ?? props.borderColor
|
||||
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)]
|
||||
}
|
||||
|
||||
function applyCornerOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
const rounded = props.rounded ?? props.cornerRadius ?? props.borderRadius
|
||||
if (typeof rounded === 'number') o.cornerRadius = rounded
|
||||
|
|
@ -238,7 +214,7 @@ function applyCornerOverrides(props: Record<string, unknown>, o: Partial<SceneNo
|
|||
|
||||
function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
applyFillOverride(props, o)
|
||||
applyStrokeOverride(props, o)
|
||||
applyStrokeOverrides(props, o)
|
||||
applyCornerOverrides(props, o)
|
||||
|
||||
if (props.opacity !== undefined) o.opacity = props.opacity as number
|
||||
|
|
@ -247,14 +223,6 @@ function applyVisualOverrides(props: Record<string, unknown>, o: Partial<SceneNo
|
|||
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
|
||||
}
|
||||
if (props.overflow === 'hidden') o.clipsContent = true
|
||||
if (props.mask) {
|
||||
o.isMask = true
|
||||
const maskTypeMap: Record<string, SceneNode['maskType']> = {
|
||||
luminance: 'LUMINANCE',
|
||||
vector: 'VECTOR'
|
||||
}
|
||||
o.maskType = maskTypeMap[props.mask as string] ?? 'ALPHA'
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransformOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
|
|
@ -451,9 +419,6 @@ function applyLayoutOverrides(
|
|||
applyPaddingOverrides(props, o)
|
||||
|
||||
if (props.grow !== undefined) o.layoutGrow = props.grow as number
|
||||
|
||||
if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number)
|
||||
if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number)
|
||||
}
|
||||
|
||||
function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
|
|
@ -464,6 +429,8 @@ function applyTextStyleOverrides(props: Record<string, unknown>, o: Partial<Scen
|
|||
if (typeof fontFamily === 'string') o.fontFamily = fontFamily
|
||||
|
||||
const weight = props.weight ?? props.fontWeight
|
||||
if (typeof props.italic === 'boolean') o.italic = props.italic
|
||||
|
||||
if (typeof weight === 'number') {
|
||||
o.fontWeight = weight
|
||||
} else if (typeof weight === 'string') {
|
||||
|
|
@ -536,57 +503,10 @@ function applyTextOverrides(
|
|||
applyTextAutoResize(props, o, parentLayout)
|
||||
}
|
||||
|
||||
function isEffect(value: unknown): value is Effect {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
'radius' in value &&
|
||||
'visible' in value
|
||||
)
|
||||
}
|
||||
|
||||
function applyShapeAndEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
if (Array.isArray(props.effects)) {
|
||||
const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect))
|
||||
if (effects.length > 0) o.effects = effects
|
||||
}
|
||||
|
||||
function applyShapeOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
if (props.points !== undefined) o.pointCount = props.points as number
|
||||
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
|
||||
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
|
||||
|
||||
if (typeof props.shadow === 'string') {
|
||||
const parts = props.shadow.split(/\s+/)
|
||||
if (parts.length >= 4) {
|
||||
const c = parseColor(parts.slice(3).join(' '))
|
||||
o.effects = [
|
||||
...(o.effects ?? []),
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: c,
|
||||
offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) },
|
||||
radius: Number.parseFloat(parts[2]),
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof props.blur === 'number') {
|
||||
o.effects = [
|
||||
...(o.effects ?? []),
|
||||
{
|
||||
type: 'LAYER_BLUR',
|
||||
radius: props.blur,
|
||||
visible: true,
|
||||
color: { ...TRANSPARENT },
|
||||
offset: { x: 0, y: 0 },
|
||||
spread: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function propsToOverrides(
|
||||
|
|
@ -603,7 +523,9 @@ export function propsToOverrides(
|
|||
applyVisualOverrides(props, o)
|
||||
applyLayoutOverrides(props, o, w, h, isText, parentLayout)
|
||||
if (isText) applyTextOverrides(props, o, parentLayout)
|
||||
applyShapeAndEffectOverrides(props, o)
|
||||
applyShapeOverrides(props, o)
|
||||
applyEffectOverrides(props, o)
|
||||
applyStateOverrides(props, o)
|
||||
|
||||
return o
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
|
|||
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
|
||||
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
|
||||
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
|
||||
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
|
||||
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
|
||||
|
||||
## Paint, text, and artwork
|
||||
|
||||
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
|
||||
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
|
||||
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
|
||||
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
|
||||
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
|
|||
'top',
|
||||
'left',
|
||||
'position',
|
||||
'constraints',
|
||||
'p',
|
||||
'padding',
|
||||
'px',
|
||||
|
|
@ -80,6 +81,11 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
|
|||
'borderWidth',
|
||||
'strokeAlign',
|
||||
'strokeDash',
|
||||
'strokeCap',
|
||||
'strokeJoin',
|
||||
'dashPattern',
|
||||
'strokes',
|
||||
'strokeWeights',
|
||||
'rounded',
|
||||
'borderRadius',
|
||||
'roundedTL',
|
||||
|
|
@ -93,6 +99,9 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
|
|||
'rotate',
|
||||
'rotation',
|
||||
'overflow',
|
||||
'mask',
|
||||
'visible',
|
||||
'locked',
|
||||
'shadow',
|
||||
'blur',
|
||||
'effects',
|
||||
|
|
@ -102,6 +111,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [
|
|||
'fontFamily',
|
||||
'weight',
|
||||
'fontWeight',
|
||||
'italic',
|
||||
'color',
|
||||
'text',
|
||||
'characters',
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export type StyleProps = {
|
|||
strokeWidth?: number | DesignVariable
|
||||
strokeAlign?: 'inside' | 'outside' | 'center'
|
||||
strokeDash?: number[] | boolean
|
||||
dashPattern?: number[]
|
||||
rounded?: number | DesignVariable
|
||||
roundedTL?: number | DesignVariable
|
||||
roundedTR?: number | DesignVariable
|
||||
|
|
|
|||
|
|
@ -221,7 +221,7 @@ describe('sceneNodeToJSX', () => {
|
|||
expect(jsx).toContain('strokeWidth={2}')
|
||||
})
|
||||
|
||||
test('hidden children are excluded', () => {
|
||||
test('hidden children are kept and marked hidden', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Parent',
|
||||
|
|
@ -239,9 +239,14 @@ describe('sceneNodeToJSX', () => {
|
|||
height: 50,
|
||||
visible: false
|
||||
})
|
||||
const jsx = sceneNodeToJSX(frame.id, graph)
|
||||
expect(jsx).toContain('Visible')
|
||||
expect(jsx).not.toContain('Hidden')
|
||||
expect(sceneNodeToJSX(frame.id, graph)).toBe(
|
||||
[
|
||||
'<Frame name="Parent" w={100} h={100}>',
|
||||
' <Rectangle name="Visible" w={50} h={50} />',
|
||||
' <Rectangle name="Hidden" w={50} h={50} visible={false} />',
|
||||
'</Frame>'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('empty text node renders self-closing', () => {
|
||||
|
|
@ -301,6 +306,54 @@ describe('sceneNodeToJSX', () => {
|
|||
expect(jsx).toContain('blur={4}')
|
||||
})
|
||||
|
||||
test('effects: two shadows become one effects prop, not duplicate attributes', () => {
|
||||
const graph = makeGraph()
|
||||
const shadow = {
|
||||
type: 'DROP_SHADOW' as const,
|
||||
color: { r: 0, g: 0, b: 0, a: 0.2 },
|
||||
offset: { x: 0, y: 2 },
|
||||
radius: 4,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
name: 'Card',
|
||||
width: 10,
|
||||
height: 10,
|
||||
effects: [shadow, { ...shadow, offset: { x: 0, y: 8 }, radius: 16 }]
|
||||
})
|
||||
expect(sceneNodeToJSX(node.id, graph)).toBe(
|
||||
[
|
||||
'<Rectangle name="Card" w={10} h={10} effects={[',
|
||||
' dropShadow({ x: 0, y: 2, radius: 4, color: "#00000033" }),',
|
||||
' dropShadow({ x: 0, y: 8, radius: 16, color: "#00000033" })',
|
||||
']} />'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('effects: a background blur is not exported as a layer blur', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
name: 'Glass',
|
||||
width: 10,
|
||||
height: 10,
|
||||
effects: [
|
||||
{
|
||||
type: 'BACKGROUND_BLUR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: 12,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(sceneNodeToJSX(node.id, graph)).toBe(
|
||||
'<Rectangle name="Glass" w={10} h={10} effects={[backgroundBlur(12)]} />'
|
||||
)
|
||||
})
|
||||
|
||||
test('grid layout frame', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ openpencil export design.fig -f jsx # OpenPencil format
|
|||
openpencil export design.fig -f jsx --style tailwind # Tailwind classes
|
||||
```
|
||||
|
||||
Exported OpenPencil JSX can be edited and rendered back into the document. Check supported features and actual rendering before claiming round-trip fidelity. See [CLI exports](./cli/exporting).
|
||||
Exported OpenPencil JSX can be edited and rendered back into the document. The export writes hidden and locked layers, constraints, size limits, stacked, gradient, and image fills, strokes, effects, masks, and variable bindings, so rendering an export reproduces them and `diff_jsx` shows changes to any of them. Rich text with mixed styles, vector paths, layout grids, shared styles, and component property definitions are not written yet. Instances are written as frames with their content, so exported JSX stands on its own. See [CLI exports](./cli/exporting).
|
||||
|
||||
## Visual Diffing
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
|
|||
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
|
||||
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
|
||||
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
|
||||
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
|
||||
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
|
||||
|
||||
## Paint, text, and artwork
|
||||
|
||||
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
|
||||
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
|
||||
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
|
||||
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
|
||||
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
|
||||
|
||||
|
|
@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
|
|||
|
||||
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
|
||||
|
||||
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
|
||||
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Styled saved glyph fixture
|
||||
|
||||
[`tests/fixtures/styled-saved-glyphs.json`](../../../../tests/fixtures/styled-saved-glyphs.json), which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed.
|
||||
`tests/fixtures/styled-saved-glyphs.json`, which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed.
|
||||
|
||||
Only typography, dimensions, fills, style runs, and saved glyph outlines/character indices are retained. This is not a full document or font binary. It contains 13 glyphs for 13 characters and **does not prove ligature coverage**.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render`
|
|||
- For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow.
|
||||
- `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`.
|
||||
- `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately.
|
||||
- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it.
|
||||
- Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed.
|
||||
|
||||
## Paint, text, and artwork
|
||||
|
||||
- `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`.
|
||||
- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`.
|
||||
- `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct.
|
||||
- `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available.
|
||||
- `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible.
|
||||
- Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work.
|
||||
|
||||
|
|
@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a
|
|||
|
||||
**Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`.
|
||||
|
||||
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
|
||||
**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`.
|
||||
Loading…
Reference in a new issue