* refactor(design-jsx): extract design JSX into its own package Design JSX elements, helpers, schema, reference, and JSX export only need the scene graph, yet lived in Core, so every consumer of the authoring API pulled in the renderer, layout, and file formats. @open-pencil/design-jsx now owns them and depends only on scene-graph. The renderer takes icon lookup, SVG conversion, vector creation, and layout as DesignJSXServices; Core binds its own and exports the bound renderJSX and renderTree from @open-pencil/core/design-jsx. * feat(design-jsx): export the JSX runtime for TSX authoring The package already had a JSX runtime, but nothing exported it, so design trees could only be written as function calls or JSX strings. Export `./jsx-runtime` and `./jsx-dev-runtime` so `jsxImportSource` works, and make `Fragment` produce the same empty-type node as `<>` in `renderJSX` strings. * fix(design-jsx): render fragments nested in other elements A fragment builds a node with an empty type, which only renderJSX expanded, and only at the root. Nested fragments and fragments passed to renderTree failed with 'Unknown element: <>'. Inline fragment children when trees are built, and share root expansion between renderTree and renderJSX.
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
|
|
|
import { renderTree, renderJSX, computeLayout, computeAllLayouts } from '@open-pencil/core'
|
|
import { Frame, Rectangle } from '@open-pencil/design-jsx'
|
|
|
|
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
|
|
import { makeSceneGraph } from '#tests/helpers/scene'
|
|
|
|
describe('grid layout rendering', () => {
|
|
it('creates grid frame with template columns', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr 1fr' },
|
|
Rectangle({ name: 'A', w: 50, h: 50 }),
|
|
Rectangle({ name: 'B', w: 50, h: 50 }),
|
|
Rectangle({ name: 'C', w: 50, h: 50 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.layoutMode).toBe('GRID')
|
|
expect(frame.gridTemplateColumns).toEqual([
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 1 }
|
|
])
|
|
})
|
|
|
|
it('creates grid frame with mixed track sizes', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({ name: 'Grid', w: 400, h: 200, grid: true, columns: '100 1fr 2fr' })
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.gridTemplateColumns).toEqual([
|
|
{ sizing: 'FIXED', value: 100 },
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 2 }
|
|
])
|
|
})
|
|
|
|
it('creates grid with numeric columns shorthand', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: 3 })
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.gridTemplateColumns).toEqual([
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 1 }
|
|
])
|
|
})
|
|
|
|
it('sets grid gaps', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({
|
|
name: 'Grid',
|
|
w: 300,
|
|
h: 200,
|
|
grid: true,
|
|
columns: '1fr 1fr',
|
|
columnGap: 10,
|
|
rowGap: 20
|
|
})
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.gridColumnGap).toBe(10)
|
|
expect(frame.gridRowGap).toBe(20)
|
|
})
|
|
|
|
it('sets grid gap shorthand', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr', gap: 16 })
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.gridColumnGap).toBe(16)
|
|
expect(frame.gridRowGap).toBe(16)
|
|
})
|
|
|
|
it('applies grid padding', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr', p: 10 })
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.paddingTop).toBe(10)
|
|
expect(frame.paddingRight).toBe(10)
|
|
expect(frame.paddingBottom).toBe(10)
|
|
expect(frame.paddingLeft).toBe(10)
|
|
})
|
|
|
|
it('sets grid rows', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame({
|
|
name: 'Grid',
|
|
w: 300,
|
|
h: 200,
|
|
grid: true,
|
|
columns: '1fr',
|
|
rows: '100 1fr'
|
|
})
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.gridTemplateRows).toEqual([
|
|
{ sizing: 'FIXED', value: 100 },
|
|
{ sizing: 'FR', value: 1 }
|
|
])
|
|
})
|
|
|
|
it('sets grid child position', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr 1fr', rows: '1fr 1fr' },
|
|
Rectangle({ name: 'A', w: 50, h: 50, colStart: 2, rowStart: 1 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
const children = g.getChildren(result.id)
|
|
|
|
expect(children[0].gridPosition).toEqual({ column: 2, row: 1, columnSpan: 1, rowSpan: 1 })
|
|
})
|
|
|
|
it('sets grid child span', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr 1fr', rows: '1fr 1fr' },
|
|
Rectangle({ name: 'A', w: 50, h: 50, colStart: 1, colSpan: 2, rowStart: 1, rowSpan: 2 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
const children = g.getChildren(result.id)
|
|
|
|
expect(children[0].gridPosition).toEqual({ column: 1, row: 1, columnSpan: 2, rowSpan: 2 })
|
|
})
|
|
|
|
it('grid + computeLayout positions children in cells', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr 1fr', rows: '1fr' },
|
|
Rectangle({ name: 'A', w: 50, h: 50 }),
|
|
Rectangle({ name: 'B', w: 50, h: 50 }),
|
|
Rectangle({ name: 'C', w: 50, h: 50 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
computeLayout(g, result.id)
|
|
|
|
const children = g.getChildren(result.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(100)
|
|
expect(children[2].x).toBe(200)
|
|
})
|
|
|
|
it('grid 2x2 with gap positions correctly', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 210, h: 210, grid: true, columns: '1fr 1fr', rows: '1fr 1fr', gap: 10 },
|
|
Rectangle({ name: 'A', w: 30, h: 30 }),
|
|
Rectangle({ name: 'B', w: 30, h: 30 }),
|
|
Rectangle({ name: 'C', w: 30, h: 30 }),
|
|
Rectangle({ name: 'D', w: 30, h: 30 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
computeLayout(g, result.id)
|
|
|
|
const children = g.getChildren(result.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[0].y).toBe(0)
|
|
expect(children[1].x).toBe(110)
|
|
expect(children[1].y).toBe(0)
|
|
expect(children[2].x).toBe(0)
|
|
expect(children[2].y).toBe(110)
|
|
expect(children[3].x).toBe(110)
|
|
expect(children[3].y).toBe(110)
|
|
})
|
|
|
|
it('renders grid from JSX string', async () => {
|
|
const g = makeSceneGraph()
|
|
const jsx = `
|
|
<Frame name="Grid" w={300} h={100} grid columns="1fr 1fr" gap={10}>
|
|
<Rectangle name="A" w={50} h={50} />
|
|
<Rectangle name="B" w={50} h={50} />
|
|
</Frame>
|
|
`
|
|
const [result] = await renderJSX(g, jsx)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
|
|
expect(frame.layoutMode).toBe('GRID')
|
|
expect(frame.gridTemplateColumns).toEqual([
|
|
{ sizing: 'FR', value: 1 },
|
|
{ sizing: 'FR', value: 1 }
|
|
])
|
|
expect(frame.gridColumnGap).toBe(10)
|
|
expect(frame.gridRowGap).toBe(10)
|
|
|
|
computeLayout(g, result.id)
|
|
const children = g.getChildren(result.id)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(155)
|
|
})
|
|
|
|
it('grid auto-height (no rows) grows to fit content', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, grid: true, columns: '1fr 1fr', gap: 10 },
|
|
Rectangle({ name: 'A', w: 50, h: 80 }),
|
|
Rectangle({ name: 'B', w: 50, h: 80 }),
|
|
Rectangle({ name: 'C', w: 50, h: 60 }),
|
|
Rectangle({ name: 'D', w: 50, h: 60 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
expect(frame.gridTemplateRows).toEqual([])
|
|
|
|
computeLayout(g, result.id)
|
|
const updated = getNodeOrThrow(g, result.id)
|
|
expect(updated.height).toBe(150)
|
|
})
|
|
|
|
it('grid children with flex stretch to cell width', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 200, grid: true, columns: '1fr 1fr', gap: 0 },
|
|
Frame({
|
|
name: 'A',
|
|
flex: 'col',
|
|
gap: 4,
|
|
p: 8,
|
|
children: [Rectangle({ name: 'R1', w: 10, h: 20 })]
|
|
}),
|
|
Frame({
|
|
name: 'B',
|
|
flex: 'col',
|
|
gap: 4,
|
|
p: 8,
|
|
children: [Rectangle({ name: 'R2', w: 10, h: 20 })]
|
|
})
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
computeAllLayouts(g)
|
|
|
|
const children = g.getChildren(result.id)
|
|
expect(children[0].width).toBe(100)
|
|
expect(children[1].width).toBe(100)
|
|
expect(children[0].x).toBe(0)
|
|
expect(children[1].x).toBe(100)
|
|
})
|
|
|
|
it('grid prop takes precedence over padding-triggered auto-layout', async () => {
|
|
const g = makeSceneGraph()
|
|
const tree = Frame(
|
|
{ name: 'Grid', w: 300, grid: true, columns: '1fr 1fr', p: 20 },
|
|
Rectangle({ name: 'A', w: 50, h: 50 }),
|
|
Rectangle({ name: 'B', w: 50, h: 50 })
|
|
)
|
|
const result = await renderTree(g, tree)
|
|
const frame = getNodeOrThrow(g, result.id)
|
|
expect(frame.layoutMode).toBe('GRID')
|
|
})
|
|
|
|
it('grid w=fill stretches in flex-col parent', async () => {
|
|
const g = makeSceneGraph()
|
|
const jsx = `
|
|
<Frame name="P" flex="col" w={400} p={20}>
|
|
<Frame name="G" grid columns="1fr 1fr" gap={10} w="fill">
|
|
<Rectangle w={10} h={30} />
|
|
<Rectangle w={10} h={30} />
|
|
</Frame>
|
|
</Frame>
|
|
`
|
|
const [result] = await renderJSX(g, jsx)
|
|
computeAllLayouts(g)
|
|
const grid = expectDefined(
|
|
g.getChildren(result.id).find((c) => c.name === 'G'),
|
|
'grid node'
|
|
)
|
|
expect(grid.width).toBe(360)
|
|
expect(grid.layoutMode).toBe('GRID')
|
|
})
|
|
|
|
it('grid grow=1 fills remaining space in flex-row', async () => {
|
|
const g = makeSceneGraph()
|
|
const jsx = `
|
|
<Frame name="P" flex="row" w={500} p={20} gap={10}>
|
|
<Rectangle w={100} h={50} />
|
|
<Frame name="G" grid columns="1fr 1fr" gap={10} grow={1}>
|
|
<Rectangle w={10} h={30} />
|
|
<Rectangle w={10} h={30} />
|
|
</Frame>
|
|
</Frame>
|
|
`
|
|
const [result] = await renderJSX(g, jsx)
|
|
computeAllLayouts(g)
|
|
const grid = expectDefined(
|
|
g.getChildren(result.id).find((c) => c.name === 'G'),
|
|
'grid node'
|
|
)
|
|
expect(grid.width).toBe(350)
|
|
})
|
|
|
|
it('nested flex > flex > grid: grid stretches to correct width', async () => {
|
|
const g = makeSceneGraph()
|
|
const jsx = `
|
|
<Frame name="Card" flex="col" w={780}>
|
|
<Frame name="Content" flex="col" w="fill" p={28}>
|
|
<Frame name="G" grid columns="1fr 1fr 1fr" w="fill" columnGap={16}>
|
|
<Rectangle w={10} h={30} />
|
|
<Rectangle w={10} h={30} />
|
|
<Rectangle w={10} h={30} />
|
|
</Frame>
|
|
</Frame>
|
|
</Frame>
|
|
`
|
|
const [result] = await renderJSX(g, jsx)
|
|
computeAllLayouts(g)
|
|
const content = expectDefined(
|
|
g.getChildren(result.id).find((c) => c.name === 'Content'),
|
|
'content node'
|
|
)
|
|
const grid = expectDefined(
|
|
g.getChildren(content.id).find((c) => c.name === 'G'),
|
|
'grid node'
|
|
)
|
|
expect(content.width).toBe(780)
|
|
expect(grid.width).toBe(724)
|
|
})
|
|
|
|
it('grid h=fill grows in flex-col parent', async () => {
|
|
const g = makeSceneGraph()
|
|
const jsx = `
|
|
<Frame name="P" flex="col" w={400} h={600} p={20}>
|
|
<Rectangle w={360} h={100} />
|
|
<Frame name="G" grid columns="1fr 1fr" gap={10} h="fill">
|
|
<Rectangle w={10} h={30} />
|
|
<Rectangle w={10} h={30} />
|
|
</Frame>
|
|
</Frame>
|
|
`
|
|
const [result] = await renderJSX(g, jsx)
|
|
computeAllLayouts(g)
|
|
const grid = expectDefined(
|
|
g.getChildren(result.id).find((c) => c.name === 'G'),
|
|
'grid node'
|
|
)
|
|
expect(grid.height).toBe(460)
|
|
})
|
|
})
|