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' +}