import { parseColor, colorToFill } from '../color' import { TRANSPARENT } from '../constants' import { fetchIcons } from '../iconify' import { createIconFromPaths } from '../icon-render' import { isTreeNode } from './tree' import type { SceneGraph, SceneNode, NodeType, LayoutMode, GridTrack, Stroke } from '../scene-graph' import type { TreeNode } from './tree' const TYPE_MAP: Partial> = { frame: 'FRAME', view: 'FRAME', rectangle: 'RECTANGLE', rect: 'RECTANGLE', ellipse: 'ELLIPSE', text: 'TEXT', line: 'LINE', star: 'STAR', polygon: 'POLYGON', vector: 'VECTOR', group: 'GROUP', section: 'SECTION', component: 'COMPONENT' } const WEIGHT_MAP: Record = { normal: 400, medium: 500, bold: 700 } const ALIGN_MAP: Record = { start: 'MIN', end: 'MAX', center: 'CENTER', between: 'SPACE_BETWEEN' } const COUNTER_ALIGN_MAP: Record = { start: 'MIN', end: 'MAX', center: 'CENTER', stretch: 'STRETCH' } const TEXT_ALIGN_MAP: Record = { left: 'LEFT', center: 'CENTER', right: 'RIGHT', justified: 'JUSTIFIED' } const TEXT_AUTO_RESIZE_MAP: Record = { none: 'NONE', width: 'WIDTH_AND_HEIGHT', height: 'HEIGHT' } function parseStroke(value: string, width: number): Stroke { const color = parseColor(value) return { color, opacity: color.a, visible: true, weight: width, align: 'INSIDE' } } interface RenderOptions { x?: number y?: number parentId?: string } export interface RenderResult { id: string name: string type: NodeType childIds: string[] } export async function renderTree( graph: SceneGraph, tree: TreeNode, options: RenderOptions = {} ): Promise { const parentId = options.parentId ?? graph.getPages()[0].id const result = await renderNode(graph, tree, parentId) if (options.x !== undefined) graph.updateNode(result.id, { x: options.x }) if (options.y !== undefined) graph.updateNode(result.id, { y: options.y }) return { id: result.id, name: result.name, type: result.type, childIds: result.childIds } } async function renderIconNode( graph: SceneGraph, tree: TreeNode, parentId: string ): Promise { const props = tree.props const iconName = props.name as string | undefined if (!iconName) throw new Error(' requires a name prop (e.g. name="lucide:heart")') const size = (props.size as number | undefined) ?? 24 const colorHex = (props.color as string | undefined) ?? '#000000' const parsedColor = parseColor(colorHex) const icons = await fetchIcons([iconName], size) const icon = icons.get(iconName) if (!icon || icon.paths.length === 0) { throw new Error(`Icon "${iconName}" not found`) } const parent = graph.getNode(parentId) const parentLayout = parent?.layoutMode ?? 'NONE' const overrides: Partial = {} if (props.label) overrides.name = props.label as string const { w, h } = applySizeOverrides(props, overrides, parentLayout) if (typeof w !== 'number') overrides.width = size if (typeof h !== 'number') overrides.height = size return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides) } async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise { if (tree.type === 'icon') return renderIconNode(graph, tree, parentId) const nodeType = TYPE_MAP[tree.type] if (!nodeType) throw new Error(`Unknown element: <${tree.type}>`) const parent = graph.getNode(parentId) const parentLayout = parent?.layoutMode ?? 'NONE' const isText = nodeType === 'TEXT' const childCount = tree.children.filter((c) => typeof c !== 'string' && isTreeNode(c)).length const overrides = propsToOverrides(tree.props, isText, parentLayout, childCount) if (isText) { const textContent = tree.children.filter((c): c is string => typeof c === 'string').join('') if (textContent) overrides.text = textContent } const node = graph.createNode(nodeType, parentId, overrides) for (const child of tree.children) { if (typeof child === 'string') continue if (isTreeNode(child)) { await renderNode(graph, child, node.id) } } return node } function applySizeOverrides( props: Record, o: Partial, parentLayout: SceneNode['layoutMode'] ): { w: unknown; h: unknown } { const w = props.w ?? props.width const h = props.h ?? props.height if (typeof w === 'number') o.width = w if (typeof h === 'number') o.height = h const isParentRow = parentLayout === 'HORIZONTAL' const isParentCol = parentLayout === 'VERTICAL' const isParentGrid = parentLayout === 'GRID' if (w === 'fill') { if (isParentGrid) o.layoutAlignSelf = 'STRETCH' else if (isParentRow) o.layoutGrow = 1 else if (isParentCol) o.layoutAlignSelf = 'STRETCH' else { o.layoutGrow = 1; o.layoutAlignSelf = 'STRETCH' } } if (h === 'fill') { if (isParentGrid) o.layoutAlignSelf = 'STRETCH' else if (isParentCol) o.layoutGrow = 1 else if (isParentRow) o.layoutAlignSelf = 'STRETCH' else o.layoutAlignSelf = 'STRETCH' } if (props.x !== undefined) o.x = props.x as number if (props.y !== undefined) o.y = props.y as number const hasExplicitPosition = props.x !== undefined || props.y !== undefined const isInsideAutoLayout = parentLayout !== 'NONE' if (hasExplicitPosition && isInsideAutoLayout) { o.layoutPositioning = 'ABSOLUTE' } return { w, h } } function applyVisualOverrides(props: Record, o: Partial): void { const bg = props.bg ?? props.fill if (typeof bg === 'string') { o.fills = [colorToFill(bg)] } if (typeof props.stroke === 'string') { const strokeWidth = (props.strokeWidth as number | undefined) ?? 1 o.strokes = [parseStroke(props.stroke, strokeWidth)] } const rounded = props.rounded ?? props.cornerRadius if (typeof rounded === 'number') { o.cornerRadius = rounded } if ( props.roundedTL !== undefined || props.roundedTR !== undefined || props.roundedBL !== undefined || props.roundedBR !== undefined ) { o.independentCorners = true if (props.roundedTL !== undefined) o.topLeftRadius = props.roundedTL as number if (props.roundedTR !== undefined) o.topRightRadius = props.roundedTR as number if (props.roundedBL !== undefined) o.bottomLeftRadius = props.roundedBL as number if (props.roundedBR !== undefined) o.bottomRightRadius = props.roundedBR as number } if (props.cornerSmoothing !== undefined) o.cornerSmoothing = props.cornerSmoothing as number if (props.opacity !== undefined) o.opacity = props.opacity as number if (props.rotate !== undefined) o.rotation = props.rotate as number if (props.blendMode !== undefined) { o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode'] } if (props.overflow === 'hidden') o.clipsContent = true } function applyPaddingOverrides(props: Record, o: Partial): void { const p = props.p ?? props.padding if (typeof p === 'number') { o.paddingTop = p o.paddingRight = p o.paddingBottom = p o.paddingLeft = p } const px = props.px as number | undefined const py = props.py as number | undefined if (px !== undefined) { o.paddingLeft = px o.paddingRight = px } if (py !== undefined) { o.paddingTop = py o.paddingBottom = py } if (props.pt !== undefined) o.paddingTop = props.pt as number if (props.pr !== undefined) o.paddingRight = props.pr as number if (props.pb !== undefined) o.paddingBottom = props.pb as number if (props.pl !== undefined) o.paddingLeft = props.pl as number } const PADDING_KEYS = ['p', 'padding', 'px', 'py', 'pt', 'pr', 'pb', 'pl'] as const const AUTO_LAYOUT_TRIGGER_KEYS = [...PADDING_KEYS, 'justify', 'items'] as const function hasAutoLayoutTriggerProps(props: Record): boolean { return AUTO_LAYOUT_TRIGGER_KEYS.some((k) => props[k] !== undefined) } function parseTrack(token: string): GridTrack { if (token.endsWith('fr')) { return { sizing: 'FR', value: parseFloat(token) || 1 } } if (token === 'auto') { return { sizing: 'AUTO', value: 0 } } return { sizing: 'FIXED', value: parseFloat(token) || 0 } } function parseTrackList(value: string): GridTrack[] { return value.trim().split(/\s+/).map(parseTrack) } function applyGridOverrides( props: Record, o: Partial, w: unknown, h: unknown ): void { o.layoutMode = 'GRID' if (typeof w === 'number') o.width = w if (typeof h === 'number') o.height = h if (typeof props.columns === 'string') { o.gridTemplateColumns = parseTrackList(props.columns) } else if (typeof props.columns === 'number') { o.gridTemplateColumns = Array.from({ length: props.columns }, () => ({ sizing: 'FR' as const, value: 1 })) } if (typeof props.rows === 'string') { o.gridTemplateRows = parseTrackList(props.rows) } else if (typeof props.rows === 'number') { o.gridTemplateRows = Array.from({ length: props.rows }, () => ({ sizing: 'FR' as const, value: 1 })) } if (typeof props.columnGap === 'number') o.gridColumnGap = props.columnGap if (typeof props.rowGap === 'number') o.gridRowGap = props.rowGap if (typeof props.gap === 'number') { o.gridColumnGap = props.gap o.gridRowGap = props.gap } if (props.rows === undefined && typeof h !== 'number') { o.height = 0 } } function applyGridChildOverrides(props: Record, o: Partial): void { const col = props.colStart ?? props.col const row = props.rowStart ?? props.row const colSpan = (props.colSpan as number | undefined) ?? 1 const rowSpan = (props.rowSpan as number | undefined) ?? 1 if (col !== undefined || row !== undefined) { o.gridPosition = { column: (col as number | undefined) ?? 0, row: (row as number | undefined) ?? 0, columnSpan: colSpan, rowSpan: rowSpan } } } function applyAutoLayoutSizing( o: Partial, props: Record, w: unknown, h: unknown ): void { const dir = (props.flex as string | undefined) ?? 'col' const isVertical = dir === 'col' || dir === 'column' o.layoutMode = (isVertical ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode o.primaryAxisSizing = 'HUG' o.counterAxisSizing = 'HUG' const primaryDim = isVertical ? h : w const counterDim = isVertical ? w : h if (typeof primaryDim === 'number') o.primaryAxisSizing = 'FIXED' if (typeof counterDim === 'number') o.counterAxisSizing = 'FIXED' if (primaryDim === 'hug') o.primaryAxisSizing = 'HUG' if (counterDim === 'hug') o.counterAxisSizing = 'HUG' } function shouldEnableAutoLayout( props: Record, isText: boolean, childCount: number ): boolean { if (props.flex !== undefined) return true if (!isText && hasAutoLayoutTriggerProps(props)) return true if (!isText && !props.grid && childCount >= 2) return true return false } function applyLayoutOverrides( props: Record, o: Partial, w: unknown, h: unknown, isText: boolean, parentLayout: SceneNode['layoutMode'], childCount: number ): void { if (props.grid) { applyGridOverrides(props, o, w, h) applyPaddingOverrides(props, o) if (props.grow !== undefined) o.layoutGrow = props.grow as number return } if (parentLayout === 'GRID') { applyGridChildOverrides(props, o) } if (shouldEnableAutoLayout(props, isText, childCount)) { applyAutoLayoutSizing(o, props, w, h) } if (props.gap !== undefined) o.itemSpacing = props.gap as number if (props.wrap) { o.layoutWrap = 'WRAP' if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number } if (props.justify) { o.primaryAxisAlign = ALIGN_MAP[props.justify as string] ?? 'MIN' } if (props.items) { o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN' } applyPaddingOverrides(props, o) if (props.grow !== undefined) o.layoutGrow = props.grow as number if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number) if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number) } function applyTextStyleOverrides(props: Record, o: Partial): void { const fontSize = props.size ?? props.fontSize if (typeof fontSize === 'number') o.fontSize = fontSize const fontFamily = props.font ?? props.fontFamily if (typeof fontFamily === 'string') o.fontFamily = fontFamily const weight = props.weight ?? props.fontWeight if (typeof weight === 'number') { o.fontWeight = weight } else if (typeof weight === 'string') { o.fontWeight = WEIGHT_MAP[weight] ?? 400 } if (typeof props.color === 'string') { o.fills = [colorToFill(props.color)] } if (props.lineHeight !== undefined) o.lineHeight = props.lineHeight as number if (props.letterSpacing !== undefined) o.letterSpacing = props.letterSpacing as number if (props.textDecoration !== undefined) o.textDecoration = (props.textDecoration as string).toUpperCase() as SceneNode['textDecoration'] if (props.textCase !== undefined) o.textCase = (props.textCase as string).toUpperCase() as SceneNode['textCase'] if (props.maxLines !== undefined) { o.maxLines = props.maxLines as number o.textTruncation = 'ENDING' } if (props.truncate) { o.textTruncation = 'ENDING' } if (props.textAlign) { o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT' } } function applyTextAutoResize( props: Record, o: Partial, parentLayout: SceneNode['layoutMode'] ): void { const w = props.w ?? props.width const hasExplicitWidth = w !== undefined const fillsParent = w === 'fill' || (props.grow as number) > 0 const isInsideAutoLayout = parentLayout !== 'NONE' // DO NOT CHANGE these defaults without testing headless layout (no CanvasKit). // WIDTH_AND_HEIGHT relies on MeasureFunc — without it, text keeps the 100×100 // default SceneNode size and blows up every HUG container. layout.ts has a // fallback estimator, but changing this logic can silently break all JSX rendering. if (props.textAutoResize) { o.textAutoResize = TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE' } else if (hasExplicitWidth || (isInsideAutoLayout && fillsParent)) { o.textAutoResize = 'HEIGHT' } else { o.textAutoResize = 'WIDTH_AND_HEIGHT' } } function applyTextOverrides( props: Record, o: Partial, parentLayout: SceneNode['layoutMode'] ): void { applyTextStyleOverrides(props, o) applyTextAutoResize(props, o, parentLayout) } function applyShapeAndEffectOverrides(props: Record, o: Partial): void { if (props.points !== undefined) o.pointCount = props.points as number if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number if (props.pointCount !== undefined) o.pointCount = props.pointCount as number if (typeof props.shadow === 'string') { const parts = props.shadow.split(/\s+/) if (parts.length >= 4) { const c = parseColor(parts.slice(3).join(' ')) o.effects = [ ...(o.effects ?? []), { type: 'DROP_SHADOW', color: c, offset: { x: parseFloat(parts[0]), y: parseFloat(parts[1]) }, radius: parseFloat(parts[2]), spread: 0, visible: true } ] } } if (typeof props.blur === 'number') { o.effects = [ ...(o.effects ?? []), { type: 'LAYER_BLUR', radius: props.blur, visible: true, color: { ...TRANSPARENT }, offset: { x: 0, y: 0 }, spread: 0 } ] } } function propsToOverrides( props: Record, isText: boolean, parentLayout: SceneNode['layoutMode'], childCount = 0 ): Partial { const o: Partial = {} if (props.name) o.name = props.name as string const { w, h } = applySizeOverrides(props, o, parentLayout) applyVisualOverrides(props, o) applyLayoutOverrides(props, o, w, h, isText, parentLayout, childCount) if (isText) applyTextOverrides(props, o, parentLayout) applyShapeAndEffectOverrides(props, o) return o }