* 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.
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
import { CanvasHelper } from '#tests/helpers/canvas'
|
|
import { propertyField, propertySection } from '#tests/helpers/properties'
|
|
import { getSelectedNode, getNodeById } from '#tests/helpers/store'
|
|
|
|
let page: Page
|
|
let canvas: CanvasHelper
|
|
let frameId: string
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
page = await browser.newPage()
|
|
await page.goto('/')
|
|
canvas = new CanvasHelper(page)
|
|
await canvas.waitForInit()
|
|
await canvas.clearCanvas()
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await page.close()
|
|
})
|
|
|
|
async function selectFrame() {
|
|
expect(frameId, 'frameId must be set — did the Shift+A test run?').toBeTruthy()
|
|
await page.evaluate((id: string) => {
|
|
const store = window.openPencil?.getStore?.()
|
|
if (!store) throw new Error('OpenPencil store not initialized')
|
|
store.select([id])
|
|
}, frameId)
|
|
await canvas.waitForRender()
|
|
}
|
|
|
|
test('Shift+A wraps selection in auto-layout frame', async () => {
|
|
await canvas.clearCanvas()
|
|
await canvas.drawRect(100, 100, 60, 60)
|
|
await canvas.drawRect(220, 100, 60, 60)
|
|
await canvas.pressKey('Meta+a')
|
|
await canvas.waitForRender()
|
|
|
|
await canvas.pressKey('Shift+A')
|
|
await canvas.waitForRender()
|
|
|
|
const node = await getSelectedNode(page)
|
|
expect(node).not.toBeNull()
|
|
expect(expectDefined(node, 'node').type).toBe('FRAME')
|
|
expect(expectDefined(node, 'node').layoutMode).not.toBe('NONE')
|
|
expect(expectDefined(node, 'node').childIds.length).toBe(2)
|
|
|
|
frameId = expectDefined(node, 'node').id
|
|
|
|
await expect(propertySection(page, 'Layout')).toHaveCount(0)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('direction controls switch between vertical grid and horizontal layout', async () => {
|
|
await selectFrame()
|
|
const layout = propertySection(page, 'Auto layout')
|
|
|
|
await layout.getByRole('button', { name: 'Vertical layout' }).click()
|
|
await canvas.waitForRender()
|
|
expect(expectDefined(await getNodeById(page, frameId), 'frame').layoutMode).toBe('VERTICAL')
|
|
|
|
await layout.getByRole('button', { name: 'Grid layout' }).click()
|
|
await canvas.waitForRender()
|
|
expect(expectDefined(await getNodeById(page, frameId), 'frame').layoutMode).toBe('GRID')
|
|
|
|
await layout.getByRole('button', { name: 'Horizontal layout' }).click()
|
|
await canvas.waitForRender()
|
|
expect(expectDefined(await getNodeById(page, frameId), 'frame').layoutMode).toBe('HORIZONTAL')
|
|
|
|
await layout.getByRole('button', { name: 'Vertical layout' }).click()
|
|
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
|
|
await page.getByRole('option', { name: 'Hug' }).click()
|
|
await canvas.waitForRender()
|
|
expect(expectDefined(await getNodeById(page, frameId), 'frame').layoutMode).toBe('VERTICAL')
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('gap NumberField sets itemSpacing', async () => {
|
|
await selectFrame()
|
|
const before = await getNodeById(page, frameId)
|
|
const initialSpacing = expectDefined(before, 'before').itemSpacing
|
|
|
|
await canvas.dragNumberField(page.getByTestId('layout-gap-input'), 40)
|
|
|
|
const after = await getNodeById(page, frameId)
|
|
expect(expectDefined(after, 'after').itemSpacing).toBeGreaterThan(initialSpacing + 5)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('gap menu sets auto space-between alignment', async () => {
|
|
await selectFrame()
|
|
|
|
await page.getByTestId('layout-gap-menu').click()
|
|
await page.getByRole('option', { name: 'Auto' }).click()
|
|
await canvas.waitForRender()
|
|
let frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('SPACE_BETWEEN')
|
|
await expect(page.getByTestId('layout-alignment-grid').locator('button')).toHaveCount(9)
|
|
|
|
await page.getByTestId('layout-gap-menu').click()
|
|
await page
|
|
.getByRole('option', { name: String(Math.round(expectDefined(frame, 'frame').itemSpacing)) })
|
|
.click()
|
|
await canvas.waitForRender()
|
|
|
|
frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('MIN')
|
|
await expect(page.getByTestId('layout-alignment-grid').locator('button')).toHaveCount(9)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('wrap mode exposes cross-axis gap control', async () => {
|
|
await selectFrame()
|
|
|
|
await propertySection(page, 'Auto layout').getByRole('button', { name: 'Wrap layout' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
const before = await getNodeById(page, frameId)
|
|
const initialSpacing = expectDefined(before, 'before').counterAxisSpacing
|
|
await canvas.dragNumberField(page.getByTestId('layout-cross-gap-input'), 40)
|
|
|
|
const after = await getNodeById(page, frameId)
|
|
expect(expectDefined(after, 'after').layoutWrap).toBe('WRAP')
|
|
expect(expectDefined(after, 'after').counterAxisSpacing).toBeGreaterThan(initialSpacing + 5)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('padding controls set horizontal and vertical padding pairs', async () => {
|
|
await selectFrame()
|
|
|
|
await page.getByTestId('layout-horizontal-padding-input').click()
|
|
await canvas.waitForRender()
|
|
const horizontalInput = page
|
|
.getByTestId('layout-horizontal-padding-input')
|
|
.getByRole('spinbutton')
|
|
await horizontalInput.fill('24')
|
|
await horizontalInput.press('Enter')
|
|
await canvas.waitForRender()
|
|
|
|
await page.getByTestId('layout-vertical-padding-input').click()
|
|
await canvas.waitForRender()
|
|
const verticalInput = page.getByTestId('layout-vertical-padding-input').getByRole('spinbutton')
|
|
await verticalInput.fill('16')
|
|
await verticalInput.press('Enter')
|
|
await canvas.waitForRender()
|
|
|
|
const frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').paddingTop).toBe(16)
|
|
expect(expectDefined(frame, 'frame').paddingRight).toBe(24)
|
|
expect(expectDefined(frame, 'frame').paddingBottom).toBe(16)
|
|
expect(expectDefined(frame, 'frame').paddingLeft).toBe(24)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('size dropdown adds and removes min width', async () => {
|
|
await selectFrame()
|
|
|
|
const layout = propertySection(page, 'Auto layout')
|
|
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
|
|
await page.getByRole('option', { name: 'Add min width' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
let frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').minWidth).toBe(
|
|
Math.round(expectDefined(frame, 'frame').width)
|
|
)
|
|
await expect(layout.locator('[data-property="minWidth"]')).toBeVisible()
|
|
|
|
await propertyField(page, 'width').getByRole('combobox', { name: 'Width' }).click()
|
|
await page.getByRole('option', { name: 'Remove min width' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').minWidth).toBeNull()
|
|
await expect(layout.locator('[data-property="minWidth"]')).toHaveCount(0)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('editing a Hug width switches to Fixed in one reversible interaction', async () => {
|
|
await selectFrame()
|
|
const widthField = propertyField(page, 'width')
|
|
|
|
await widthField.getByRole('combobox', { name: 'Width' }).click()
|
|
await page.getByRole('option', { name: 'Hug' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
const before = expectDefined(await getNodeById(page, frameId), 'before')
|
|
expect(before.counterAxisSizing).toBe('HUG')
|
|
|
|
await widthField.focus()
|
|
await widthField.getByRole('spinbutton').press('Escape')
|
|
const afterFocus = expectDefined(await getNodeById(page, frameId), 'after focus')
|
|
expect(afterFocus.counterAxisSizing).toBe('HUG')
|
|
|
|
await widthField.focus()
|
|
const input = widthField.getByRole('spinbutton')
|
|
await input.fill(String(Math.round(before.width + 40)))
|
|
await input.press('Escape')
|
|
await canvas.waitForRender()
|
|
|
|
const cancelled = expectDefined(await getNodeById(page, frameId), 'cancelled')
|
|
expect(cancelled.counterAxisSizing).toBe('HUG')
|
|
expect(cancelled.width).toBeCloseTo(before.width)
|
|
|
|
await widthField.focus()
|
|
await widthField.getByRole('spinbutton').fill(String(Math.round(before.width + 40)))
|
|
await widthField.getByRole('spinbutton').press('Enter')
|
|
await canvas.waitForRender()
|
|
|
|
const changed = expectDefined(await getNodeById(page, frameId), 'changed')
|
|
expect(changed.counterAxisSizing).toBe('FIXED')
|
|
expect(changed.width).toBe(Math.round(before.width + 40))
|
|
|
|
await canvas.pressKey('Meta+z')
|
|
await canvas.waitForRender()
|
|
const undone = expectDefined(await getNodeById(page, frameId), 'undone')
|
|
expect(undone.counterAxisSizing).toBe('HUG')
|
|
expect(undone.width).toBeCloseTo(before.width)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('editing a Fill height switches to Fixed in one undo step', async () => {
|
|
const frame = expectDefined(await getNodeById(page, frameId), 'frame')
|
|
const childId = expectDefined(frame.childIds[0], 'child')
|
|
await page.evaluate((id: string) => window.openPencil?.getStore?.()?.select([id]), childId)
|
|
await canvas.waitForRender()
|
|
// Fill is stored on the child: grow along a column, stretch across a row.
|
|
const fillsHeight = (node: { layoutGrow: number; layoutAlignSelf: string }) =>
|
|
frame.layoutMode === 'VERTICAL' ? node.layoutGrow > 0 : node.layoutAlignSelf === 'STRETCH'
|
|
const heightField = propertyField(page, 'height')
|
|
|
|
await heightField.getByRole('combobox', { name: 'Height' }).click()
|
|
await page.getByRole('option', { name: 'Fill' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
const before = expectDefined(await getNodeById(page, childId), 'before')
|
|
expect(fillsHeight(before)).toBe(true)
|
|
|
|
await heightField.focus()
|
|
await heightField.getByRole('spinbutton').fill(String(Math.round(before.height + 30)))
|
|
await heightField.getByRole('spinbutton').press('Enter')
|
|
await canvas.waitForRender()
|
|
|
|
const changed = expectDefined(await getNodeById(page, childId), 'changed')
|
|
expect(fillsHeight(changed)).toBe(false)
|
|
expect(changed.height).toBe(Math.round(before.height + 30))
|
|
|
|
await canvas.pressKey('Meta+z')
|
|
await canvas.waitForRender()
|
|
const undone = expectDefined(await getNodeById(page, childId), 'undone')
|
|
expect(fillsHeight(undone)).toBe(true)
|
|
expect(undone.height).toBeCloseTo(before.height)
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('alignment grid center sets CENTER alignment', async () => {
|
|
await selectFrame()
|
|
|
|
const centerCell = page.getByTestId('layout-alignment-grid').locator('button').nth(4)
|
|
await centerCell.click()
|
|
await canvas.waitForRender()
|
|
await expect(centerCell).toHaveAttribute('data-active', 'true')
|
|
|
|
const frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('CENTER')
|
|
expect(expectDefined(frame, 'frame').counterAxisAlign).toBe('CENTER')
|
|
canvas.assertNoErrors()
|
|
})
|
|
|
|
test('Freeform disables auto layout and a flow option restores it', async () => {
|
|
await selectFrame()
|
|
|
|
await propertySection(page, 'Auto layout').getByRole('button', { name: 'Freeform' }).click()
|
|
await canvas.waitForRender()
|
|
|
|
let frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').layoutMode).toBe('NONE')
|
|
const layout = propertySection(page, 'Layout')
|
|
await expect(layout).toBeVisible()
|
|
await expect(layout.getByRole('button', { name: 'Add auto layout' })).toHaveAttribute(
|
|
'data-state',
|
|
'off'
|
|
)
|
|
await expect(layout.getByText('Flow', { exact: true })).toBeVisible()
|
|
await expect(layout.getByRole('checkbox', { name: 'Clip content' })).toBeVisible()
|
|
await expect(layout.getByRole('button', { name: 'Freeform' })).toHaveAttribute('data-state', 'on')
|
|
await expect(propertySection(page, 'Auto layout')).toHaveCount(0)
|
|
|
|
await layout.getByRole('button', { name: 'Vertical layout' }).click()
|
|
await canvas.waitForRender()
|
|
frame = await getNodeById(page, frameId)
|
|
expect(expectDefined(frame, 'frame').layoutMode).toBe('VERTICAL')
|
|
const autoLayout = propertySection(page, 'Auto layout')
|
|
await expect(autoLayout).toBeVisible()
|
|
await expect(autoLayout.getByRole('button', { name: 'Remove auto layout' })).toHaveAttribute(
|
|
'data-state',
|
|
'on'
|
|
)
|
|
await expect(propertySection(page, 'Layout')).toHaveCount(0)
|
|
canvas.assertNoErrors()
|
|
})
|