feat(dom-css): add SceneGraph conversion
This commit is contained in:
parent
4d61c002fa
commit
141d88cf94
|
|
@ -9,6 +9,7 @@
|
||||||
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
|
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
|
||||||
- Add structured design JSX effect helpers for shadows and blur effects.
|
- Add structured design JSX effect helpers for shadows and blur effects.
|
||||||
- Add the `@open-pencil/dom-css` package skeleton for DOM/CSS projection and browser/headless CSS runtime adapters.
|
- Add the `@open-pencil/dom-css` package skeleton for DOM/CSS projection and browser/headless CSS runtime adapters.
|
||||||
|
- Add initial `@open-pencil/dom-css` DesignDOM ⇄ SceneGraph conversion helpers for HTML/CSS-shaped card layouts.
|
||||||
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
|
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
|
||||||
- Add `unbind_variable` MCP tool for removing variable bindings.
|
- Add `unbind_variable` MCP tool for removing variable bindings.
|
||||||
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.
|
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.
|
||||||
|
|
|
||||||
51
packages/dom-css/src/css-values.ts
Normal file
51
packages/dom-css/src/css-values.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { colorToCSS, parseColor } from '@open-pencil/core/color'
|
||||||
|
import type { Fill, SceneNode } from '@open-pencil/core/scene-graph'
|
||||||
|
import type { Color } from '@open-pencil/core/types'
|
||||||
|
|
||||||
|
import type { DesignStyleDeclaration } from './types'
|
||||||
|
|
||||||
|
const TRANSPARENT_KEYWORDS = new Set(['transparent', 'rgba(0, 0, 0, 0)', 'rgb(0 0 0 / 0)'])
|
||||||
|
|
||||||
|
export function parseCssNumber(value: string | undefined): number | null {
|
||||||
|
if (!value) return null
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length === 0 || trimmed === 'auto') return null
|
||||||
|
const parsed = Number.parseFloat(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCssColor(value: string | undefined): Color | null {
|
||||||
|
if (!value) return null
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed.length === 0 || TRANSPARENT_KEYWORDS.has(trimmed.toLowerCase())) return null
|
||||||
|
return parseColor(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillToCss(fill: Fill | undefined): string | undefined {
|
||||||
|
if (fill?.type !== 'SOLID' || !fill.visible) return undefined
|
||||||
|
return colorToCSS({ ...fill.color, a: fill.opacity })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cssColorToFill(value: string | undefined): Fill[] {
|
||||||
|
const color = parseCssColor(value)
|
||||||
|
if (!color) return []
|
||||||
|
return [{ type: 'SOLID', color, opacity: color.a, visible: true }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickStyle(elementStyle: DesignStyleDeclaration | undefined, property: string) {
|
||||||
|
return elementStyle?.[property]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergedStyle(node: {
|
||||||
|
inlineStyle?: DesignStyleDeclaration
|
||||||
|
computedStyle?: DesignStyleDeclaration
|
||||||
|
}) {
|
||||||
|
return { ...node.inlineStyle, ...node.computedStyle }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneNodeSizeStyle(node: SceneNode): DesignStyleDeclaration {
|
||||||
|
const style: DesignStyleDeclaration = {}
|
||||||
|
if (node.width > 0) style.width = `${node.width}px`
|
||||||
|
if (node.height > 0) style.height = `${node.height}px`
|
||||||
|
return style
|
||||||
|
}
|
||||||
129
packages/dom-css/src/from-scene-graph.ts
Normal file
129
packages/dom-css/src/from-scene-graph.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { colorToCSS } from '@open-pencil/core/color'
|
||||||
|
import { BLACK } from '@open-pencil/core/constants'
|
||||||
|
import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph'
|
||||||
|
|
||||||
|
import { fillToCss, sceneNodeSizeStyle } from './css-values'
|
||||||
|
import type { DesignDocument, DesignNode, DesignStyleDeclaration } from './types'
|
||||||
|
|
||||||
|
export interface SceneGraphToDesignOptions {
|
||||||
|
rootId?: string
|
||||||
|
includeSourceIds?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeChildren(graph: SceneGraph, node: SceneNode): SceneNode[] {
|
||||||
|
return node.childIds
|
||||||
|
.map((id) => graph.getNode(id))
|
||||||
|
.filter((child): child is SceneNode => child !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
|
||||||
|
const style = sceneNodeSizeStyle(node)
|
||||||
|
const fill = fillToCss(node.fills[0])
|
||||||
|
if (fill) style['background-color'] = fill
|
||||||
|
if (node.opacity < 1) style.opacity = String(node.opacity)
|
||||||
|
if (node.cornerRadius > 0) style['border-radius'] = `${node.cornerRadius}px`
|
||||||
|
|
||||||
|
if (node.layoutMode !== 'NONE') {
|
||||||
|
style.display = 'flex'
|
||||||
|
style['flex-direction'] = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'
|
||||||
|
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`
|
||||||
|
if (node.paddingBottom > 0) style['padding-bottom'] = `${node.paddingBottom}px`
|
||||||
|
if (node.paddingLeft > 0) style['padding-left'] = `${node.paddingLeft}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
|
||||||
|
const style = sceneNodeSizeStyle(node)
|
||||||
|
style.color = fillToCss(node.fills[0]) ?? colorToCSS(BLACK)
|
||||||
|
style['font-family'] = node.fontFamily
|
||||||
|
style['font-size'] = `${node.fontSize}px`
|
||||||
|
style['font-weight'] = String(node.fontWeight)
|
||||||
|
if (node.italic) style['font-style'] = 'italic'
|
||||||
|
if (node.lineHeight !== null) style['line-height'] = `${node.lineHeight}px`
|
||||||
|
if (node.letterSpacing !== 0) style['letter-spacing'] = `${node.letterSpacing}px`
|
||||||
|
if (node.textAlignHorizontal !== 'LEFT')
|
||||||
|
style['text-align'] = node.textAlignHorizontal.toLowerCase()
|
||||||
|
if (node.textDecoration !== 'NONE') {
|
||||||
|
style['text-decoration-line'] =
|
||||||
|
node.textDecoration === 'UNDERLINE' ? 'underline' : 'line-through'
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
function attrsForNode(node: SceneNode, includeSourceIds: boolean): Record<string, string> {
|
||||||
|
return includeSourceIds ? { 'data-open-pencil-node-id': node.id } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sceneNodeToDesignNode(
|
||||||
|
graph: SceneGraph,
|
||||||
|
node: SceneNode,
|
||||||
|
options: Required<SceneGraphToDesignOptions>
|
||||||
|
): DesignNode | null {
|
||||||
|
if (!node.visible || node.internalOnly) return null
|
||||||
|
|
||||||
|
if (node.type === 'TEXT') {
|
||||||
|
return {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'span',
|
||||||
|
attrs: attrsForNode(node, options.includeSourceIds),
|
||||||
|
inlineStyle: styleFromTextNode(node),
|
||||||
|
sourceSceneNodeId: node.id,
|
||||||
|
sourceSceneNode: node,
|
||||||
|
children: [{ type: 'text', text: node.text }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = nodeChildren(graph, node)
|
||||||
|
.map((child) => sceneNodeToDesignNode(graph, child, options))
|
||||||
|
.filter((child): child is DesignNode => child !== null)
|
||||||
|
|
||||||
|
if (node.type === 'CANVAS') {
|
||||||
|
return {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'main',
|
||||||
|
attrs: attrsForNode(node, options.includeSourceIds),
|
||||||
|
sourceSceneNodeId: node.id,
|
||||||
|
sourceSceneNode: node,
|
||||||
|
children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
attrs: attrsForNode(node, options.includeSourceIds),
|
||||||
|
inlineStyle: styleFromSceneNode(node),
|
||||||
|
sourceSceneNodeId: node.id,
|
||||||
|
sourceSceneNode: node,
|
||||||
|
children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sceneGraphToDesignDocument(
|
||||||
|
graph: SceneGraph,
|
||||||
|
options: SceneGraphToDesignOptions = {}
|
||||||
|
): DesignDocument {
|
||||||
|
const root = graph.getNode(options.rootId ?? graph.rootId)
|
||||||
|
const resolvedOptions: Required<SceneGraphToDesignOptions> = {
|
||||||
|
rootId: options.rootId ?? graph.rootId,
|
||||||
|
includeSourceIds: options.includeSourceIds ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = root
|
||||||
|
? nodeChildren(graph, root)
|
||||||
|
.map((child) => sceneNodeToDesignNode(graph, child, resolvedOptions))
|
||||||
|
.filter((child): child is DesignNode => child !== null)
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'document',
|
||||||
|
sourceGraph: graph,
|
||||||
|
children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SceneGraphToDesignOptions as ToDesignDocumentOptions }
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
export { serializeHTML, serializeNode } from './serialize'
|
export { serializeHTML, serializeNode } from './serialize'
|
||||||
export { createBrowserCssRuntime, createCssRuntime, createHeadlessCssRuntime } from './runtime'
|
export { createBrowserCssRuntime, createCssRuntime, createHeadlessCssRuntime } from './runtime'
|
||||||
|
export { designDocumentToSceneGraph } from './to-scene-graph'
|
||||||
|
export { sceneGraphToDesignDocument } from './from-scene-graph'
|
||||||
|
export type { ToDesignDocumentOptions } from './from-scene-graph'
|
||||||
|
export type { ToSceneGraphOptions } from './to-scene-graph'
|
||||||
export type {
|
export type {
|
||||||
CssComputeOptions,
|
CssComputeOptions,
|
||||||
CssRuntime,
|
CssRuntime,
|
||||||
|
|
|
||||||
181
packages/dom-css/src/to-scene-graph.ts
Normal file
181
packages/dom-css/src/to-scene-graph.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { SceneGraph, type Fill, type SceneNode } from '@open-pencil/core/scene-graph'
|
||||||
|
|
||||||
|
import { cssColorToFill, mergedStyle, parseCssNumber, pickStyle } from './css-values'
|
||||||
|
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from './types'
|
||||||
|
|
||||||
|
export interface DesignDocumentToSceneGraphOptions {
|
||||||
|
pageName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function textContent(node: DesignNode): string {
|
||||||
|
if (node.type === 'text') return node.text
|
||||||
|
return node.children.map(textContent).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextLikeElement(node: DesignElement): boolean {
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
'p',
|
||||||
|
'label',
|
||||||
|
'strong',
|
||||||
|
'em',
|
||||||
|
'button',
|
||||||
|
'a',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6'
|
||||||
|
].includes(node.tagName.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCssNumber(style: DesignStyleDeclaration, ...properties: string[]): number | null {
|
||||||
|
for (const property of properties) {
|
||||||
|
const parsed = parseCssNumber(pickStyle(style, property))
|
||||||
|
if (parsed !== null) return parsed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillsFromStyle(style: DesignStyleDeclaration, property: string): Fill[] {
|
||||||
|
return cssColorToFill(pickStyle(style, property))
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNodeBox(node: SceneNode, style: DesignStyleDeclaration): void {
|
||||||
|
const width = firstCssNumber(style, 'width')
|
||||||
|
const height = firstCssNumber(style, 'height')
|
||||||
|
if (width !== null) node.width = width
|
||||||
|
if (height !== null) node.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void {
|
||||||
|
setNodeBox(node, style)
|
||||||
|
|
||||||
|
const fills = fillsFromStyle(style, 'background-color')
|
||||||
|
if (fills.length > 0) node.fills = fills
|
||||||
|
|
||||||
|
const opacity = parseCssNumber(pickStyle(style, 'opacity'))
|
||||||
|
if (opacity !== null) node.opacity = opacity
|
||||||
|
|
||||||
|
const cornerRadius = firstCssNumber(style, 'border-radius')
|
||||||
|
if (cornerRadius !== null) node.cornerRadius = cornerRadius
|
||||||
|
|
||||||
|
if (pickStyle(style, 'display') === 'flex') {
|
||||||
|
node.layoutMode = pickStyle(style, 'flex-direction') === 'column' ? 'VERTICAL' : 'HORIZONTAL'
|
||||||
|
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
|
||||||
|
node.paddingBottom = firstCssNumber(style, 'padding-bottom', 'padding') ?? 0
|
||||||
|
node.paddingLeft = firstCssNumber(style, 'padding-left', 'padding') ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextStyle(node: SceneNode, style: DesignStyleDeclaration): void {
|
||||||
|
setNodeBox(node, style)
|
||||||
|
|
||||||
|
const fills = fillsFromStyle(style, 'color')
|
||||||
|
if (fills.length > 0) node.fills = fills
|
||||||
|
|
||||||
|
const fontSize = parseCssNumber(pickStyle(style, 'font-size'))
|
||||||
|
if (fontSize !== null) node.fontSize = fontSize
|
||||||
|
|
||||||
|
const fontWeight = parseCssNumber(pickStyle(style, 'font-weight'))
|
||||||
|
if (fontWeight !== null) node.fontWeight = fontWeight
|
||||||
|
|
||||||
|
const lineHeight = parseCssNumber(pickStyle(style, 'line-height'))
|
||||||
|
if (lineHeight !== null) node.lineHeight = lineHeight
|
||||||
|
|
||||||
|
const letterSpacing = parseCssNumber(pickStyle(style, 'letter-spacing'))
|
||||||
|
if (letterSpacing !== null) node.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
const fontFamily = pickStyle(style, 'font-family')
|
||||||
|
if (fontFamily)
|
||||||
|
node.fontFamily = fontFamily.split(',')[0]?.replaceAll('"', '').trim() || node.fontFamily
|
||||||
|
|
||||||
|
node.italic = pickStyle(style, 'font-style') === 'italic'
|
||||||
|
|
||||||
|
const textAlign = pickStyle(style, 'text-align')?.toUpperCase()
|
||||||
|
if (textAlign === 'CENTER' || textAlign === 'RIGHT' || textAlign === 'JUSTIFIED') {
|
||||||
|
node.textAlignHorizontal = textAlign
|
||||||
|
}
|
||||||
|
|
||||||
|
const textDecoration = pickStyle(style, 'text-decoration-line')
|
||||||
|
if (textDecoration === 'underline') node.textDecoration = 'UNDERLINE'
|
||||||
|
if (textDecoration === 'line-through') node.textDecoration = 'STRIKETHROUGH'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTextNode(
|
||||||
|
graph: SceneGraph,
|
||||||
|
parentId: string,
|
||||||
|
text: string,
|
||||||
|
style: DesignStyleDeclaration
|
||||||
|
) {
|
||||||
|
const node = graph.createNode('TEXT', parentId, {
|
||||||
|
name: text.slice(0, 32) || 'Text',
|
||||||
|
text,
|
||||||
|
width: Math.max(text.length * 8, 1),
|
||||||
|
height: 20
|
||||||
|
})
|
||||||
|
applyTextStyle(node, style)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementNode(graph: SceneGraph, parentId: string, element: DesignElement): SceneNode {
|
||||||
|
const style = mergedStyle(element)
|
||||||
|
if (isTextLikeElement(element) && element.children.every((child) => child.type === 'text')) {
|
||||||
|
return createTextNode(graph, parentId, textContent(element), style)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = graph.createNode('FRAME', parentId, {
|
||||||
|
name: element.attrs.id || element.attrs.class || element.tagName,
|
||||||
|
clipsContent: false
|
||||||
|
})
|
||||||
|
applyElementStyle(node, style)
|
||||||
|
|
||||||
|
for (const child of element.children) {
|
||||||
|
createDesignNode(graph, node.id, child, style)
|
||||||
|
}
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDesignNode(
|
||||||
|
graph: SceneGraph,
|
||||||
|
parentId: string,
|
||||||
|
node: DesignNode,
|
||||||
|
inheritedStyle: DesignStyleDeclaration = {}
|
||||||
|
): SceneNode | null {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
if (node.text.trim().length === 0) return null
|
||||||
|
return createTextNode(graph, parentId, node.text, inheritedStyle)
|
||||||
|
}
|
||||||
|
|
||||||
|
return createElementNode(graph, parentId, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitPageToChildren(page: SceneNode, graph: SceneGraph): void {
|
||||||
|
const children = graph.getChildren(page.id)
|
||||||
|
if (children.length === 0) return
|
||||||
|
page.width = Math.max(...children.map((child) => child.x + child.width))
|
||||||
|
page.height = Math.max(...children.map((child) => child.y + child.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function designDocumentToSceneGraph(
|
||||||
|
document: DesignDocument,
|
||||||
|
options: DesignDocumentToSceneGraphOptions = {}
|
||||||
|
): SceneGraph {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
const page = graph.getPages().find((node) => node.type === 'CANVAS') ?? graph.addPage('DesignDOM')
|
||||||
|
|
||||||
|
page.name = options.pageName ?? 'DesignDOM'
|
||||||
|
|
||||||
|
for (const child of document.children) {
|
||||||
|
createDesignNode(graph, page.id, child)
|
||||||
|
}
|
||||||
|
|
||||||
|
fitPageToChildren(page, graph)
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { DesignDocumentToSceneGraphOptions as ToSceneGraphOptions }
|
||||||
90
tests/engine/dom-css/conversion.test.ts
Normal file
90
tests/engine/dom-css/conversion.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
designDocumentToSceneGraph,
|
||||||
|
sceneGraphToDesignDocument,
|
||||||
|
type DesignDocument
|
||||||
|
} from '@open-pencil/dom-css'
|
||||||
|
|
||||||
|
const cardDocument: DesignDocument = {
|
||||||
|
type: 'document',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'div',
|
||||||
|
attrs: { class: 'card' },
|
||||||
|
computedStyle: {
|
||||||
|
width: '320px',
|
||||||
|
height: '160px',
|
||||||
|
display: 'flex',
|
||||||
|
'flex-direction': 'column',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
'border-radius': '16px',
|
||||||
|
'background-color': 'rgb(255, 255, 255)'
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
tagName: 'h1',
|
||||||
|
attrs: {},
|
||||||
|
computedStyle: {
|
||||||
|
color: 'rgb(17, 24, 39)',
|
||||||
|
'font-size': '24px',
|
||||||
|
'font-weight': '700',
|
||||||
|
'line-height': '32px'
|
||||||
|
},
|
||||||
|
children: [{ type: 'text', text: 'OpenPencil' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@open-pencil/dom-css conversion', () => {
|
||||||
|
it('projects a DesignDOM card into a scene graph', () => {
|
||||||
|
const graph = designDocumentToSceneGraph(cardDocument)
|
||||||
|
const page = graph.getPages()[0]
|
||||||
|
expect(page?.name).toBe('DesignDOM')
|
||||||
|
|
||||||
|
const card = page ? graph.getChildren(page.id)[0] : undefined
|
||||||
|
expect(card?.type).toBe('FRAME')
|
||||||
|
expect(card?.width).toBe(320)
|
||||||
|
expect(card?.height).toBe(160)
|
||||||
|
expect(card?.layoutMode).toBe('VERTICAL')
|
||||||
|
expect(card?.itemSpacing).toBe(12)
|
||||||
|
expect(card?.paddingTop).toBe(24)
|
||||||
|
expect(card?.cornerRadius).toBe(16)
|
||||||
|
expect(card?.fills[0]?.type).toBe('SOLID')
|
||||||
|
|
||||||
|
const title = card ? graph.getChildren(card.id)[0] : undefined
|
||||||
|
expect(title?.type).toBe('TEXT')
|
||||||
|
expect(title?.text).toBe('OpenPencil')
|
||||||
|
expect(title?.fontSize).toBe(24)
|
||||||
|
expect(title?.fontWeight).toBe(700)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects a scene graph back into DesignDOM', () => {
|
||||||
|
const graph = designDocumentToSceneGraph(cardDocument)
|
||||||
|
const document = sceneGraphToDesignDocument(graph)
|
||||||
|
const page = document.children[0]
|
||||||
|
expect(page?.type).toBe('element')
|
||||||
|
if (page?.type !== 'element') return
|
||||||
|
|
||||||
|
const card = page.children[0]
|
||||||
|
expect(card?.type).toBe('element')
|
||||||
|
if (card?.type !== 'element') return
|
||||||
|
|
||||||
|
expect(card.tagName).toBe('div')
|
||||||
|
expect(card.inlineStyle?.width).toBe('320px')
|
||||||
|
expect(card.inlineStyle?.display).toBe('flex')
|
||||||
|
expect(card.inlineStyle?.['flex-direction']).toBe('column')
|
||||||
|
expect(card.attrs['data-open-pencil-node-id']).toBeTruthy()
|
||||||
|
|
||||||
|
const title = card.children[0]
|
||||||
|
expect(title?.type).toBe('element')
|
||||||
|
if (title?.type !== 'element') return
|
||||||
|
expect(title.tagName).toBe('span')
|
||||||
|
expect(title.inlineStyle?.['font-size']).toBe('24px')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue