openpencil/tests/engine/editor/frame-presets.test.ts
Luca Candela 18899ff13c
feat(editor): add Figma-style frame presets (#418)
Add frame preset creation and resizing with constraint-aware layout, undo/redo, toolbar flyouts, localization, documentation, and regression coverage.
2026-07-26 00:05:57 +03:00

304 lines
9.5 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { computeLayout } from '@open-pencil/core/layout'
import { getNodeOrThrow } from '#tests/helpers/assert'
const PHONE_PRESET = { name: 'iPhone Air', width: 420, height: 912 }
const DESKTOP_PRESET = { name: 'Desktop', width: 1440, height: 1024 }
describe('frame presets', () => {
test('creates a named frame at the viewport center and supports undo and redo', () => {
const editor = createEditor({ getViewportSize: () => ({ width: 1000, height: 800 }) })
editor.state.panX = 100
editor.state.panY = 50
editor.state.zoom = 2
const previousId = editor.createShape('RECTANGLE', 10, 20, 30, 40)
editor.select([previousId])
editor.setTool('FRAME')
const id = editor.createFrameFromPreset(PHONE_PRESET)
const frame = getNodeOrThrow(editor.graph, id)
expect(frame).toMatchObject({
type: 'FRAME',
name: 'iPhone Air',
x: -10,
y: -281,
width: 420,
height: 912,
parentId: editor.state.currentPageId
})
expect(editor.state.selectedIds).toEqual(new Set([id]))
expect(editor.state.activeTool).toBe('SELECT')
editor.undo.undo()
expect(editor.graph.getNode(id)).toBeUndefined()
expect(editor.state.selectedIds).toEqual(new Set([previousId]))
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
name: 'iPhone Air',
width: 420,
height: 912
})
expect(editor.state.selectedIds).toEqual(new Set([id]))
})
test('resizes a frame without renaming it and supports undo and redo', () => {
const editor = createEditor()
const id = editor.createFrameFromPreset(PHONE_PRESET)
editor.graph.updateNode(id, {
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
layoutGrow: 1,
layoutAlignSelf: 'STRETCH'
})
const nestedId = editor.graph.createNode('FRAME', id, {
x: 20,
y: 30,
width: 100,
height: 80,
horizontalConstraint: 'STRETCH'
}).id
const grandchildId = editor.graph.createNode('RECTANGLE', nestedId, {
x: 10,
y: 15,
width: 20,
height: 25,
horizontalConstraint: 'MAX'
}).id
editor.resizeFrameToPreset(id, DESKTOP_PRESET)
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
name: 'iPhone Air',
width: 1440,
height: 1024,
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutGrow: 0,
layoutAlignSelf: 'MIN'
})
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 1120 })
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 1030, width: 20 })
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
name: 'iPhone Air',
width: 420,
height: 912,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
layoutGrow: 1,
layoutAlignSelf: 'STRETCH'
})
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 100 })
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 10, width: 20 })
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
name: 'iPhone Air',
width: 1440,
height: 1024
})
expect(getNodeOrThrow(editor.graph, nestedId)).toMatchObject({ x: 20, width: 1120 })
expect(getNodeOrThrow(editor.graph, grandchildId)).toMatchObject({ x: 1030, width: 20 })
})
test('keeps exact preset dimensions inside a stretching auto-layout parent', () => {
const editor = createEditor()
const parentId = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 500,
height: 500,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
counterAxisAlign: 'STRETCH'
}).id
const id = editor.graph.createNode('FRAME', parentId, {
width: 100,
height: 100,
layoutAlignSelf: 'AUTO'
}).id
computeLayout(editor.graph, parentId)
expect(getNodeOrThrow(editor.graph, id).height).toBe(500)
editor.resizeFrameToPreset(id, { name: 'Custom', width: 200, height: 300 })
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 200,
height: 300,
layoutAlignSelf: 'MIN'
})
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 100,
height: 500,
layoutAlignSelf: 'AUTO'
})
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 200,
height: 300,
layoutAlignSelf: 'MIN'
})
})
test('keeps exact preset dimensions for an auto-layout frame inside a grid', () => {
const editor = createEditor()
const parentId = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 500,
height: 500,
layoutMode: 'GRID',
gridTemplateColumns: [{ sizing: 'FR', value: 1 }],
gridTemplateRows: [{ sizing: 'FR', value: 1 }]
}).id
const id = editor.graph.createNode('FRAME', parentId, {
width: 100,
height: 100,
layoutMode: 'HORIZONTAL',
layoutAlignSelf: 'AUTO'
}).id
computeLayout(editor.graph, parentId)
expect(getNodeOrThrow(editor.graph, id).width).toBe(500)
editor.resizeFrameToPreset(id, { name: 'Custom', width: 200, height: 300 })
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 200,
height: 300,
layoutAlignSelf: 'MIN'
})
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 500,
height: 100,
layoutAlignSelf: 'AUTO'
})
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, id)).toMatchObject({
width: 200,
height: 300,
layoutAlignSelf: 'MIN'
})
})
test('recomputes nested constraints across resize, undo, and redo', () => {
const editor = createEditor()
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 100,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED'
}).id
const childId = editor.graph.createNode('FRAME', id, {
width: 100,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutGrow: 1
}).id
const nestedFrameId = editor.graph.createNode('FRAME', childId, {
width: 100,
height: 100,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
layoutGrow: 1
}).id
const constrainedId = editor.graph.createNode('RECTANGLE', nestedFrameId, {
x: 80,
y: 0,
width: 10,
height: 10,
layoutPositioning: 'ABSOLUTE',
horizontalConstraint: 'MAX'
}).id
editor.resizeFrameToPreset(id, { name: 'Wide', width: 200, height: 100 })
expect(getNodeOrThrow(editor.graph, childId).width).toBe(200)
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(200)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(180)
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, childId).width).toBe(100)
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(100)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(80)
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, childId).width).toBe(200)
expect(getNodeOrThrow(editor.graph, nestedFrameId).width).toBe(200)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(180)
})
test('restores exact constrained geometry after lossy resize rounding', () => {
const editor = createEditor()
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 100,
height: 100
}).id
const childId = editor.graph.createNode('RECTANGLE', id, {
x: 10,
y: 0,
width: 10,
height: 10,
horizontalConstraint: 'CENTER'
}).id
editor.resizeFrameToPreset(id, { name: 'iPhone 16', width: 393, height: 852 })
expect(getNodeOrThrow(editor.graph, childId).x).toBe(157)
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, childId).x).toBe(10)
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, childId).x).toBe(157)
})
test('uses post-layout geometry for constraints nested in a HUG frame', () => {
const editor = createEditor()
const id = editor.graph.createNode('FRAME', editor.state.currentPageId, {
width: 100,
height: 100
}).id
const hugId = editor.graph.createNode('FRAME', id, {
width: 50,
height: 50,
layoutMode: 'HORIZONTAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'FIXED',
horizontalConstraint: 'STRETCH'
}).id
editor.graph.createNode('RECTANGLE', hugId, {
width: 50,
height: 10
})
const constrainedId = editor.graph.createNode('RECTANGLE', hugId, {
x: 40,
y: 0,
width: 10,
height: 10,
layoutPositioning: 'ABSOLUTE',
horizontalConstraint: 'MAX'
}).id
computeLayout(editor.graph, hugId)
editor.resizeFrameToPreset(id, { name: 'Wide', width: 200, height: 100 })
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, hugId).width).toBe(50)
expect(getNodeOrThrow(editor.graph, constrainedId).x).toBe(40)
})
})