From 45df90c5c75d751c1342a7a657f859b3096ff4f3 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 8 Mar 2026 18:23:53 +0300 Subject: [PATCH] CSS Grid layout mode (#73) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- CHANGELOG.md | 15 + bun.lock | 14 +- components.d.ts | 10 + package.json | 2 +- packages/core/package.json | 2 +- packages/core/src/index.ts | 3 + packages/core/src/kiwi-serialize.ts | 2 +- packages/core/src/layout.ts | 138 +++++++- packages/core/src/render/export-jsx.ts | 100 +++++- packages/core/src/scene-graph.ts | 32 +- src/components/properties/LayoutSection.vue | 344 +++++++++++++++---- src/stores/editor.ts | 39 ++- tests/engine/export-jsx.test.ts | 85 +++++ tests/engine/layout.test.ts | 362 +++++++++++++++++++- tests/engine/tailwind-jsx.test.ts | 83 +++++ 15 files changed, 1115 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eb474d43..920ea8c1e 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/bun.lock b/bun.lock index 0b70dacd1..d22b576d5 100644 --- a/bun.lock +++ b/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=="], diff --git a/components.d.ts b/components.d.ts index 231609011..e91c62326 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/package.json b/package.json index 2a1667175..2daa87eae 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index ae1982248..49de6997f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1c7db5823..e4563f72d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,6 +24,9 @@ export { type LayoutAlign, type LayoutCounterAlign, type LayoutWrap, + type GridTrack, + type GridTrackSizing, + type GridPosition, type ConstraintType, type TextAutoResize, type TextAlignVertical, diff --git a/packages/core/src/kiwi-serialize.ts b/packages/core/src/kiwi-serialize.ts index 8d02ef7e3..a1a6ed733 100644 --- a/packages/core/src/kiwi-serialize.ts +++ b/packages/core/src/kiwi-serialize.ts @@ -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 diff --git a/packages/core/src/layout.ts b/packages/core/src/layout.ts index 71b80b911..3e564c898 100644 --- a/packages/core/src/layout.ts +++ b/packages/core/src/layout.ts @@ -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,9 +265,10 @@ 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' - ? measureTextSize(child) - : null + const measured = + child.type === 'TEXT' && child.textAutoResize === 'WIDTH_AND_HEIGHT' + ? measureTextSize(child) + : null const w = measured ? measured.width : child.width const h = child.height @@ -228,21 +326,23 @@ function setSizing( } function applyYogaLayout(graph: SceneGraph, frame: SceneNode, yogaNode: YogaNode): void { - if (frame.primaryAxisSizing === 'HUG' || frame.counterAxisSizing === 'HUG') { - const computedW = yogaNode.getComputedWidth() - const computedH = yogaNode.getComputedHeight() - const updates: Partial = {} + if (frame.layoutMode !== 'GRID') { + if (frame.primaryAxisSizing === 'HUG' || frame.counterAxisSizing === 'HUG') { + const computedW = yogaNode.getComputedWidth() + const computedH = yogaNode.getComputedHeight() + const updates: Partial = {} - if (frame.primaryAxisSizing === 'HUG') { - if (frame.layoutMode === 'HORIZONTAL') updates.width = computedW - else updates.height = computedH - } - if (frame.counterAxisSizing === 'HUG') { - if (frame.layoutMode === 'HORIZONTAL') updates.height = computedH - else updates.width = computedW - } + if (frame.primaryAxisSizing === 'HUG') { + if (frame.layoutMode === 'HORIZONTAL') updates.width = computedW + else updates.height = computedH + } + if (frame.counterAxisSizing === 'HUG') { + if (frame.layoutMode === 'HORIZONTAL') updates.height = computedH + else updates.width = computedW + } - graph.updateNode(frame.id, updates) + graph.updateNode(frame.id, updates) + } } const children = graph.getChildren(frame.id) @@ -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 { diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts index 3a5dd6f81..2c7ebaad9 100644 --- a/packages/core/src/render/export-jsx.ts +++ b/packages/core/src/render/export-jsx.ts @@ -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( diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index efd812842..814592631 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -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 = {}): 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', diff --git a/src/components/properties/LayoutSection.vue b/src/components/properties/LayoutSection.vue index 0329464fa..6251c2ae6 100644 --- a/src/components/properties/LayoutSection.vue +++ b/src/components/properties/LayoutSection.vue @@ -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(() => { - 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(() => { }) const heightSizing = computed(() => { - 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(() => { }) 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 +) { + 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' +}