Deduplicate render internals: shared resolveToTree, remove dead code
This commit is contained in:
parent
13f562adc6
commit
a32d80f448
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ?? []),
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue