CSS Grid layout mode (#73)

* Switch to @open-pencil/yoga-layout with CSS Grid support

Use our fork (open-pencil/yoga, grid branch) which cherry-picks the
upstream CSS Grid PRs (#1893–#1898) onto current main. The JS bindings
are ported from the old embind approach to the new wasm_bridge.c API.

npm:@open-pencil/yoga-layout alias keeps all imports as 'yoga-layout'.

Also handle FinalizationRegistry change (upstream #1908) — node.free()
no longer exists, nodes are garbage collected automatically.

* Add CSS Grid layout mode

Scene graph:
- LayoutMode gains 'GRID' option alongside HORIZONTAL/VERTICAL
- GridTrack type (sizing: FIXED/FR/AUTO, value) for track definitions
- GridPosition type (column, row, columnSpan, rowSpan) for children
- New node props: gridTemplateColumns/Rows, gridColumnGap, gridRowGap,
  gridPosition

Layout engine:
- buildGridTree() sets Display.Grid and maps GridTrack[] to Yoga's
  setGridTemplateColumns/Rows API with FR/Points/Auto track types
- Grid children use gridPosition for column/row placement with span
- Flex layout path unchanged

Store:
- setLayoutMode('GRID') auto-creates NxM track grid based on child
  count (sqrt heuristic), defaults to 1fr tracks

UI (LayoutSection.vue):
- Grid button (grid-2x2 icon) added to flow direction row
- Columns/Rows track editors: ScrubInput for value + AppSelect for
  sizing mode (Fill fr / Fixed px / Auto), add/remove buttons
- Separate column gap and row gap ScrubInputs
- Wrap button hidden when grid is active
- Flex alignment grid hidden when grid is active

Kiwi serialization skips GRID mode for now (no .fig codec support).

* Add grid layout integration tests

12 new test scenarios covering:
- Basic 2x2 grid, fixed columns, mixed fr/fixed, unequal fr weights
- Column gap, row gap, both gaps combined
- Padding offsets
- Explicit gridPosition placement, column span, row span
- Absolute children skipped
- Hidden children collapsed
- Nested grid inside flex parent (computeAllLayouts)

Also fix: grid frames as children of flex parents now correctly use
Display.Grid via configureChildAsGrid() instead of falling through
to configureChildAsAutoLayout() which set FlexDirection.

* Deduplicate grid yoga configuration

Extract configureAsGrid() and createGridChildNode() shared by
buildGridTree (root-level grid) and configureChildAsGrid (grid
nested inside flex parent).

* Add grid layout to changelog

* Add grid support to JSX and Tailwind CSS export

OpenPencil format: grid → columns/rows/columnGap/rowGap props,
child colStart/rowStart/colSpan/rowSpan.

Tailwind format: grid grid-cols-N grid-rows-N gap-x-* gap-y-*,
mixed tracks use arbitrary values (grid-cols-[200px_1fr_auto]),
child col-start-*/row-start-*/col-span-*/row-span-*.

Both formats: padding emitted for grid frames (shared with flex),
flex-only props (justify/items/wrap/gap) scoped to isFlex.

* Remove duplicate gridTrackToTw, use shared formatTrack

Also drop unused GridTrackSizing import from LayoutSection.vue.

* Fix grid icon: use layout-grid instead of grid-2x2

unplugin-icons can't resolve icon names with digits after hyphens
(grid-2x2 → grid2x2 which doesn't exist).

* Polish layout UI and fix auto-layout behavior

- Replace text labels with compact icons: ↔/↕ for gap, ☐ for uniform padding, T/R/B/L for per-side padding
- Direction-aware gap icon: ↕ for vertical, ↔ for horizontal
- Pin +/− padding toggle button right of gap input
- Fix alignment grid axes for vertical layout (transpose primary/counter)
- Fix grid switch: set FIXED sizing, compute frame size from children
- Remove hardcoded white fill from Shift+A wrap
- Auto-detect horizontal vs vertical from selection bounds

* Add flex-to-grid switch integration test

Verify HUG frame expands and children are placed in 2x2 grid
when switching from vertical flex to grid layout.

* Update changelog for grid layout
This commit is contained in:
Danila Poyarkov 2026-03-08 18:23:53 +03:00 committed by GitHub
parent 52b9ebc5c2
commit 45df90c5c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1115 additions and 116 deletions

View file

@ -1,5 +1,20 @@
# Changelog
## Unreleased
### Features
- CSS Grid layout mode — select a frame, click the grid icon in the auto layout toolbar to switch from flex to grid. Configure column/row tracks (fr, fixed px, auto), column and row gaps, and per-side padding. Powered by a [Yoga fork](https://github.com/open-pencil/yoga/tree/grid) with cherry-picked CSS Grid PRs from upstream
- JSX and Tailwind CSS export for grid layouts — `grid grid-cols-N`, `gap-x-*`/`gap-y-*`, child `col-start-*`/`row-start-*`/`col-span-*`/`row-span-*`
### Improvements
- Compact layout controls — icon-based gap (↔/↕) and padding (T/R/B/L) inputs instead of text labels
- Auto-detect horizontal vs vertical direction when wrapping in auto layout (Shift+A)
- Fix alignment grid for vertical layouts — visual positions now match spatial axes
- Fix grid switch from HUG-sized frames — frame expands to fit children
- Remove unwanted white fill when wrapping in auto layout
## 0.8.0 — 2026-03-07
### Features

View file

@ -40,7 +40,7 @@
"y-indexeddb": "^9.0.12",
"y-protocols": "^1.0.7",
"yjs": "^13.6.29",
"yoga-layout": "^3.2.1",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2",
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
@ -72,7 +72,7 @@
},
"packages/acp": {
"name": "@open-pencil/acp",
"version": "0.7.0",
"version": "0.8.0",
"bin": {
"openpencil-acp": "./dist/index.js",
},
@ -87,7 +87,7 @@
},
"packages/cli": {
"name": "@open-pencil/cli",
"version": "0.7.0",
"version": "0.8.0",
"bin": {
"openpencil": "./src/index.ts",
},
@ -103,14 +103,14 @@
},
"packages/core": {
"name": "@open-pencil/core",
"version": "0.7.0",
"version": "0.8.0",
"dependencies": {
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"diff": "^8.0.3",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"yoga-layout": "^3.2.1",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2",
},
"devDependencies": {
"@types/culori": "^4.0.1",
@ -127,7 +127,7 @@
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.7.0",
"version": "0.8.0",
"bin": {
"openpencil-mcp": "./dist/index.js",
"openpencil-mcp-http": "./dist/http.js",
@ -2402,7 +2402,7 @@
"yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"yoga-layout": ["@open-pencil/yoga-layout@3.3.0-grid.2", "", {}, "sha512-BFaOZ2iXD/v3Y805j5PN/biIKCFEOB/U3Wg8d7gG7FR4NK/hrp199/vMbNr7/BOfH/Xuu6JariSFNKll9zQSBw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],

10
components.d.ts vendored
View file

@ -35,6 +35,7 @@ declare module 'vue' {
IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default']
IconLucideAlignHorizontalDistributeCenter: typeof import('~icons/lucide/align-horizontal-distribute-center')['default']
IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default']
IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default']
IconLucideAlignHorizontalJustifyStart: typeof import('~icons/lucide/align-horizontal-justify-start')['default']
@ -46,6 +47,10 @@ declare module 'vue' {
IconLucideArrowDown: typeof import('~icons/lucide/arrow-down')['default']
IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default']
IconLucideBaseline: typeof import('~icons/lucide/baseline')['default']
IconLucideBetweenHorizontalEnd: typeof import('~icons/lucide/between-horizontal-end')['default']
IconLucideBetweenHorizontalStart: typeof import('~icons/lucide/between-horizontal-start')['default']
IconLucideBetweenVerticalEnd: typeof import('~icons/lucide/between-vertical-end')['default']
IconLucideBetweenVerticalStart: typeof import('~icons/lucide/between-vertical-start')['default']
IconLucideBlend: typeof import('~icons/lucide/blend')['default']
IconLucideBold: typeof import('~icons/lucide/bold')['default']
IconLucideBot: typeof import('~icons/lucide/bot')['default']
@ -64,10 +69,14 @@ declare module 'vue' {
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid')['default']
IconLucideLink: typeof import('~icons/lucide/link')['default']
IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle')['default']
IconLucideMenu: typeof import('~icons/lucide/menu')['default']
IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideMoveHorizontal: typeof import('~icons/lucide/move-horizontal')['default']
IconLucideMoveVertical: typeof import('~icons/lucide/move-vertical')['default']
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']
IconLucideRedo2: typeof import('~icons/lucide/redo2')['default']
@ -79,6 +88,7 @@ declare module 'vue' {
IconLucideShare2: typeof import('~icons/lucide/share2')['default']
IconLucideSidebar: typeof import('~icons/lucide/sidebar')['default']
IconLucideSlidersHorizontal: typeof import('~icons/lucide/sliders-horizontal')['default']
IconLucideSpace: typeof import('~icons/lucide/space')['default']
IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default']
IconLucideSquare: typeof import('~icons/lucide/square')['default']
IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default']

View file

@ -65,7 +65,7 @@
"y-indexeddb": "^9.0.12",
"y-protocols": "^1.0.7",
"yjs": "^13.6.29",
"yoga-layout": "^3.2.1"
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2"
},
"devDependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -41,7 +41,7 @@
"diff": "^8.0.3",
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"yoga-layout": "^3.2.1"
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2"
},
"devDependencies": {
"@types/culori": "^4.0.1",

View file

@ -24,6 +24,9 @@ export {
type LayoutAlign,
type LayoutCounterAlign,
type LayoutWrap,
type GridTrack,
type GridTrackSizing,
type GridPosition,
type ConstraintType,
type TextAutoResize,
type TextAlignVertical,

View file

@ -279,7 +279,7 @@ export function sceneNodeToKiwi(
if (node.clipsContent) nc.clipsContent = true
}
if (node.layoutMode !== 'NONE') {
if (node.layoutMode !== 'NONE' && node.layoutMode !== 'GRID') {
nc.stackMode = node.layoutMode
nc.stackSpacing = node.itemSpacing
nc.stackVerticalPadding = node.paddingTop

View file

@ -3,6 +3,7 @@ import Yoga, {
Direction,
Display,
FlexDirection,
GridTrackType,
Gutter,
Justify,
Edge,
@ -10,7 +11,7 @@ import Yoga, {
type Node as YogaNode
} from 'yoga-layout'
import type { SceneGraph, SceneNode } from './scene-graph'
import type { GridTrack, SceneGraph, SceneNode } from './scene-graph'
export type TextMeasurer = (node: SceneNode) => { width: number; height: number } | null
@ -24,7 +25,8 @@ export function computeLayout(graph: SceneGraph, frameId: string): void {
const frame = graph.getNode(frameId)
if (!frame || frame.layoutMode === 'NONE') return
const yogaRoot = buildYogaTree(graph, frame)
const yogaRoot =
frame.layoutMode === 'GRID' ? buildGridTree(graph, frame) : buildYogaTree(graph, frame)
yogaRoot.calculateLayout(undefined, undefined, Direction.LTR)
applyYogaLayout(graph, frame, yogaRoot)
freeYogaTree(yogaRoot)
@ -49,6 +51,77 @@ function computeLayoutsBottomUp(graph: SceneGraph, nodeId: string, visited: Set<
}
}
// --- Grid layout ---
function mapGridTrack(track: GridTrack): { type: GridTrackType; value: number } {
switch (track.sizing) {
case 'FR':
return { type: GridTrackType.Fr, value: track.value }
case 'FIXED':
return { type: GridTrackType.Points, value: track.value }
case 'AUTO':
return { type: GridTrackType.Auto, value: 0 }
}
}
function configureAsGrid(yogaNode: YogaNode, node: SceneNode): void {
yogaNode.setDisplay(Display.Grid)
yogaNode.setWidth(node.width)
yogaNode.setHeight(node.height)
if (node.gridTemplateColumns.length > 0) {
yogaNode.setGridTemplateColumns(node.gridTemplateColumns.map(mapGridTrack))
}
if (node.gridTemplateRows.length > 0) {
yogaNode.setGridTemplateRows(node.gridTemplateRows.map(mapGridTrack))
}
yogaNode.setGap(Gutter.Column, node.gridColumnGap)
yogaNode.setGap(Gutter.Row, node.gridRowGap)
yogaNode.setPadding(Edge.Top, node.paddingTop)
yogaNode.setPadding(Edge.Right, node.paddingRight)
yogaNode.setPadding(Edge.Bottom, node.paddingBottom)
yogaNode.setPadding(Edge.Left, node.paddingLeft)
}
function createGridChildNode(child: SceneNode): YogaNode {
const yogaChild = Yoga.Node.create()
if (!child.visible) {
yogaChild.setDisplay(Display.None)
} else {
const pos = child.gridPosition
if (pos) {
yogaChild.setGridColumnStart(pos.column)
yogaChild.setGridColumnEndSpan(pos.columnSpan)
yogaChild.setGridRowStart(pos.row)
yogaChild.setGridRowEndSpan(pos.rowSpan)
}
if (child.layoutGrow > 0 || child.layoutAlignSelf === 'STRETCH') {
yogaChild.setFlexGrow(1)
} else {
yogaChild.setWidth(child.width)
yogaChild.setHeight(child.height)
}
}
return yogaChild
}
function buildGridTree(graph: SceneGraph, frame: SceneNode): YogaNode {
const root = Yoga.Node.create()
configureAsGrid(root, frame)
const children = graph.getChildren(frame.id)
for (const child of children) {
if (child.layoutPositioning === 'ABSOLUTE') continue
root.insertChild(createGridChildNode(child), root.getChildCount())
}
return root
}
// --- Flex layout ---
function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode {
const root = Yoga.Node.create()
@ -92,6 +165,8 @@ function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode {
if (!child.visible) {
yogaChild.setDisplay(Display.None)
} else if (child.layoutMode === 'GRID') {
configureChildAsGrid(yogaChild, child, frame, graph)
} else if (child.layoutMode !== 'NONE') {
configureChildAsAutoLayout(yogaChild, child, frame, graph)
} else {
@ -104,6 +179,28 @@ function buildYogaTree(graph: SceneGraph, frame: SceneNode): YogaNode {
return root
}
function configureChildAsGrid(
yogaChild: YogaNode,
child: SceneNode,
_parent: SceneNode,
graph: SceneGraph
): void {
configureAsGrid(yogaChild, child)
if (child.layoutGrow > 0) {
yogaChild.setFlexGrow(child.layoutGrow)
}
if (child.layoutAlignSelf === 'STRETCH') {
yogaChild.setAlignSelf(Align.Stretch)
}
const grandchildren = graph.getChildren(child.id)
for (const gc of grandchildren) {
if (gc.layoutPositioning === 'ABSOLUTE') continue
yogaChild.insertChild(createGridChildNode(gc), yogaChild.getChildCount())
}
}
function configureChildAsAutoLayout(
yogaChild: YogaNode,
child: SceneNode,
@ -168,7 +265,8 @@ function configureChildAsLeaf(yogaChild: YogaNode, child: SceneNode, parent: Sce
const isRow = parent.layoutMode === 'HORIZONTAL'
const stretchCross = child.layoutAlignSelf === 'STRETCH' || parent.counterAxisAlign === 'STRETCH'
const measured = child.type === 'TEXT' && child.textAutoResize === 'WIDTH_AND_HEIGHT'
const measured =
child.type === 'TEXT' && child.textAutoResize === 'WIDTH_AND_HEIGHT'
? measureTextSize(child)
: null
const w = measured ? measured.width : child.width
@ -228,6 +326,7 @@ function setSizing(
}
function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void {
if (frame.layoutMode !== 'GRID') {
if (frame.primaryAxisSizing === 'HUG' || frame.counterAxisSizing === 'HUG') {
const computedW = yogaNode.getComputedWidth()
const computedH = yogaNode.getComputedHeight()
@ -244,6 +343,7 @@ function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode
graph.updateNode(frame.id, updates)
}
}
const children = graph.getChildren(frame.id)
let yogaIndex = 0
@ -271,7 +371,7 @@ function freeYogaTree(node: YogaNode): void {
for (let i = node.getChildCount() - 1; i >= 0; i--) {
freeYogaTree(node.getChild(i))
}
node.free()
if ('free' in node) (node as { free(): void }).free()
}
function mapJustify(align: string): Justify {

View file

@ -9,7 +9,16 @@ import {
opacityToTw
} from './tailwind'
import type { SceneGraph, SceneNode, Fill, Stroke, Effect, NodeType, Color } from '../scene-graph'
import type {
SceneGraph,
SceneNode,
Fill,
Stroke,
Effect,
NodeType,
Color,
GridTrack
} from '../scene-graph'
export type JSXFormat = 'openpencil' | 'tailwind'
@ -115,7 +124,10 @@ function getNodeContext(node: SceneNode, graph: SceneGraph) {
const parent = node.parentId ? graph.getNode(node.parentId) : null
return {
isAutoLayout: node.layoutMode !== 'NONE',
parentIsAutoLayout: parent ? parent.layoutMode !== 'NONE' : false
isGrid: node.layoutMode === 'GRID',
isFlex: node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL',
parentIsAutoLayout: parent ? parent.layoutMode !== 'NONE' : false,
parentIsGrid: parent ? parent.layoutMode === 'GRID' : false
}
}
@ -160,17 +172,40 @@ function collectCornerRadii(node: SceneNode): CornerRadii | null {
return { tl: r, tr: r, br: r, bl: r }
}
function formatTrack(t: GridTrack): string {
if (t.sizing === 'FR') return `${t.value}fr`
if (t.sizing === 'FIXED') return `${t.value}px`
return 'auto'
}
function formatTracks(tracks: GridTrack[]): string {
return tracks.map(formatTrack).join(' ')
}
// --- OpenPencil format ---
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
const props: [string, unknown][] = []
const { isAutoLayout, parentIsAutoLayout } = getNodeContext(node, graph)
const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext(
node,
graph
)
if (node.name && node.name !== node.type) {
props.push(['name', node.name])
}
if (isAutoLayout) {
if (isGrid) {
props.push(['grid', true])
if (node.gridTemplateColumns.length > 0)
props.push(['columns', formatTracks(node.gridTemplateColumns)])
if (node.gridTemplateRows.length > 0)
props.push(['rows', formatTracks(node.gridTemplateRows)])
if (node.width > 0) props.push(['w', node.width])
if (node.height > 0) props.push(['h', node.height])
if (node.gridColumnGap > 0) props.push(['columnGap', node.gridColumnGap])
if (node.gridRowGap > 0) props.push(['rowGap', node.gridRowGap])
} else if (isFlex) {
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
@ -190,14 +225,23 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
}
if (parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow])
if (isAutoLayout && node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
if (isAutoLayout && node.layoutWrap === 'WRAP') {
if (parentIsGrid && node.gridPosition) {
const pos = node.gridPosition
if (pos.column > 0) props.push(['colStart', pos.column])
if (pos.row > 0) props.push(['rowStart', pos.row])
if (pos.columnSpan > 1) props.push(['colSpan', pos.columnSpan])
if (pos.rowSpan > 1) props.push(['rowSpan', pos.rowSpan])
}
if (isFlex && node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
if (isFlex && node.layoutWrap === 'WRAP') {
props.push(['wrap', true])
if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing])
}
if (isAutoLayout) {
if (isFlex) {
if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center'])
else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end'])
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between'])
@ -205,7 +249,9 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
}
if (isAutoLayout) {
const pad = collectPadding(node)
if (pad) {
props.push(
@ -304,11 +350,30 @@ function twRounded(prefix: string, px: number): string {
return r ? `${prefix}-${r}` : prefix
}
function gridTemplateTw(tracks: GridTrack[]): string {
const allEqual1Fr = tracks.every((t) => t.sizing === 'FR' && t.value === 1)
if (allEqual1Fr) return String(tracks.length)
return `[${tracks.map(formatTrack).join('_')}]`
}
function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
const classes: string[] = []
const { isAutoLayout, parentIsAutoLayout } = getNodeContext(node, graph)
const { isAutoLayout, isGrid, isFlex, parentIsAutoLayout, parentIsGrid } = getNodeContext(
node,
graph
)
if (isAutoLayout) {
if (isGrid) {
classes.push('grid')
if (node.gridTemplateColumns.length > 0)
classes.push(`grid-cols-${gridTemplateTw(node.gridTemplateColumns)}`)
if (node.gridTemplateRows.length > 0)
classes.push(`grid-rows-${gridTemplateTw(node.gridTemplateRows)}`)
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
if (node.gridColumnGap > 0) classes.push(`gap-x-${pxToSpacing(node.gridColumnGap)}`)
if (node.gridRowGap > 0) classes.push(`gap-y-${pxToSpacing(node.gridRowGap)}`)
} else if (isFlex) {
classes.push('flex')
if (node.layoutMode === 'VERTICAL') classes.push('flex-col')
@ -330,14 +395,23 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
}
if (parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow')
if (isAutoLayout && node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
if (isAutoLayout && node.layoutWrap === 'WRAP') {
if (parentIsGrid && node.gridPosition) {
const pos = node.gridPosition
if (pos.column > 0) classes.push(`col-start-${pos.column}`)
if (pos.row > 0) classes.push(`row-start-${pos.row}`)
if (pos.columnSpan > 1) classes.push(`col-span-${pos.columnSpan}`)
if (pos.rowSpan > 1) classes.push(`row-span-${pos.rowSpan}`)
}
if (isFlex && node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
if (isFlex && node.layoutWrap === 'WRAP') {
classes.push('flex-wrap')
if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`)
}
if (isAutoLayout) {
if (isFlex) {
if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center')
else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end')
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between')
@ -345,7 +419,9 @@ function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
if (node.counterAxisAlign === 'CENTER') classes.push('items-center')
else if (node.counterAxisAlign === 'MAX') classes.push('items-end')
else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch')
}
if (isAutoLayout) {
const pad = collectPadding(node)
if (pad) {
classes.push(

View file

@ -158,8 +158,22 @@ export interface ArcData {
innerRadius: number
}
export type LayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL'
export type LayoutMode = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'GRID'
export type LayoutSizing = 'FIXED' | 'HUG' | 'FILL'
export type GridTrackSizing = 'FIXED' | 'FR' | 'AUTO'
export interface GridTrack {
sizing: GridTrackSizing
value: number
}
export interface GridPosition {
column: number
row: number
columnSpan: number
rowSpan: number
}
export type LayoutAlign = 'MIN' | 'CENTER' | 'MAX' | 'SPACE_BETWEEN'
export type LayoutCounterAlign = 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' | 'BASELINE'
export type LayoutWrap = 'NO_WRAP' | 'WRAP'
@ -257,6 +271,12 @@ export interface SceneNode {
isMask: boolean
maskType: MaskType
gridTemplateColumns: GridTrack[]
gridTemplateRows: GridTrack[]
gridColumnGap: number
gridRowGap: number
gridPosition: GridPosition | null
counterAxisAlignContent: 'AUTO' | 'SPACE_BETWEEN'
itemReverseZIndex: boolean
strokesIncludedInLayout: boolean
@ -390,6 +410,11 @@ function createDefaultNode(type: NodeType, overrides: Partial<SceneNode> = {}):
maxHeight: null,
isMask: false,
maskType: 'ALPHA',
gridTemplateColumns: [],
gridTemplateRows: [],
gridColumnGap: 0,
gridRowGap: 0,
gridPosition: null,
counterAxisAlignContent: 'AUTO',
itemReverseZIndex: false,
strokesIncludedInLayout: false,
@ -922,6 +947,11 @@ export class SceneGraph {
'paddingRight',
'paddingBottom',
'paddingLeft',
'gridTemplateColumns',
'gridTemplateRows',
'gridColumnGap',
'gridRowGap',
'gridPosition',
'clipsContent',
'independentStrokeWeights',
'borderTopWeight',

View file

@ -5,7 +5,13 @@ import AppSelect from '@/components/AppSelect.vue'
import ScrubInput from '@/components/ScrubInput.vue'
import { useNodeProps } from '@/composables/use-node-props'
import type { SceneNode, LayoutSizing, LayoutAlign, LayoutCounterAlign } from '@open-pencil/core'
import type {
SceneNode,
LayoutSizing,
LayoutAlign,
LayoutCounterAlign,
GridTrack
} from '@open-pencil/core'
const { store, node, updateProp, commitProp } = useNodeProps()
@ -17,8 +23,13 @@ const isInAutoLayout = computed(() => {
return parent ? parent.layoutMode !== 'NONE' : false
})
const isGrid = computed(() => node.value.layoutMode === 'GRID')
const isFlex = computed(
() => node.value.layoutMode === 'HORIZONTAL' || node.value.layoutMode === 'VERTICAL'
)
const widthSizing = computed<LayoutSizing>(() => {
if (node.value.layoutMode !== 'NONE') {
if (isFlex.value) {
return node.value.layoutMode === 'HORIZONTAL'
? node.value.primaryAxisSizing
: node.value.counterAxisSizing
@ -28,7 +39,7 @@ const widthSizing = computed<LayoutSizing>(() => {
})
const heightSizing = computed<LayoutSizing>(() => {
if (node.value.layoutMode !== 'NONE') {
if (isFlex.value) {
return node.value.layoutMode === 'VERTICAL'
? node.value.primaryAxisSizing
: node.value.counterAxisSizing
@ -38,7 +49,7 @@ const heightSizing = computed<LayoutSizing>(() => {
})
function setWidthSizing(sizing: LayoutSizing) {
if (node.value.layoutMode !== 'NONE') {
if (isFlex.value) {
if (node.value.layoutMode === 'HORIZONTAL') updateProp('primaryAxisSizing', sizing)
else updateProp('counterAxisSizing', sizing)
} else if (isInAutoLayout.value) {
@ -47,7 +58,7 @@ function setWidthSizing(sizing: LayoutSizing) {
}
function setHeightSizing(sizing: LayoutSizing) {
if (node.value.layoutMode !== 'NONE') {
if (isFlex.value) {
if (node.value.layoutMode === 'VERTICAL') updateProp('primaryAxisSizing', sizing)
else updateProp('counterAxisSizing', sizing)
} else if (isInAutoLayout.value) {
@ -87,23 +98,25 @@ function commitUniformPadding(_value: number, previous: number) {
const widthSizingOptions = computed(() => {
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: `Fixed width (${Math.round(node.value.width)})` }
{ value: 'FIXED', label: 'Fixed' }
]
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
if (isFlex.value) options.push({ value: 'HUG', label: 'Hug' })
if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: 'Fill' })
return options
})
const heightSizingOptions = computed(() => {
const options: { value: LayoutSizing; label: string }[] = [
{ value: 'FIXED', label: `Fixed height (${Math.round(node.value.height)})` }
{ value: 'FIXED', label: 'Fixed' }
]
if (node.value.layoutMode !== 'NONE') options.push({ value: 'HUG', label: 'Hug contents' })
if (isInAutoLayout.value) options.push({ value: 'FILL', label: 'Fill container' })
if (isFlex.value) options.push({ value: 'HUG', label: 'Hug' })
if (isInAutoLayout.value || isFlex.value) options.push({ value: 'FILL', label: 'Fill' })
return options
})
const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> = [
type AlignCell = { primary: LayoutAlign; counter: LayoutCounterAlign }
const ALIGN_HORIZONTAL: AlignCell[] = [
{ primary: 'MIN', counter: 'MIN' },
{ primary: 'CENTER', counter: 'MIN' },
{ primary: 'MAX', counter: 'MIN' },
@ -115,6 +128,22 @@ const ALIGN_GRID: Array<{ primary: LayoutAlign; counter: LayoutCounterAlign }> =
{ primary: 'MAX', counter: 'MAX' }
]
const ALIGN_VERTICAL: AlignCell[] = [
{ primary: 'MIN', counter: 'MIN' },
{ primary: 'MIN', counter: 'CENTER' },
{ primary: 'MIN', counter: 'MAX' },
{ primary: 'CENTER', counter: 'MIN' },
{ primary: 'CENTER', counter: 'CENTER' },
{ primary: 'CENTER', counter: 'MAX' },
{ primary: 'MAX', counter: 'MIN' },
{ primary: 'MAX', counter: 'CENTER' },
{ primary: 'MAX', counter: 'MAX' }
]
const alignGrid = computed(() =>
node.value.layoutMode === 'VERTICAL' ? ALIGN_VERTICAL : ALIGN_HORIZONTAL
)
function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
store.updateNodeWithUndo(
node.value.id,
@ -122,6 +151,40 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
'Change alignment'
)
}
// --- Grid helpers ---
const TRACK_SIZING_OPTIONS = [
{ value: 'FR' as const, label: 'Fill (fr)' },
{ value: 'FIXED' as const, label: 'Fixed (px)' },
{ value: 'AUTO' as const, label: 'Auto' }
]
function updateGridTrack(
prop: 'gridTemplateColumns' | 'gridTemplateRows',
index: number,
updates: Partial<GridTrack>
) {
const tracks = [...node.value[prop]]
tracks[index] = { ...tracks[index], ...updates }
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Change grid track')
}
function addTrack(prop: 'gridTemplateColumns' | 'gridTemplateRows') {
const tracks = [...node.value[prop], { sizing: 'FR' as const, value: 1 }]
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Add grid track')
}
function removeTrack(prop: 'gridTemplateColumns' | 'gridTemplateRows', index: number) {
const tracks = node.value[prop].filter((_: GridTrack, i: number) => i !== index)
store.updateNodeWithUndo(node.value.id, { [prop]: tracks }, 'Remove grid track')
}
function trackLabel(track: GridTrack): string {
if (track.sizing === 'FR') return `${track.value}fr`
if (track.sizing === 'FIXED') return `${track.value}px`
return 'Auto'
}
</script>
<template>
@ -137,7 +200,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
@commit="(v: number, p: number) => commitProp('width', v, p)"
/>
<AppSelect
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
v-if="isFlex || isInAutoLayout"
:model-value="widthSizing"
:options="widthSizingOptions"
@update:model-value="setWidthSizing"
@ -153,7 +216,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
@commit="(v: number, p: number) => commitProp('height', v, p)"
/>
<AppSelect
v-if="node.layoutMode !== 'NONE' || isInAutoLayout"
v-if="isFlex || isInAutoLayout"
:model-value="heightSizing"
:options="heightSizingOptions"
@update:model-value="setHeightSizing"
@ -186,6 +249,7 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</div>
<template v-if="node.layoutMode !== 'NONE'">
<!-- Flow direction buttons -->
<div class="mt-1.5 flex gap-0.5">
<button
data-test-id="layout-direction-horizontal"
@ -214,6 +278,20 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
<icon-lucide-arrow-down class="size-3.5" />
</button>
<button
data-test-id="layout-direction-grid"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
isGrid
? 'border-accent bg-accent/10 text-accent'
: 'border-border text-muted hover:bg-hover hover:text-surface'
"
title="Grid layout"
@click="store.setLayoutMode(node.id, 'GRID')"
>
<icon-lucide-layout-grid class="size-3.5" />
</button>
<button
v-if="isFlex"
data-test-id="layout-direction-wrap"
class="flex cursor-pointer items-center justify-center rounded border px-2 py-1"
:class="
@ -228,75 +306,201 @@ function setAlignment(primary: LayoutAlign, counter: LayoutCounterAlign) {
</button>
</div>
<!-- Grid-specific controls -->
<template v-if="isGrid">
<!-- Column tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Columns</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add column"
@click="addTrack('gridTemplateColumns')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div
v-for="(col, i) in node.gridTemplateColumns"
:key="i"
class="flex items-center gap-1"
>
<ScrubInput
v-if="col.sizing !== 'AUTO'"
class="flex-1"
:icon="`C${i + 1}`"
:model-value="col.value"
:min="col.sizing === 'FR' ? 1 : 0"
:step="col.sizing === 'FR' ? 1 : 1"
:suffix="col.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateColumns', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(col) }}</span>
<AppSelect
:model-value="col.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateColumns', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateColumns.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove column"
@click="removeTrack('gridTemplateColumns', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Row tracks -->
<div class="mt-2">
<div class="mb-1 flex items-center justify-between">
<label class="text-[11px] text-muted">Rows</label>
<button
class="cursor-pointer rounded border-none bg-transparent px-1 text-xs leading-none text-muted hover:bg-hover hover:text-surface"
title="Add row"
@click="addTrack('gridTemplateRows')"
>
+
</button>
</div>
<div class="flex flex-col gap-1">
<div v-for="(row, i) in node.gridTemplateRows" :key="i" class="flex items-center gap-1">
<ScrubInput
v-if="row.sizing !== 'AUTO'"
class="flex-1"
:icon="`R${i + 1}`"
:model-value="row.value"
:min="row.sizing === 'FR' ? 1 : 0"
:step="row.sizing === 'FR' ? 1 : 1"
:suffix="row.sizing === 'FR' ? 'fr' : 'px'"
@update:model-value="updateGridTrack('gridTemplateRows', i, { value: $event })"
/>
<span v-else class="flex-1 px-1 text-xs text-muted">{{ trackLabel(row) }}</span>
<AppSelect
:model-value="row.sizing"
:options="TRACK_SIZING_OPTIONS"
@update:model-value="
updateGridTrack('gridTemplateRows', i, {
sizing: $event,
value: $event === 'FR' ? 1 : $event === 'FIXED' ? 100 : 0
})
"
/>
<button
v-if="node.gridTemplateRows.length > 1"
class="cursor-pointer rounded border-none bg-transparent px-0.5 text-xs text-muted hover:text-surface"
title="Remove row"
@click="removeTrack('gridTemplateRows', i)"
>
×
</button>
</div>
</div>
</div>
<!-- Grid gaps -->
<div class="mt-2 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="↔"
:model-value="Math.round(node.gridColumnGap)"
:min="0"
@update:model-value="updateProp('gridColumnGap', $event)"
@commit="(v: number, p: number) => commitProp('gridColumnGap', v, p)"
/>
<ScrubInput
icon="↕"
:model-value="Math.round(node.gridRowGap)"
:min="0"
@update:model-value="updateProp('gridRowGap', $event)"
@commit="(v: number, p: number) => commitProp('gridRowGap', v, p)"
/>
</div>
</template>
<!-- Flex-specific controls -->
<template v-if="isFlex">
<div class="mt-2 flex items-center gap-1.5">
<ScrubInput
data-test-id="layout-gap-input"
icon="Gap"
class="flex-1"
:icon="node.layoutMode === 'VERTICAL' ? '↕' : '↔'"
:model-value="Math.round(node.itemSpacing)"
:min="0"
@update:model-value="updateProp('itemSpacing', $event)"
@commit="(v: number, p: number) => commitProp('itemSpacing', v, p)"
/>
<button
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded border border-border bg-transparent text-muted hover:bg-hover hover:text-surface"
:title="showIndividualPadding || !hasUniformPadding() ? 'Uniform padding' : 'Per-side padding'"
@click="showIndividualPadding = !showIndividualPadding"
>
<icon-lucide-minus v-if="showIndividualPadding || !hasUniformPadding()" class="size-3" />
<icon-lucide-plus v-else class="size-3" />
</button>
</div>
<template v-if="hasUniformPadding() && !showIndividualPadding">
<div v-if="!showIndividualPadding && hasUniformPadding()" class="mt-1.5">
<ScrubInput
data-test-id="layout-uniform-padding-input"
icon="Pad"
icon=""
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="setUniformPadding"
@commit="commitUniformPadding"
/>
</template>
<button
class="rounded border border-border bg-transparent px-2 py-1 text-left text-xs text-muted hover:bg-hover hover:text-surface"
@click="showIndividualPadding = !showIndividualPadding"
>
{{ showIndividualPadding ? 'Uniform padding' : 'Per-side padding' }}
</button>
</div>
</template>
<div
v-if="showIndividualPadding || !hasUniformPadding()"
class="mt-1.5 grid grid-cols-2 gap-1.5"
>
<!-- Per-side padding -->
<template v-if="isGrid || (isFlex && (showIndividualPadding || !hasUniformPadding()))">
<div class="mt-1.5 grid grid-cols-2 gap-1.5">
<ScrubInput
icon="Top"
icon="T"
:model-value="Math.round(node.paddingTop)"
:min="0"
@update:model-value="updateProp('paddingTop', $event)"
@commit="(v: number, p: number) => commitProp('paddingTop', v, p)"
/>
<ScrubInput
icon="Right"
icon="R"
:model-value="Math.round(node.paddingRight)"
:min="0"
@update:model-value="updateProp('paddingRight', $event)"
@commit="(v: number, p: number) => commitProp('paddingRight', v, p)"
/>
<ScrubInput
icon="Bottom"
icon="B"
:model-value="Math.round(node.paddingBottom)"
:min="0"
@update:model-value="updateProp('paddingBottom', $event)"
@commit="(v: number, p: number) => commitProp('paddingBottom', v, p)"
/>
<ScrubInput
icon="Left"
icon="L"
:model-value="Math.round(node.paddingLeft)"
:min="0"
@update:model-value="updateProp('paddingLeft', $event)"
@commit="(v: number, p: number) => commitProp('paddingLeft', v, p)"
/>
</div>
</template>
<div class="mt-2">
<!-- Alignment (flex only) -->
<div v-if="isFlex" class="mt-2">
<label class="mb-1 block text-[11px] text-muted">Alignment</label>
<div data-test-id="layout-alignment-grid" class="grid grid-cols-3 gap-1">
<div data-test-id="layout-alignment-grid" class="grid w-fit grid-cols-3 gap-0.5">
<button
v-for="cell in ALIGN_GRID"
v-for="cell in alignGrid"
:key="`${cell.primary}-${cell.counter}`"
class="flex aspect-square cursor-pointer items-center justify-center rounded border text-[11px]"
class="flex size-6 cursor-pointer items-center justify-center rounded border text-[11px]"
:class="
node.primaryAxisAlign === cell.primary && node.counterAxisAlign === cell.counter
? 'border-accent bg-accent/10 text-accent'

View file

@ -986,12 +986,42 @@ export function createEditorStore() {
counterAxisSizing: node.counterAxisSizing,
primaryAxisAlign: node.primaryAxisAlign,
counterAxisAlign: node.counterAxisAlign,
gridTemplateColumns: node.gridTemplateColumns,
gridTemplateRows: node.gridTemplateRows,
gridColumnGap: node.gridColumnGap,
gridRowGap: node.gridRowGap,
width: node.width,
height: node.height
}
const updates: Partial<SceneNode> = { layoutMode: mode }
if (mode !== 'NONE' && node.layoutMode === 'NONE') {
if (mode === 'GRID' && node.layoutMode !== 'GRID') {
const children = graph.getChildren(id)
const cols = Math.max(2, Math.ceil(Math.sqrt(children.length)))
const rows = Math.max(1, Math.ceil(children.length / cols))
updates.gridTemplateColumns = Array.from({ length: cols }, () => ({
sizing: 'FR' as const,
value: 1
}))
updates.gridTemplateRows = Array.from({ length: rows }, () => ({
sizing: 'FR' as const,
value: 1
}))
updates.gridColumnGap = 0
updates.gridRowGap = 0
updates.primaryAxisSizing = 'FIXED'
updates.counterAxisSizing = 'FIXED'
if (node.primaryAxisSizing === 'HUG' || node.counterAxisSizing === 'HUG') {
const maxChildW = Math.max(...children.map((c) => c.width), 100)
const maxChildH = Math.max(...children.map((c) => c.height), 100)
updates.width = maxChildW * cols
updates.height = maxChildH * rows
}
updates.paddingTop = 0
updates.paddingRight = 0
updates.paddingBottom = 0
updates.paddingLeft = 0
} else if (mode !== 'NONE' && node.layoutMode === 'NONE') {
updates.itemSpacing = 0
updates.paddingTop = 0
updates.paddingRight = 0
@ -1055,18 +1085,21 @@ export function createEditorStore() {
const parentAbs = isTopLevel(parentId) ? { x: 0, y: 0 } : graph.getAbsolutePosition(parentId)
const direction: LayoutMode =
nodes.length <= 1 ? 'VERTICAL' : maxX - minX >= maxY - minY ? 'HORIZONTAL' : 'VERTICAL'
const frame = graph.createNode('FRAME', parentId, {
name: 'Frame',
x: minX - parentAbs.x,
y: minY - parentAbs.y,
width: maxX - minX,
height: maxY - minY,
layoutMode: 'VERTICAL',
layoutMode: direction,
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
primaryAxisAlign: 'MIN',
counterAxisAlign: 'MIN',
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
fills: []
})
const frameId = frame.id

View file

@ -270,6 +270,91 @@ describe('sceneNodeToJSX', () => {
expect(jsx).toContain('shadow="0 4 8')
expect(jsx).toContain('blur={4}')
})
test('grid layout frame', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Grid',
width: 400,
height: 300,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridTemplateRows: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridColumnGap: 16,
gridRowGap: 8,
paddingTop: 12,
paddingRight: 12,
paddingBottom: 12,
paddingLeft: 12
})
const jsx = sceneNodeToJSX(frame.id, graph)
expect(jsx).toContain('grid')
expect(jsx).toContain('columns="1fr 1fr 1fr"')
expect(jsx).toContain('rows="1fr 1fr"')
expect(jsx).toContain('columnGap={16}')
expect(jsx).toContain('rowGap={8}')
expect(jsx).toContain('p={12}')
expect(jsx).toContain('w={400}')
expect(jsx).not.toContain('flex')
})
test('grid with mixed track sizes', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 600,
height: 400,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FIXED', value: 200 },
{ sizing: 'FR', value: 1 },
{ sizing: 'AUTO', value: 0 }
],
gridTemplateRows: [],
gridColumnGap: 0,
gridRowGap: 0
})
const jsx = sceneNodeToJSX(frame.id, graph)
expect(jsx).toContain('columns="200px 1fr auto"')
expect(jsx).not.toContain('rows=')
expect(jsx).not.toContain('columnGap')
})
test('child grid position', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 400,
height: 300,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridTemplateRows: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridColumnGap: 0,
gridRowGap: 0
})
const child = graph.createNode('RECTANGLE', frame.id, {
name: 'Span',
width: 100,
height: 50,
gridPosition: { column: 1, row: 2, columnSpan: 2, rowSpan: 1 }
})
const jsx = sceneNodeToJSX(child.id, graph)
expect(jsx).toContain('colStart={1}')
expect(jsx).toContain('rowStart={2}')
expect(jsx).toContain('colSpan={2}')
expect(jsx).not.toContain('rowSpan')
})
})
describe('selectionToJSX', () => {

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode, computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'
import { SceneGraph, type SceneNode, type GridTrack, computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
@ -1049,3 +1049,363 @@ describe('Auto Layout', () => {
})
})
})
function gridFrame(
graph: SceneGraph,
parentId: string,
columns: GridTrack[],
rows: GridTrack[],
overrides: Partial<SceneNode> = {}
): SceneNode {
return graph.createNode('FRAME', parentId, {
layoutMode: 'GRID',
width: 400,
height: 300,
gridTemplateColumns: columns,
gridTemplateRows: rows,
gridColumnGap: 0,
gridRowGap: 0,
...overrides,
})
}
function fr(value = 1): GridTrack {
return { sizing: 'FR', value }
}
function fixed(value: number): GridTrack {
return { sizing: 'FIXED', value }
}
describe('Grid Layout', () => {
describe('basic grid', () => {
test('places children in 2x2 equal columns', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr(), fr()])
for (let i = 0; i < 4; i++) rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// Row 1: (0,0) and (200,0)
expect(children[0].x).toBe(0)
expect(children[0].y).toBe(0)
expect(children[1].x).toBe(200)
expect(children[1].y).toBe(0)
// Row 2: (0,150) and (200,150)
expect(children[2].x).toBe(0)
expect(children[2].y).toBe(150)
expect(children[3].x).toBe(200)
expect(children[3].y).toBe(150)
})
test('fixed column widths', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fixed(100), fixed(300)], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[0].width).toBe(50)
expect(children[1].x).toBe(100)
expect(children[1].width).toBe(50)
})
test('mixed fr and fixed columns', () => {
const graph = new SceneGraph()
// 400px wide: 100px fixed + 300px remaining as 1fr
const frame = gridFrame(graph, pageId(graph), [fixed(100), fr()], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(100)
})
test('unequal fr columns', () => {
const graph = new SceneGraph()
// 1fr + 2fr = 3fr total → 133.33px + 266.67px
const frame = gridFrame(
graph, pageId(graph),
[fr(1), fr(2)],
[fr()],
{ width: 300, height: 100 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBeCloseTo(100, 0)
})
})
describe('gaps', () => {
test('column gap', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr()],
[fr()],
{ gridColumnGap: 20 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// (400 - 20) / 2 = 190 per column
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(210)
})
test('row gap', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr(), fr()],
{ gridRowGap: 20, height: 200 },
)
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].y).toBe(0)
// (200 - 20) / 2 = 90 per row
expect(children[1].y).toBe(110)
})
test('both column and row gaps', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr()],
[fr(), fr()],
{ gridColumnGap: 10, gridRowGap: 10, width: 210, height: 210 },
)
for (let i = 0; i < 4; i++) rect(graph, frame.id, 30, 30)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
// (210 - 10) / 2 = 100 per col, (210 - 10) / 2 = 100 per row
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)
})
})
describe('padding', () => {
test('padding offsets grid content', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr()],
{ paddingTop: 10, paddingLeft: 20, paddingRight: 30, paddingBottom: 40 },
)
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(20)
expect(child.y).toBe(10)
})
})
describe('explicit placement', () => {
test('gridPosition places child at specific cell', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr(), fr()], [fr(), fr()])
// Place in column 2, row 1 (0-indexed internally, but yoga uses 1-indexed)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 2, row: 1, columnSpan: 1, rowSpan: 1 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
// Column 2 starts at 400/3 ≈ 133px
expect(child.x).toBeCloseTo(133, 0)
expect(child.y).toBe(0)
})
test('column span stretches across multiple columns', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr(), fr(), fr()],
[fr()],
{ width: 300, height: 100 },
)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 1, row: 1, columnSpan: 2, rowSpan: 1 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.x).toBe(0)
// Child keeps its own 50px width since it's not set to fill
expect(child.width).toBe(50)
})
test('row span stretches across multiple rows', () => {
const graph = new SceneGraph()
const frame = gridFrame(
graph, pageId(graph),
[fr()],
[fr(), fr(), fr()],
{ width: 100, height: 300 },
)
rect(graph, frame.id, 50, 50, {
gridPosition: { column: 1, row: 1, columnSpan: 1, rowSpan: 2 },
})
computeLayout(graph, frame.id)
const child = graph.getChildren(frame.id)[0]
expect(child.y).toBe(0)
expect(child.height).toBe(50)
})
})
describe('absolute children', () => {
test('absolute children are skipped in grid layout', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
rect(graph, frame.id, 50, 50)
rect(graph, frame.id, 50, 50, { layoutPositioning: 'ABSOLUTE', x: 300, y: 200 })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].x).toBe(0)
expect(children[1].x).toBe(300)
expect(children[1].y).toBe(200)
expect(children[2].x).toBe(200)
})
})
describe('hidden children', () => {
test('hidden children collapse in grid', () => {
const graph = new SceneGraph()
const frame = gridFrame(graph, pageId(graph), [fr(), fr()], [fr()])
rect(graph, frame.id, 50, 50, { visible: false })
rect(graph, frame.id, 50, 50)
computeLayout(graph, frame.id)
const children = graph.getChildren(frame.id)
expect(children[0].width).toBe(0)
expect(children[0].height).toBe(0)
})
})
describe('flex-to-grid switch', () => {
test('HUG frame expands to fit children in grid', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const frame = autoFrame(graph, page, {
layoutMode: 'VERTICAL',
primaryAxisSizing: 'HUG',
counterAxisSizing: 'HUG',
width: 100,
height: 100,
})
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
rect(graph, frame.id, 80, 80)
computeLayout(graph, frame.id)
const hugNode = graph.getNode(frame.id)!
expect(hugNode.width).toBe(80)
expect(hugNode.height).toBe(320)
const children = graph.getChildren(frame.id)
const cols = Math.max(2, Math.ceil(Math.sqrt(children.length)))
const rows = Math.max(1, Math.ceil(children.length / cols))
const maxChildW = Math.max(...children.map((c) => c.width))
const maxChildH = Math.max(...children.map((c) => c.height))
graph.updateNode(frame.id, {
layoutMode: 'GRID',
primaryAxisSizing: 'FIXED',
counterAxisSizing: 'FIXED',
width: maxChildW * cols,
height: maxChildH * rows,
gridTemplateColumns: Array.from({ length: cols }, () => ({ sizing: 'FR' as const, value: 1 })),
gridTemplateRows: Array.from({ length: rows }, () => ({ sizing: 'FR' as const, value: 1 })),
gridColumnGap: 0,
gridRowGap: 0,
})
computeLayout(graph, frame.id)
const gridNode = graph.getNode(frame.id)!
expect(gridNode.width).toBe(160)
expect(gridNode.height).toBe(160)
const gridChildren = graph.getChildren(frame.id)
expect(gridChildren[0].x).toBe(0)
expect(gridChildren[0].y).toBe(0)
expect(gridChildren[1].x).toBe(80)
expect(gridChildren[1].y).toBe(0)
expect(gridChildren[2].x).toBe(0)
expect(gridChildren[2].y).toBe(80)
expect(gridChildren[3].x).toBe(80)
expect(gridChildren[3].y).toBe(80)
})
})
describe('computeAllLayouts with grid', () => {
test('grid frames are computed in bottom-up pass', () => {
const graph = new SceneGraph()
const page = pageId(graph)
const outer = autoFrame(graph, page, {
layoutMode: 'VERTICAL',
width: 400,
height: 600,
itemSpacing: 10,
})
const inner = gridFrame(graph, outer.id, [fr(), fr()], [fr()], {
width: 380,
height: 100,
})
rect(graph, inner.id, 50, 50)
rect(graph, inner.id, 50, 50)
rect(graph, outer.id, 100, 50)
computeAllLayouts(graph)
const innerChildren = graph.getChildren(inner.id)
expect(innerChildren[0].x).toBe(0)
expect(innerChildren[1].x).toBe(190)
const outerChildren = graph.getChildren(outer.id)
expect(outerChildren[0].y).toBe(0)
expect(outerChildren[1].y).toBe(110)
})
})
})

View file

@ -509,4 +509,87 @@ describe('Tailwind JSX export', () => {
expect(jsx).toContain('text-white')
expect(jsx).not.toContain('#')
})
test('grid layout → grid + grid-cols + grid-rows', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
name: 'Grid',
width: 400,
height: 300,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridTemplateRows: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridColumnGap: 16,
gridRowGap: 8,
paddingTop: 12,
paddingRight: 12,
paddingBottom: 12,
paddingLeft: 12
})
const jsx = tw(graph, frame.id)
expect(jsx).toContain('grid ')
expect(jsx).toContain('grid-cols-3')
expect(jsx).toContain('grid-rows-2')
expect(jsx).toContain('gap-x-4')
expect(jsx).toContain('gap-y-2')
expect(jsx).toContain('p-3')
expect(jsx).not.toContain('flex')
})
test('grid with mixed tracks uses arbitrary value', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 600,
height: 400,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FIXED', value: 200 },
{ sizing: 'FR', value: 1 },
{ sizing: 'AUTO', value: 0 }
],
gridTemplateRows: [],
gridColumnGap: 0,
gridRowGap: 0
})
const jsx = tw(graph, frame.id)
expect(jsx).toContain('grid-cols-[200px_1fr_auto]')
expect(jsx).not.toContain('grid-rows')
})
test('grid child placement → col-start/row-start/col-span', () => {
const graph = makeGraph()
const frame = graph.createNode('FRAME', pageId(graph), {
width: 400,
height: 300,
layoutMode: 'GRID',
gridTemplateColumns: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridTemplateRows: [
{ sizing: 'FR', value: 1 },
{ sizing: 'FR', value: 1 }
],
gridColumnGap: 0,
gridRowGap: 0
})
const child = graph.createNode('RECTANGLE', frame.id, {
name: 'Span',
width: 100,
height: 50,
gridPosition: { column: 1, row: 2, columnSpan: 2, rowSpan: 1 }
})
const jsx = tw(graph, child.id)
expect(jsx).toContain('col-start-1')
expect(jsx).toContain('row-start-2')
expect(jsx).toContain('col-span-2')
expect(jsx).not.toContain('row-span')
})
})