mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
feat(codegen): add React and HTML code generation
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.
This commit is contained in:
parent
6a7a0d0019
commit
23aa080979
4 changed files with 920 additions and 0 deletions
131
src/components/panels/code-panel.tsx
Normal file
131
src/components/panels/code-panel.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { Copy, Check, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { generateReactCode } from '@/services/codegen/react-generator'
|
||||
import { generateHTMLCode } from '@/services/codegen/html-generator'
|
||||
import { highlightCode } from '@/utils/syntax-highlight'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
|
||||
type CodeTab = 'react' | 'html'
|
||||
|
||||
export default function CodePanel({ onClose }: { onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = useState<CodeTab>('react')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
|
||||
const children = useDocumentStore((s) => s.document.children)
|
||||
const getNodeById = useDocumentStore((s) => s.getNodeById)
|
||||
|
||||
// Force re-render when document changes
|
||||
void children
|
||||
|
||||
const targetNodes: PenNode[] = useMemo(() => {
|
||||
if (selectedIds.length > 0) {
|
||||
return selectedIds
|
||||
.map((id) => getNodeById(id))
|
||||
.filter((n): n is PenNode => n !== undefined)
|
||||
}
|
||||
return children
|
||||
}, [selectedIds, children, getNodeById])
|
||||
|
||||
const generatedCode = useMemo(() => {
|
||||
if (activeTab === 'react') {
|
||||
return generateReactCode(targetNodes)
|
||||
}
|
||||
const { html, css } = generateHTMLCode(targetNodes)
|
||||
return `<!-- HTML -->\n${html}\n\n/* CSS */\n${css}`
|
||||
}, [activeTab, targetNodes])
|
||||
|
||||
const highlightedHTML = useMemo(() => {
|
||||
if (activeTab === 'react') {
|
||||
return highlightCode(generatedCode, 'jsx')
|
||||
}
|
||||
// Split HTML and CSS sections for mixed highlighting
|
||||
const htmlEnd = generatedCode.indexOf('\n\n/* CSS */')
|
||||
if (htmlEnd === -1) return highlightCode(generatedCode, 'html')
|
||||
const htmlPart = generatedCode.slice(0, htmlEnd)
|
||||
const cssPart = generatedCode.slice(htmlEnd + 2)
|
||||
return highlightCode(htmlPart, 'html') + '\n\n' + highlightCode(cssPart, 'css')
|
||||
}, [activeTab, generatedCode])
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(generatedCode).then(() => {
|
||||
setCopied(true)
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}, [generatedCode])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tabs: { key: CodeTab; label: string }[] = [
|
||||
{ key: 'react', label: 'React + Tailwind' },
|
||||
{ key: 'html', label: 'HTML + CSS' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-card border-t border-border flex flex-col" style={{ height: 280 }}>
|
||||
{/* Header */}
|
||||
<div className="h-8 flex items-center px-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'text-xs px-2 py-1 rounded transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
title="Close code panel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
<pre className="text-xs leading-relaxed font-mono text-foreground/80 whitespace-pre">
|
||||
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="h-6 flex items-center px-3 border-t border-border shrink-0">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{selectedIds.length > 0
|
||||
? `Generating code for ${selectedIds.length} selected element${selectedIds.length > 1 ? 's' : ''}`
|
||||
: 'Generating code for entire document'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
326
src/services/codegen/html-generator.ts
Normal file
326
src/services/codegen/html-generator.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import type { PenDocument, PenNode, ContainerProps, TextNode } from '@/types/pen'
|
||||
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles'
|
||||
|
||||
/**
|
||||
* Converts PenDocument nodes to HTML + CSS.
|
||||
*/
|
||||
|
||||
let classCounter = 0
|
||||
|
||||
function resetClassCounter() {
|
||||
classCounter = 0
|
||||
}
|
||||
|
||||
function nextClassName(prefix: string): string {
|
||||
classCounter++
|
||||
return `${prefix}-${classCounter}`
|
||||
}
|
||||
|
||||
function indent(depth: number): string {
|
||||
return ' '.repeat(depth)
|
||||
}
|
||||
|
||||
function fillToCSS(fills: PenFill[] | undefined): Record<string, string> {
|
||||
if (!fills || fills.length === 0) return {}
|
||||
const fill = fills[0]
|
||||
if (fill.type === 'solid') {
|
||||
return { background: fill.color }
|
||||
}
|
||||
if (fill.type === 'linear_gradient') {
|
||||
const angle = fill.angle ?? 180
|
||||
const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')
|
||||
return { background: `linear-gradient(${angle}deg, ${stops})` }
|
||||
}
|
||||
if (fill.type === 'radial_gradient') {
|
||||
const stops = fill.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')
|
||||
return { background: `radial-gradient(circle, ${stops})` }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function strokeToCSS(stroke: PenStroke | undefined): Record<string, string> {
|
||||
if (!stroke) return {}
|
||||
const css: Record<string, string> = {}
|
||||
const thickness = typeof stroke.thickness === 'number'
|
||||
? stroke.thickness
|
||||
: stroke.thickness[0]
|
||||
css['border-width'] = `${thickness}px`
|
||||
css['border-style'] = 'solid'
|
||||
if (stroke.fill && stroke.fill.length > 0) {
|
||||
const sf = stroke.fill[0]
|
||||
if (sf.type === 'solid') {
|
||||
css['border-color'] = sf.color
|
||||
}
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
function effectsToCSS(effects: PenEffect[] | undefined): Record<string, string> {
|
||||
if (!effects || effects.length === 0) return {}
|
||||
const shadows: string[] = []
|
||||
for (const effect of effects) {
|
||||
if (effect.type === 'shadow') {
|
||||
const s = effect as ShadowEffect
|
||||
const inset = s.inner ? 'inset ' : ''
|
||||
shadows.push(`${inset}${s.offsetX}px ${s.offsetY}px ${s.blur}px ${s.spread}px ${s.color}`)
|
||||
}
|
||||
}
|
||||
if (shadows.length > 0) {
|
||||
return { 'box-shadow': shadows.join(', ') }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function cornerRadiusToCSS(
|
||||
cr: number | [number, number, number, number] | undefined,
|
||||
): Record<string, string> {
|
||||
if (cr === undefined) return {}
|
||||
if (typeof cr === 'number') {
|
||||
return cr === 0 ? {} : { 'border-radius': `${cr}px` }
|
||||
}
|
||||
return { 'border-radius': `${cr[0]}px ${cr[1]}px ${cr[2]}px ${cr[3]}px` }
|
||||
}
|
||||
|
||||
function layoutToCSS(node: ContainerProps): Record<string, string> {
|
||||
const css: Record<string, string> = {}
|
||||
if (node.layout === 'vertical') {
|
||||
css.display = 'flex'
|
||||
css['flex-direction'] = 'column'
|
||||
} else if (node.layout === 'horizontal') {
|
||||
css.display = 'flex'
|
||||
css['flex-direction'] = 'row'
|
||||
}
|
||||
if (node.gap !== undefined && typeof node.gap === 'number') {
|
||||
css.gap = `${node.gap}px`
|
||||
}
|
||||
if (node.padding !== undefined) {
|
||||
if (typeof node.padding === 'number') {
|
||||
css.padding = `${node.padding}px`
|
||||
} else if (Array.isArray(node.padding)) {
|
||||
css.padding = node.padding.map((p) => `${p}px`).join(' ')
|
||||
}
|
||||
}
|
||||
if (node.justifyContent) {
|
||||
const map: Record<string, string> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
space_between: 'space-between',
|
||||
space_around: 'space-around',
|
||||
}
|
||||
css['justify-content'] = map[node.justifyContent] ?? node.justifyContent
|
||||
}
|
||||
if (node.alignItems) {
|
||||
const map: Record<string, string> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
}
|
||||
css['align-items'] = map[node.alignItems] ?? node.alignItems
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
interface CSSRule {
|
||||
className: string
|
||||
properties: Record<string, string>
|
||||
}
|
||||
|
||||
function getTextContent(node: TextNode): string {
|
||||
if (typeof node.content === 'string') return node.content
|
||||
return node.content.map((s) => s.text).join('')
|
||||
}
|
||||
|
||||
function escapeHTML(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function generateNodeHTML(
|
||||
node: PenNode,
|
||||
depth: number,
|
||||
rules: CSSRule[],
|
||||
): string {
|
||||
const pad = indent(depth)
|
||||
const css: Record<string, string> = {}
|
||||
|
||||
// Position
|
||||
if (node.x !== undefined || node.y !== undefined) {
|
||||
css.position = 'absolute'
|
||||
if (node.x !== undefined) css.left = `${node.x}px`
|
||||
if (node.y !== undefined) css.top = `${node.y}px`
|
||||
}
|
||||
|
||||
// Opacity
|
||||
if (node.opacity !== undefined && node.opacity !== 1 && typeof node.opacity === 'number') {
|
||||
css.opacity = String(node.opacity)
|
||||
}
|
||||
|
||||
// Rotation
|
||||
if (node.rotation) {
|
||||
css.transform = `rotate(${node.rotation}deg)`
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'frame':
|
||||
case 'rectangle':
|
||||
case 'group': {
|
||||
if (typeof node.width === 'number') css.width = `${node.width}px`
|
||||
if (typeof node.height === 'number') css.height = `${node.height}px`
|
||||
Object.assign(css, fillToCSS(node.fill))
|
||||
Object.assign(css, strokeToCSS(node.stroke))
|
||||
Object.assign(css, cornerRadiusToCSS(node.cornerRadius))
|
||||
Object.assign(css, effectsToCSS(node.effects))
|
||||
Object.assign(css, layoutToCSS(node))
|
||||
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
|
||||
rules.push({ className, properties: css })
|
||||
|
||||
const children = node.children ?? []
|
||||
if (children.length === 0) {
|
||||
return `${pad}<div class="${className}"></div>`
|
||||
}
|
||||
const childrenHTML = children
|
||||
.map((c) => generateNodeHTML(c, depth + 1, rules))
|
||||
.join('\n')
|
||||
return `${pad}<div class="${className}">\n${childrenHTML}\n${pad}</div>`
|
||||
}
|
||||
|
||||
case 'ellipse': {
|
||||
if (typeof node.width === 'number') css.width = `${node.width}px`
|
||||
if (typeof node.height === 'number') css.height = `${node.height}px`
|
||||
css['border-radius'] = '50%'
|
||||
Object.assign(css, fillToCSS(node.fill))
|
||||
Object.assign(css, strokeToCSS(node.stroke))
|
||||
Object.assign(css, effectsToCSS(node.effects))
|
||||
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'ellipse')
|
||||
rules.push({ className, properties: css })
|
||||
return `${pad}<div class="${className}"></div>`
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
if (typeof node.width === 'number') css.width = `${node.width}px`
|
||||
if (typeof node.height === 'number') css.height = `${node.height}px`
|
||||
if (node.fill) {
|
||||
const fill = node.fill[0]
|
||||
if (fill?.type === 'solid') css.color = fill.color
|
||||
}
|
||||
if (node.fontSize) css['font-size'] = `${node.fontSize}px`
|
||||
if (node.fontWeight) css['font-weight'] = String(node.fontWeight)
|
||||
if (node.fontStyle === 'italic') css['font-style'] = 'italic'
|
||||
if (node.textAlign) css['text-align'] = node.textAlign
|
||||
if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
|
||||
if (node.lineHeight) css['line-height'] = String(node.lineHeight)
|
||||
if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
|
||||
if (node.underline) css['text-decoration'] = 'underline'
|
||||
if (node.strikethrough) css['text-decoration'] = 'line-through'
|
||||
Object.assign(css, effectsToCSS(node.effects))
|
||||
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'text')
|
||||
rules.push({ className, properties: css })
|
||||
|
||||
const size = node.fontSize ?? 16
|
||||
const tag = size >= 32 ? 'h1' : size >= 24 ? 'h2' : size >= 20 ? 'h3' : 'p'
|
||||
const text = escapeHTML(getTextContent(node))
|
||||
return `${pad}<${tag} class="${className}">${text}</${tag}>`
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0
|
||||
css.width = `${w}px`
|
||||
if (node.stroke) {
|
||||
const thickness = typeof node.stroke.thickness === 'number'
|
||||
? node.stroke.thickness
|
||||
: node.stroke.thickness[0]
|
||||
css['border-top-width'] = `${thickness}px`
|
||||
css['border-top-style'] = 'solid'
|
||||
if (node.stroke.fill && node.stroke.fill.length > 0) {
|
||||
const sf = node.stroke.fill[0]
|
||||
if (sf.type === 'solid') css['border-top-color'] = sf.color
|
||||
}
|
||||
}
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line')
|
||||
rules.push({ className, properties: css })
|
||||
return `${pad}<hr class="${className}" />`
|
||||
}
|
||||
|
||||
case 'polygon':
|
||||
case 'path': {
|
||||
if (typeof node.width === 'number') css.width = `${node.width}px`
|
||||
if (typeof node.height === 'number') css.height = `${node.height}px`
|
||||
Object.assign(css, fillToCSS(node.fill))
|
||||
const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type)
|
||||
rules.push({ className, properties: css })
|
||||
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 class="${className}" viewBox="0 0 ${w} ${h}">\n${pad} <path d="${node.d}" fill="${fillColor}" />\n${pad}</svg>`
|
||||
}
|
||||
return `${pad}<div class="${className}"></div>`
|
||||
}
|
||||
|
||||
case 'ref':
|
||||
return `${pad}<!-- Ref: ${node.ref} -->`
|
||||
|
||||
default:
|
||||
return `${pad}<!-- Unknown node -->`
|
||||
}
|
||||
}
|
||||
|
||||
function cssRulesToString(rules: CSSRule[]): string {
|
||||
return rules
|
||||
.map((r) => {
|
||||
const props = Object.entries(r.properties)
|
||||
.map(([k, v]) => ` ${k}: ${v};`)
|
||||
.join('\n')
|
||||
return `.${r.className} {\n${props}\n}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export function generateHTMLCode(nodes: PenNode[]): { html: string; css: string } {
|
||||
resetClassCounter()
|
||||
const rules: CSSRule[] = []
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
html: '<div class="container"></div>',
|
||||
css: '.container {\n position: relative;\n}',
|
||||
}
|
||||
}
|
||||
|
||||
// Compute wrapper size
|
||||
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 containerCSS: Record<string, string> = { position: 'relative' }
|
||||
if (maxW > 0) containerCSS.width = `${maxW}px`
|
||||
if (maxH > 0) containerCSS.height = `${maxH}px`
|
||||
rules.push({ className: 'container', properties: containerCSS })
|
||||
|
||||
const childrenHTML = nodes
|
||||
.map((n) => generateNodeHTML(n, 1, rules))
|
||||
.join('\n')
|
||||
|
||||
const html = `<div class="container">\n${childrenHTML}\n</div>`
|
||||
const css = cssRulesToString(rules)
|
||||
|
||||
return { html, css }
|
||||
}
|
||||
|
||||
export function generateHTMLFromDocument(doc: PenDocument): { html: string; css: string } {
|
||||
return generateHTMLCode(doc.children)
|
||||
}
|
||||
331
src/services/codegen/react-generator.ts
Normal file
331
src/services/codegen/react-generator.ts
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
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)
|
||||
}
|
||||
132
src/utils/syntax-highlight.ts
Normal file
132
src/utils/syntax-highlight.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Lightweight syntax highlighter for JSX, HTML, and CSS.
|
||||
* Produces HTML strings with color spans.
|
||||
*/
|
||||
|
||||
interface TokenRule {
|
||||
pattern: RegExp
|
||||
className: string
|
||||
}
|
||||
|
||||
const JSX_RULES: TokenRule[] = [
|
||||
// Multi-line comments
|
||||
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
|
||||
// Single-line comments
|
||||
{ pattern: /\/\/.*/g, className: 'syn-comment' },
|
||||
// JSX self-closing tags: <Tag ... />
|
||||
{ pattern: /<\/?[A-Za-z][A-Za-z0-9.]*/g, className: 'syn-tag' },
|
||||
// Closing bracket
|
||||
{ pattern: /\/?>/g, className: 'syn-tag' },
|
||||
// Strings (double and single quoted)
|
||||
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
|
||||
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
|
||||
// Template literals
|
||||
{ pattern: /`(?:[^`\\]|\\.)*`/g, className: 'syn-string' },
|
||||
// Keywords
|
||||
{ pattern: /\b(export|function|return|const|let|var|import|from|default|if|else|for|while|switch|case|break|continue|new|this|class|extends|typeof|instanceof|void|null|undefined|true|false)\b/g, className: 'syn-keyword' },
|
||||
// Attribute names (word followed by =)
|
||||
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
|
||||
// Numbers
|
||||
{ pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' },
|
||||
// Curly braces in JSX
|
||||
{ pattern: /[{}]/g, className: 'syn-bracket' },
|
||||
]
|
||||
|
||||
const HTML_RULES: TokenRule[] = [
|
||||
// Comments
|
||||
{ pattern: /<!--[\s\S]*?-->/g, className: 'syn-comment' },
|
||||
// Tags
|
||||
{ pattern: /<\/?[a-zA-Z][a-zA-Z0-9-]*/g, className: 'syn-tag' },
|
||||
{ pattern: /\/?>/g, className: 'syn-tag' },
|
||||
// Attribute values
|
||||
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
|
||||
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
|
||||
// Attribute names
|
||||
{ pattern: /\b[a-zA-Z-]+(?==)/g, className: 'syn-attr' },
|
||||
]
|
||||
|
||||
const CSS_RULES: TokenRule[] = [
|
||||
// Comments
|
||||
{ pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' },
|
||||
// Selectors (class/id/element)
|
||||
{ pattern: /[.#]?[a-zA-Z_-][a-zA-Z0-9_-]*(?=\s*\{)/g, className: 'syn-tag' },
|
||||
// Property names
|
||||
{ pattern: /[a-zA-Z-]+(?=\s*:)/g, className: 'syn-attr' },
|
||||
// Strings
|
||||
{ pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' },
|
||||
{ pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' },
|
||||
// Numbers with units
|
||||
{ pattern: /\b\d+\.?\d*(px|em|rem|%|deg|vh|vw|s|ms)?\b/g, className: 'syn-number' },
|
||||
// Colors
|
||||
{ pattern: /#[0-9a-fA-F]{3,8}\b/g, className: 'syn-string' },
|
||||
// Braces
|
||||
{ pattern: /[{}]/g, className: 'syn-bracket' },
|
||||
// Keywords
|
||||
{ pattern: /\b(important|inherit|initial|unset|none|auto|solid|dashed|flex|grid|block|inline|absolute|relative|fixed|sticky)\b/g, className: 'syn-keyword' },
|
||||
]
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
interface Token {
|
||||
start: number
|
||||
end: number
|
||||
className: string
|
||||
}
|
||||
|
||||
function tokenize(code: string, rules: TokenRule[]): Token[] {
|
||||
const tokens: Token[] = []
|
||||
for (const rule of rules) {
|
||||
const regex = new RegExp(rule.pattern.source, rule.pattern.flags)
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = regex.exec(code)) !== null) {
|
||||
tokens.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
className: rule.className,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Sort by start position; earlier rules win ties (priority order)
|
||||
tokens.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Remove overlapping tokens (first match wins)
|
||||
const filtered: Token[] = []
|
||||
let lastEnd = 0
|
||||
for (const token of tokens) {
|
||||
if (token.start >= lastEnd) {
|
||||
filtered.push(token)
|
||||
lastEnd = token.end
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
function renderTokens(code: string, tokens: Token[]): string {
|
||||
let result = ''
|
||||
let pos = 0
|
||||
for (const token of tokens) {
|
||||
if (token.start > pos) {
|
||||
result += escapeHtml(code.slice(pos, token.start))
|
||||
}
|
||||
result += `<span class="${token.className}">${escapeHtml(code.slice(token.start, token.end))}</span>`
|
||||
pos = token.end
|
||||
}
|
||||
if (pos < code.length) {
|
||||
result += escapeHtml(code.slice(pos))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export type SyntaxLanguage = 'jsx' | 'html' | 'css'
|
||||
|
||||
export function highlightCode(code: string, language: SyntaxLanguage): string {
|
||||
const rules = language === 'jsx' ? JSX_RULES : language === 'html' ? HTML_RULES : CSS_RULES
|
||||
const tokens = tokenize(code, rules)
|
||||
return renderTokens(code, tokens)
|
||||
}
|
||||
Loading…
Reference in a new issue