mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
Implement code generators that convert PenNode trees to React+Tailwind and HTML+CSS output. Add code panel with tab switching, syntax highlighting, and copy-to-clipboard. Supports generating code for selected elements or the entire document.
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
|
|
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
|
|
|
|
/**
|
|
* Converts PenDocument nodes to React + Tailwind code.
|
|
*/
|
|
|
|
function indent(depth: number): string {
|
|
return ' '.repeat(depth)
|
|
}
|
|
|
|
function fillToTailwind(fills: PenFill[] | undefined): string[] {
|
|
if (!fills || fills.length === 0) return []
|
|
const fill = fills[0]
|
|
if (fill.type === 'solid') {
|
|
return [`bg-[${fill.color}]`]
|
|
}
|
|
return []
|
|
}
|
|
|
|
function fillToTextColor(fills: PenFill[] | undefined): string[] {
|
|
if (!fills || fills.length === 0) return []
|
|
const fill = fills[0]
|
|
if (fill.type === 'solid') {
|
|
return [`text-[${fill.color}]`]
|
|
}
|
|
return []
|
|
}
|
|
|
|
function strokeToTailwind(stroke: PenStroke | undefined): string[] {
|
|
if (!stroke) return []
|
|
const classes: string[] = []
|
|
const thickness = typeof stroke.thickness === 'number'
|
|
? stroke.thickness
|
|
: stroke.thickness[0]
|
|
classes.push('border', `border-[${thickness}px]`)
|
|
if (stroke.fill && stroke.fill.length > 0) {
|
|
const sf = stroke.fill[0]
|
|
if (sf.type === 'solid') {
|
|
classes.push(`border-[${sf.color}]`)
|
|
}
|
|
}
|
|
return classes
|
|
}
|
|
|
|
function effectsToTailwind(effects: PenEffect[] | undefined): string[] {
|
|
if (!effects || effects.length === 0) return []
|
|
const classes: string[] = []
|
|
for (const effect of effects) {
|
|
if (effect.type === 'shadow') {
|
|
const s = effect as ShadowEffect
|
|
classes.push(`shadow-[${s.offsetX}px_${s.offsetY}px_${s.blur}px_${s.spread}px_${s.color}]`)
|
|
}
|
|
}
|
|
return classes
|
|
}
|
|
|
|
function cornerRadiusToTailwind(
|
|
cr: number | [number, number, number, number] | undefined,
|
|
): string[] {
|
|
if (cr === undefined) return []
|
|
if (typeof cr === 'number') {
|
|
if (cr === 0) return []
|
|
return [`rounded-[${cr}px]`]
|
|
}
|
|
const [tl, tr, br, bl] = cr
|
|
if (tl === tr && tr === br && br === bl) {
|
|
return tl === 0 ? [] : [`rounded-[${tl}px]`]
|
|
}
|
|
return [`rounded-[${tl}px_${tr}px_${br}px_${bl}px]`]
|
|
}
|
|
|
|
function layoutToTailwind(node: ContainerProps): string[] {
|
|
const classes: string[] = []
|
|
if (node.layout === 'vertical') {
|
|
classes.push('flex', 'flex-col')
|
|
} else if (node.layout === 'horizontal') {
|
|
classes.push('flex', 'flex-row')
|
|
}
|
|
if (node.gap !== undefined && typeof node.gap === 'number' && node.gap > 0) {
|
|
classes.push(`gap-[${node.gap}px]`)
|
|
}
|
|
if (node.padding !== undefined) {
|
|
if (typeof node.padding === 'number') {
|
|
classes.push(`p-[${node.padding}px]`)
|
|
} else if (Array.isArray(node.padding)) {
|
|
if (node.padding.length === 2) {
|
|
classes.push(`py-[${node.padding[0]}px]`, `px-[${node.padding[1]}px]`)
|
|
} else if (node.padding.length === 4) {
|
|
classes.push(
|
|
`pt-[${node.padding[0]}px]`,
|
|
`pr-[${node.padding[1]}px]`,
|
|
`pb-[${node.padding[2]}px]`,
|
|
`pl-[${node.padding[3]}px]`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if (node.justifyContent) {
|
|
const jcMap: Record<string, string> = {
|
|
start: 'justify-start',
|
|
center: 'justify-center',
|
|
end: 'justify-end',
|
|
space_between: 'justify-between',
|
|
space_around: 'justify-around',
|
|
}
|
|
if (jcMap[node.justifyContent]) classes.push(jcMap[node.justifyContent])
|
|
}
|
|
if (node.alignItems) {
|
|
const aiMap: Record<string, string> = {
|
|
start: 'items-start',
|
|
center: 'items-center',
|
|
end: 'items-end',
|
|
}
|
|
if (aiMap[node.alignItems]) classes.push(aiMap[node.alignItems])
|
|
}
|
|
return classes
|
|
}
|
|
|
|
function sizeToTailwind(
|
|
width: number | string | undefined,
|
|
height: number | string | undefined,
|
|
): string[] {
|
|
const classes: string[] = []
|
|
if (typeof width === 'number') classes.push(`w-[${width}px]`)
|
|
if (typeof height === 'number') classes.push(`h-[${height}px]`)
|
|
return classes
|
|
}
|
|
|
|
function opacityToTailwind(opacity: number | string | undefined): string[] {
|
|
if (opacity === undefined || opacity === 1) return []
|
|
if (typeof opacity === 'number') {
|
|
const pct = Math.round(opacity * 100)
|
|
return [`opacity-[${pct}%]`]
|
|
}
|
|
return []
|
|
}
|
|
|
|
function textTag(node: TextNode): string {
|
|
const size = node.fontSize ?? 16
|
|
if (size >= 32) return 'h1'
|
|
if (size >= 24) return 'h2'
|
|
if (size >= 20) return 'h3'
|
|
return 'p'
|
|
}
|
|
|
|
function getTextContent(node: TextNode): string {
|
|
if (typeof node.content === 'string') return node.content
|
|
return node.content.map((s) => s.text).join('')
|
|
}
|
|
|
|
function textToTailwind(node: TextNode): string[] {
|
|
const classes: string[] = []
|
|
if (node.fontSize) classes.push(`text-[${node.fontSize}px]`)
|
|
if (node.fontWeight) {
|
|
const w = typeof node.fontWeight === 'number' ? node.fontWeight : parseInt(node.fontWeight, 10)
|
|
if (!isNaN(w)) classes.push(`font-[${w}]`)
|
|
}
|
|
if (node.fontStyle === 'italic') classes.push('italic')
|
|
if (node.textAlign) {
|
|
const taMap: Record<string, string> = {
|
|
left: 'text-left',
|
|
center: 'text-center',
|
|
right: 'text-right',
|
|
justify: 'text-justify',
|
|
}
|
|
if (taMap[node.textAlign]) classes.push(taMap[node.textAlign])
|
|
}
|
|
if (node.fontFamily) classes.push(`font-['${node.fontFamily.replace(/\s/g, '_')}']`)
|
|
if (node.lineHeight) classes.push(`leading-[${node.lineHeight}]`)
|
|
if (node.letterSpacing) classes.push(`tracking-[${node.letterSpacing}px]`)
|
|
if (node.underline) classes.push('underline')
|
|
if (node.strikethrough) classes.push('line-through')
|
|
return classes
|
|
}
|
|
|
|
function generateNodeJSX(node: PenNode, depth: number): string {
|
|
const pad = indent(depth)
|
|
const classes: string[] = []
|
|
|
|
// Position
|
|
if (node.x !== undefined || node.y !== undefined) {
|
|
classes.push('absolute')
|
|
if (node.x !== undefined) classes.push(`left-[${node.x}px]`)
|
|
if (node.y !== undefined) classes.push(`top-[${node.y}px]`)
|
|
}
|
|
|
|
// Opacity
|
|
classes.push(...opacityToTailwind(node.opacity))
|
|
|
|
// Rotation
|
|
if (node.rotation) {
|
|
classes.push(`rotate-[${node.rotation}deg]`)
|
|
}
|
|
|
|
switch (node.type) {
|
|
case 'frame':
|
|
case 'rectangle':
|
|
case 'group': {
|
|
classes.push(
|
|
...sizeToTailwind(node.width, node.height),
|
|
...fillToTailwind(node.fill),
|
|
...strokeToTailwind(node.stroke),
|
|
...cornerRadiusToTailwind(node.cornerRadius),
|
|
...effectsToTailwind(node.effects),
|
|
...layoutToTailwind(node),
|
|
)
|
|
const childNodes = node.children ?? []
|
|
if (childNodes.length === 0) {
|
|
return `${pad}<div className="${classes.join(' ')}" />`
|
|
}
|
|
const childrenJSX = childNodes
|
|
.map((c) => generateNodeJSX(c, depth + 1))
|
|
.join('\n')
|
|
const comment = node.name ? `${pad}{/* ${node.name} */}\n` : ''
|
|
return `${comment}${pad}<div className="${classes.join(' ')}">\n${childrenJSX}\n${pad}</div>`
|
|
}
|
|
|
|
case 'ellipse': {
|
|
classes.push(
|
|
'rounded-full',
|
|
...sizeToTailwind(node.width, node.height),
|
|
...fillToTailwind(node.fill),
|
|
...strokeToTailwind(node.stroke),
|
|
...effectsToTailwind(node.effects),
|
|
)
|
|
return `${pad}<div className="${classes.join(' ')}" />`
|
|
}
|
|
|
|
case 'text': {
|
|
const tag = textTag(node)
|
|
classes.push(
|
|
...sizeToTailwind(node.width, node.height),
|
|
...fillToTextColor(node.fill),
|
|
...textToTailwind(node),
|
|
...effectsToTailwind(node.effects),
|
|
)
|
|
const text = escapeJSX(getTextContent(node))
|
|
return `${pad}<${tag} className="${classes.join(' ')}">${text}</${tag}>`
|
|
}
|
|
|
|
case 'line': {
|
|
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
|
|
classes.push(`w-[${w}px]`)
|
|
if (node.stroke) {
|
|
const thickness = typeof node.stroke.thickness === 'number'
|
|
? node.stroke.thickness
|
|
: node.stroke.thickness[0]
|
|
classes.push(`border-t-[${thickness}px]`)
|
|
if (node.stroke.fill && node.stroke.fill.length > 0) {
|
|
const sf = node.stroke.fill[0]
|
|
if (sf.type === 'solid') {
|
|
classes.push(`border-[${sf.color}]`)
|
|
}
|
|
}
|
|
}
|
|
return `${pad}<hr className="${classes.join(' ')}" />`
|
|
}
|
|
|
|
case 'polygon':
|
|
case 'path': {
|
|
// For complex shapes, output an SVG inline
|
|
classes.push(...sizeToTailwind(node.width, node.height))
|
|
if (node.type === 'path') {
|
|
const w = typeof node.width === 'number' ? node.width : 100
|
|
const h = typeof node.height === 'number' ? node.height : 100
|
|
const fillColor = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : 'currentColor'
|
|
return `${pad}<svg className="${classes.join(' ')}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
|
|
}
|
|
classes.push(...fillToTailwind(node.fill))
|
|
return `${pad}<div className="${classes.join(' ')}" />`
|
|
}
|
|
|
|
case 'ref':
|
|
return `${pad}{/* Ref: ${node.ref} */}`
|
|
|
|
default:
|
|
return `${pad}{/* Unknown node */}`
|
|
}
|
|
}
|
|
|
|
function escapeJSX(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/{/g, '{')
|
|
.replace(/}/g, '}')
|
|
}
|
|
|
|
export function generateReactCode(
|
|
nodes: PenNode[],
|
|
componentName = 'GeneratedDesign',
|
|
): string {
|
|
if (nodes.length === 0) {
|
|
return `export function ${componentName}() {\n return <div className="relative" />\n}\n`
|
|
}
|
|
|
|
// Find bounding box for the root wrapper
|
|
let maxW = 0
|
|
let maxH = 0
|
|
for (const node of nodes) {
|
|
const x = node.x ?? 0
|
|
const y = node.y ?? 0
|
|
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
maxW = Math.max(maxW, x + w)
|
|
maxH = Math.max(maxH, y + h)
|
|
}
|
|
|
|
const wrapperClasses = ['relative']
|
|
if (maxW > 0) wrapperClasses.push(`w-[${maxW}px]`)
|
|
if (maxH > 0) wrapperClasses.push(`h-[${maxH}px]`)
|
|
|
|
const childrenJSX = nodes
|
|
.map((n) => generateNodeJSX(n, 2))
|
|
.join('\n')
|
|
|
|
return `export function ${componentName}() {
|
|
return (
|
|
<div className="${wrapperClasses.join(' ')}">
|
|
${childrenJSX}
|
|
</div>
|
|
)
|
|
}
|
|
`
|
|
}
|
|
|
|
export function generateReactFromDocument(doc: PenDocument): string {
|
|
return generateReactCode(doc.children)
|
|
}
|