diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts index 62ea023a1..cd8769e41 100644 --- a/packages/dom-css/src/from-scene-graph.ts +++ b/packages/dom-css/src/from-scene-graph.ts @@ -16,6 +16,21 @@ function nodeChildren(graph: SceneGraph, node: SceneNode): SceneNode[] { .filter((child): child is SceneNode => child !== undefined) } +function justifyContentToCSS(value: SceneNode['primaryAxisAlign']): string | undefined { + if (value === 'CENTER') return 'center' + if (value === 'MAX') return 'flex-end' + if (value === 'SPACE_BETWEEN') return 'space-between' + return undefined +} + +function alignItemsToCSS(value: SceneNode['counterAxisAlign']): string | undefined { + if (value === 'CENTER') return 'center' + if (value === 'MAX') return 'flex-end' + if (value === 'STRETCH') return 'stretch' + if (value === 'BASELINE') return 'baseline' + return undefined +} + function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration { const style = sceneNodeSizeStyle(node) const fill = fillToCSS(node.fills[0]) @@ -30,6 +45,10 @@ function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration { if (node.layoutMode !== 'NONE') { style.display = 'flex' style['flex-direction'] = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column' + const justifyContent = justifyContentToCSS(node.primaryAxisAlign) + const alignItems = alignItemsToCSS(node.counterAxisAlign) + if (justifyContent) style['justify-content'] = justifyContent + if (alignItems) style['align-items'] = alignItems if (node.itemSpacing > 0) style.gap = `${node.itemSpacing}px` if (node.paddingTop > 0) style['padding-top'] = `${node.paddingTop}px` if (node.paddingRight > 0) style['padding-right'] = `${node.paddingRight}px` diff --git a/packages/dom-css/src/to-scene-graph.ts b/packages/dom-css/src/to-scene-graph.ts index fe2b80a33..a7721c246 100644 --- a/packages/dom-css/src/to-scene-graph.ts +++ b/packages/dom-css/src/to-scene-graph.ts @@ -56,6 +56,21 @@ function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void { if (height !== null) node.height = height } +function primaryAxisAlignFromCSS(value: string | undefined): SceneNode['primaryAxisAlign'] { + if (value === 'center') return 'CENTER' + if (value === 'end' || value === 'flex-end') return 'MAX' + if (value === 'space-between') return 'SPACE_BETWEEN' + return 'MIN' +} + +function counterAxisAlignFromCSS(value: string | undefined): SceneNode['counterAxisAlign'] { + if (value === 'center') return 'CENTER' + if (value === 'end' || value === 'flex-end') return 'MAX' + if (value === 'stretch') return 'STRETCH' + if (value === 'baseline') return 'BASELINE' + return 'MIN' +} + function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void { setNodeBox(node, style) @@ -77,8 +92,11 @@ function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void const cornerRadius = firstCSSNumber(style, 'border-radius') if (cornerRadius !== null) node.cornerRadius = cornerRadius - if (pickStyle(style, 'display') === 'flex') { + const display = pickStyle(style, 'display') + if (display === 'flex' || display === 'inline-flex') { node.layoutMode = pickStyle(style, 'flex-direction') === 'column' ? 'VERTICAL' : 'HORIZONTAL' + node.primaryAxisAlign = primaryAxisAlignFromCSS(pickStyle(style, 'justify-content')) + node.counterAxisAlign = counterAxisAlignFromCSS(pickStyle(style, 'align-items')) node.itemSpacing = firstCSSNumber(style, 'gap', 'column-gap', 'row-gap') ?? 0 node.paddingTop = firstCSSNumber(style, 'padding-top', 'padding') ?? 0 node.paddingRight = firstCSSNumber(style, 'padding-right', 'padding') ?? 0 diff --git a/tests/engine/dom-css/conversion.test.ts b/tests/engine/dom-css/conversion.test.ts index c9d733b07..b3e571484 100644 --- a/tests/engine/dom-css/conversion.test.ts +++ b/tests/engine/dom-css/conversion.test.ts @@ -71,6 +71,59 @@ describe('@open-pencil/dom-css conversion', () => { expect(html).toContain('box-shadow') }) + it('maps CSS flex alignment into scene graph auto-layout alignment', () => { + const graph = designDocumentToSceneGraph({ + type: 'document', + children: [ + { + type: 'element', + tagName: 'div', + attrs: { class: 'toolbar' }, + computedStyle: { + display: 'inline-flex', + 'align-items': 'center', + 'justify-content': 'space-between', + gap: '8px', + width: '240px', + height: '40px' + }, + children: [ + { + type: 'element', + tagName: 'span', + attrs: {}, + children: [{ type: 'text', text: 'File' }] + }, + { + type: 'element', + tagName: 'span', + attrs: {}, + children: [{ type: 'text', text: 'Edit' }] + } + ] + } + ] + }) + const page = graph.getPages()[0] + const toolbar = page ? graph.getChildren(page.id)[0] : undefined + + expect(toolbar?.type).toBe('FRAME') + if (toolbar?.type !== 'FRAME') return + expect(toolbar.layoutMode).toBe('HORIZONTAL') + expect(toolbar.primaryAxisAlign).toBe('SPACE_BETWEEN') + expect(toolbar.counterAxisAlign).toBe('CENTER') + + const roundTrip = sceneGraphToDesignDocument(graph) + const root = roundTrip.children[0] + expect(root?.type).toBe('element') + if (root?.type !== 'element') return + const roundTripToolbar = root.children[0] + expect(roundTripToolbar?.type).toBe('element') + if (roundTripToolbar?.type !== 'element') return + expect(roundTripToolbar.inlineStyle?.['justify-content']).toBe('space-between') + expect(roundTripToolbar.inlineStyle?.['align-items']).toBe('center') + }) + it('projects a Tailwind card through generated CSS into a scene graph', async () => { const runtime = createHeadlessCSSRuntime() const classes = [...tailwindCardClasses]