openpencil/tests/engine/render/jsx/grid-layout.test.ts
Danila Poyarkov 11e708de39
fix(layout): size each axis on its own and lay out before geometry reads (#942)
* fix(figma-api): lay out pending edits before geometry reads

Scripts read x, y, width, height, transforms, and bounds as they were before the script ran until the tool finished and laid out its changes. Figma lays out on read, so a hugging parent reports its new size right after a child is added.

The graph now records the scope of edits outside layout application, and the geometry getters lay that scope out first through the same runner tools use after a call. One recorder exists per graph; a new FigmaAPI starts it afresh because the editor lays out its own edits.

* fix(layout): size each axis on its own, as Figma does

Fill is stored on the child, as layoutGrow along the parent's primary axis and STRETCH across it, with a grid laid out like a row; primaryAxisSizing and counterAxisSizing only fix or hug. A shared layoutSizing helper in scene-graph reads and writes per-axis sizing, and layout, the Figma API, the properties panel, design JSX, DOM/CSS export, and .pen import use it.

This fixes grid children filling both axes when set to fill one, auto-layout children that stretch but kept a fixed size, the Figma API writing Fill to a frame's own sizing (which .fig export dropped), and JSX and HTML exports losing grid and cross-axis fill. Behavior was checked against live Figma, and imported layouts were compared with the geometry stored in material3.fig and nuxtui.fig.

* fix(layout): keep pending edits until laid out and opt out of inherited stretch

A new FigmaAPI cleared the edits recorded on its graph, so after a script failed before its tool laid out its changes, the next script read stale geometry; edits now stay recorded until a read lays them out, and return to the record if that layout throws. Setting a child to Fixed or Hug across a parent that stretches every child left it filling; it now opts out with MIN, as frame presets do.
2026-10-07 11:52:08 +00:00

348 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)
})
// A hugging card keeps its content width in a grid cell, as in live Figma; w="fill" fills it.
it('grid children with flex hug their content unless they fill the cell', async () => {
const g = makeSceneGraph()
const card = (name: string, w?: 'fill') =>
Frame({
name,
...(w ? { w } : {}),
flex: 'col',
gap: 4,
p: 8,
children: [Rectangle({ name: `${name} content`, w: 10, h: 20 })]
})
const tree = Frame(
{ name: 'Grid', w: 200, grid: true, columns: '1fr 1fr', gap: 0 },
card('A'),
card('B', 'fill')
)
const result = await renderTree(g, tree)
computeAllLayouts(g)
const children = g.getChildren(result.id)
expect(children[0].width).toBe(26)
expect(children[1].width).toBe(100)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(100)
expect(children[1].height).toBe(36)
})
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)
})
})