Deduplicate render internals: shared resolveToTree, remove dead code

This commit is contained in:
Danila Poyarkov 2026-03-01 10:45:39 +03:00
parent 13f562adc6
commit a32d80f448
4 changed files with 51 additions and 139 deletions

View file

@ -17,7 +17,7 @@ export {
INTRINSIC_ELEMENTS
} from './components'
export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeNode, node } from './tree'
export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeNode, node, resolveToTree } from './tree'
export { renderTree, type RenderResult } from './renderer'

View file

@ -1,52 +1,9 @@
import * as React from './mini-react'
import { isTreeNode, type TreeNode } from './tree'
import { resolveToTree, type TreeNode } from './tree'
import { renderTree, type RenderResult } from './renderer'
import type { SceneGraph } from '../scene-graph'
interface ReactElement {
type: unknown
props: Record<string, unknown>
}
function isReactElement(x: unknown): x is ReactElement {
return x !== null && typeof x === 'object' && 'type' in x && 'props' in x
}
function resolveToTree(element: unknown, depth = 0): TreeNode | null {
if (depth > 100) throw new Error('Component resolution depth exceeded')
if (isTreeNode(element)) return element
if (isReactElement(element)) {
if (typeof element.type === 'function') {
return resolveToTree(
(element.type as (p: Record<string, unknown>) => unknown)(element.props),
depth + 1
)
}
if (typeof element.type === 'string') {
const children: (TreeNode | string)[] = []
const elChildren = element.props.children
if (elChildren != null) {
const childArray = Array.isArray(elChildren) ? elChildren : [elChildren]
for (const child of childArray.flat()) {
if (child == null) continue
if (typeof child === 'string' || typeof child === 'number') {
children.push(String(child))
} else {
const resolved = resolveToTree(child, depth + 1)
if (resolved) children.push(resolved)
}
}
}
const { children: _, ...props } = element.props
return { type: element.type, props, children }
}
}
return null
}
/**
* Build a component function from a JSX string using esbuild.
* Uses mini-react createElement — no React dependency.

View file

@ -1,6 +1,6 @@
import { parseColor, colorToFill } from '../color'
import type { SceneGraph, SceneNode, NodeType, Fill, LayoutMode, Stroke } from '../scene-graph'
import type { SceneGraph, SceneNode, NodeType, LayoutMode, Stroke } from '../scene-graph'
import type { TreeNode } from './tree'
import { isTreeNode } from './tree'
@ -53,10 +53,6 @@ const TEXT_AUTO_RESIZE_MAP: Record<string, SceneNode['textAutoResize']> = {
height: 'HEIGHT'
}
function parseFill(value: string): Fill {
return colorToFill(value)
}
function parseStroke(value: string, width: number): Stroke {
const color = parseColor(value)
return {
@ -108,7 +104,6 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN
const isText = nodeType === 'TEXT'
const overrides = propsToOverrides(tree.props, isText)
// Text content from children
if (isText) {
const textContent = tree.children
.filter((c): c is string => typeof c === 'string')
@ -118,7 +113,6 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN
const node = graph.createNode(nodeType, parentId, overrides)
// Render child nodes (skip string children — already handled for text)
for (const child of tree.children) {
if (typeof child === 'string') continue
if (isTreeNode(child)) {
@ -132,17 +126,13 @@ function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): SceneN
function propsToOverrides(props: Record<string, unknown>, isText: boolean): Partial<SceneNode> {
const o: Partial<SceneNode> = {}
// Name
if (props.name) o.name = props.name as string
// Size
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
// "fill" and "hug" sizing handled via layout sizing
const hasFlex = props.flex !== undefined
if (w === 'fill') {
o.layoutGrow = 1
o.layoutAlignSelf = 'STRETCH'
@ -151,23 +141,19 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
o.layoutAlignSelf = 'STRETCH'
}
// Position
if (props.x !== undefined) o.x = props.x as number
if (props.y !== undefined) o.y = props.y as number
// Fill / background
const bg = props.bg ?? props.fill
if (typeof bg === 'string') {
o.fills = [parseFill(bg)]
o.fills = [colorToFill(bg)]
}
// Stroke
if (typeof props.stroke === 'string') {
const strokeWidth = (props.strokeWidth as number) ?? 1
o.strokes = [parseStroke(props.stroke, strokeWidth)]
}
// Corner radius
const rounded = props.rounded ?? props.cornerRadius
if (typeof rounded === 'number') {
o.cornerRadius = rounded
@ -182,57 +168,40 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
}
if (props.cornerSmoothing !== undefined) o.cornerSmoothing = props.cornerSmoothing as number
// Opacity
if (props.opacity !== undefined) o.opacity = props.opacity as number
// Rotation
if (props.rotate !== undefined) o.rotation = props.rotate as number
// Blend mode
if (props.blendMode !== undefined) {
o.blendMode = (props.blendMode as string).toUpperCase() as SceneNode['blendMode']
}
// Overflow / clip
if (props.overflow === 'hidden') o.clipsContent = true
// Layout (auto-layout / flexbox)
if (hasFlex) {
if (props.flex !== undefined) {
const dir = props.flex as string
o.layoutMode = (dir === 'col' || dir === 'column' ? 'VERTICAL' : 'HORIZONTAL') as LayoutMode
// Default sizing: hug content
o.primaryAxisSizing = 'HUG'
o.counterAxisSizing = 'HUG'
// Override with explicit w/h
if (typeof w === 'number') o.primaryAxisSizing = 'FIXED'
if (typeof h === 'number') o.counterAxisSizing = 'FIXED'
if (w === 'hug') o.primaryAxisSizing = 'HUG'
if (h === 'hug') o.counterAxisSizing = 'HUG'
}
// Gap / spacing
if (props.gap !== undefined) o.itemSpacing = props.gap as number
// Wrap
if (props.wrap) {
o.layoutWrap = 'WRAP'
if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number
}
// Alignment
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'
if (props.items === 'stretch') {
// Children should stretch — handled at child level
}
}
// Padding
const p = props.p ?? props.padding
if (typeof p === 'number') {
o.paddingTop = p
@ -249,14 +218,11 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
if (props.pb !== undefined) o.paddingBottom = props.pb as number
if (props.pl !== undefined) o.paddingLeft = props.pl as number
// Grow
if (props.grow !== undefined) o.layoutGrow = props.grow as number
// Min/max size — stored as-is, layout engine uses them
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)
// Text properties
if (isText) {
const fontSize = props.size ?? props.fontSize
if (typeof fontSize === 'number') o.fontSize = fontSize
@ -272,50 +238,40 @@ function propsToOverrides(props: Record<string, unknown>, isText: boolean): Part
}
if (typeof props.color === 'string') {
o.fills = [parseFill(props.color)]
o.fills = [colorToFill(props.color)]
}
if (props.textAlign) {
o.textAlignHorizontal = TEXT_ALIGN_MAP[props.textAlign as string] ?? 'LEFT'
}
if (props.textAutoResize) {
o.textAutoResize = TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE'
} else {
// Default: auto-resize height for text nodes
o.textAutoResize = 'HEIGHT'
}
o.textAutoResize = props.textAutoResize
? TEXT_AUTO_RESIZE_MAP[props.textAutoResize as string] ?? 'NONE'
: 'HEIGHT'
}
// Star-specific
if (props.points !== undefined) o.pointCount = props.points as number
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
// Polygon-specific
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number
// Stroke align
if (props.strokeAlign) {
// Stored on the stroke itself, applied during rendering
}
// Effects: shadow and blur
if (typeof props.shadow === 'string') {
const parts = (props.shadow as string).split(/\s+/)
if (parts.length >= 4) {
const [x, y, blur, color] = [
parseFloat(parts[0]!),
parseFloat(parts[1]!),
parseFloat(parts[2]!),
parts.slice(3).join(' ')
]
const c = parseColor(color)
const c = parseColor(parts.slice(3).join(' '))
o.effects = [
...(o.effects ?? []),
{ type: 'DROP_SHADOW', color: c, offset: { x, y }, radius: blur, spread: 0, visible: true }
{
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 ?? []),

View file

@ -19,57 +19,56 @@ function isReactElement(x: unknown): x is ReactElement {
return x !== null && typeof x === 'object' && 'type' in x && 'props' in x
}
function resolveElement(el: ReactElement, depth = 0): TreeNode | null {
/**
* Resolve any element-like value (ReactElement, TreeNode, function component)
* into a TreeNode. Handles recursive function components up to depth 100.
*/
export function resolveToTree(element: unknown, depth = 0): TreeNode | null {
if (depth > 100) throw new Error('Component resolution depth exceeded')
if (isTreeNode(el)) return el
if (element == null) return null
if (isTreeNode(element)) return element
if (!isReactElement(element)) return null
if (typeof el.type === 'function') {
const result = (el.type as (p: Record<string, unknown>) => unknown)(el.props)
if (isTreeNode(result)) return result
if (isReactElement(result)) return resolveElement(result, depth + 1)
if (typeof element.type === 'function') {
return resolveToTree(
(element.type as (p: Record<string, unknown>) => unknown)(element.props),
depth + 1
)
}
if (typeof el.type === 'string') {
return convertToTree(el)
}
return null
}
function convertToTree(el: ReactElement): TreeNode {
const children: (TreeNode | string)[] = []
const elChildren = el.props.children
if (elChildren != null) {
const childArray = Array.isArray(elChildren) ? elChildren : [elChildren]
for (const child of childArray.flat()) {
if (child == null) continue
if (typeof child === 'string' || typeof child === 'number') {
children.push(String(child))
} else if (isReactElement(child)) {
const resolved = resolveElement(child)
if (resolved) children.push(resolved)
if (typeof element.type === 'string') {
const children: (TreeNode | string)[] = []
const elChildren = element.props.children
if (elChildren != null) {
const childArray = Array.isArray(elChildren) ? elChildren : [elChildren]
for (const child of childArray.flat()) {
if (child == null) continue
if (typeof child === 'string' || typeof child === 'number') {
children.push(String(child))
} else {
const resolved = resolveToTree(child, depth + 1)
if (resolved) children.push(resolved)
}
}
}
const { children: _, ...props } = element.props
return { type: element.type, props, children }
}
const { children: _, ...props } = el.props
return { type: el.type as string, props, children }
return null
}
function processChild(child: unknown): TreeNode | string | null {
function resolveChild(child: unknown): TreeNode | string | null {
if (child == null) return null
if (typeof child === 'string' || typeof child === 'number') return String(child)
if (isTreeNode(child)) return child
if (isReactElement(child)) return resolveElement(child)
return null
return resolveToTree(child)
}
export function node(type: string, props: Record<string, unknown>): TreeNode {
const { children, ...rest } = props
const processed = [children]
.flat(Infinity)
.map(processChild)
.map(resolveChild)
.filter((c): c is TreeNode | string => c !== null)
return { type, props: rest, children: processed }
}