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:
parent
52b9ebc5c2
commit
45df90c5c7
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
14
bun.lock
14
bun.lock
|
|
@ -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
10
components.d.ts
vendored
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export {
|
|||
type LayoutAlign,
|
||||
type LayoutCounterAlign,
|
||||
type LayoutWrap,
|
||||
type GridTrack,
|
||||
type GridTrackSizing,
|
||||
type GridPosition,
|
||||
type ConstraintType,
|
||||
type TextAutoResize,
|
||||
type TextAlignVertical,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue