Tailwind CSS v4 JSX export (#54)
* Add Tailwind CSS v4 JSX export format
- Add JSXFormat type ('openpencil' | 'tailwind') to sceneNodeToJSX/selectionToJSX
- Tailwind resolver (tailwind.ts): px→spacing (v4 multiplier), hex→color class,
fontSize/fontWeight/borderRadius named lookups with arbitrary value fallback
- Export: FRAME→div, TEXT→p, SECTION→section, className with TW utility classes
- CLI: export --format jsx --style tailwind
- CodePanel: format toggle button (OpenPencil / Tailwind)
- Rename Jsx→JSX in all public APIs (sceneNodeToJSX, selectionToJSX, renderJSX, etc.)
- 29 new Tailwind export tests, all 43 export tests pass
* Extract escapeJSXText — replace nested ternary with entity map lookup
* Deduplicate export-jsx: extract shared padding, corner radius, and node context helpers
* Update changelog
* Tighten Tailwind JSX export fidelity
This commit is contained in:
parent
324e1460d6
commit
5ae6234e40
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
### Features
|
||||
|
||||
- Tailwind CSS v4 JSX export — export selections as HTML with Tailwind utility classes (`<div className="flex gap-4 p-3">`) from the Code panel, CLI (`bun open-pencil export --format jsx --style tailwind`), or programmatically via `sceneNodeToJSX(id, graph, 'tailwind')`. Supports layout, sizing, colors, border radius, opacity, rotation, overflow, shadows, blur, and typography. Uses v4 spacing semantics (px/4 multiplier) with automatic fallback to arbitrary values.
|
||||
- Code panel format toggle — switch between OpenPencil (custom components) and Tailwind (HTML + utility classes) output
|
||||
- New AI/MCP tools: `analyze_colors`, `analyze_typography`, `analyze_spacing`, `analyze_clusters`, `diff_create`, `diff_show`, `get_components`, `get_current_page`, `arrange`, `node_to_component`
|
||||
|
||||
### Improvements
|
||||
|
|
|
|||
|
|
@ -1,32 +1,40 @@
|
|||
import { defineCommand } from 'citty'
|
||||
import { basename, extname, resolve } from 'node:path'
|
||||
|
||||
import { renderNodesToSVG } from '@open-pencil/core'
|
||||
import { renderNodesToSVG, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
|
||||
|
||||
import { loadDocument, loadFonts, exportNodes, exportThumbnail } from '../headless'
|
||||
import { ok, printError } from '../format'
|
||||
import type { ExportFormat } from '@open-pencil/core'
|
||||
import type { ExportFormat, JSXFormat } from '@open-pencil/core'
|
||||
|
||||
const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP']
|
||||
const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX']
|
||||
const JSX_STYLES = ['openpencil', 'tailwind']
|
||||
|
||||
export default defineCommand({
|
||||
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, or SVG' },
|
||||
meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' },
|
||||
args: {
|
||||
file: { type: 'positional', description: '.fig file path', required: true },
|
||||
output: { type: 'string', alias: 'o', description: 'Output file path (default: <name>.<format>)' },
|
||||
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg (default: png)', default: 'png' },
|
||||
format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' },
|
||||
scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' },
|
||||
quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' },
|
||||
page: { type: 'string', description: 'Page name (default: first page)' },
|
||||
node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' },
|
||||
style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' },
|
||||
thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' },
|
||||
width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' },
|
||||
height: { type: 'string', description: 'Thumbnail height (default: 1080)', default: '1080' }
|
||||
},
|
||||
async run({ args }) {
|
||||
const format = args.format.toUpperCase() as ExportFormat
|
||||
if (![...RASTER_FORMATS, 'SVG'].includes(format)) {
|
||||
printError(`Invalid format "${args.format}". Use png, jpg, webp, or svg.`)
|
||||
const format = args.format.toUpperCase() as ExportFormat | 'JSX'
|
||||
if (!ALL_FORMATS.includes(format)) {
|
||||
printError(`Invalid format "${args.format}". Use png, jpg, webp, svg, or jsx.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (format === 'JSX' && !JSX_STYLES.includes(args.style)) {
|
||||
printError(`Invalid JSX style "${args.style}". Use openpencil or tailwind.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -43,8 +51,27 @@ export default defineCommand({
|
|||
process.exit(1)
|
||||
}
|
||||
|
||||
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
||||
const defaultName = basename(args.file, extname(args.file))
|
||||
|
||||
if (format === 'JSX') {
|
||||
const jsxFormat = args.style as JSXFormat
|
||||
const nodeIds = args.node ? [args.node] : page.childIds
|
||||
const jsxStr = nodeIds.length === 1
|
||||
? sceneNodeToJSX(nodeIds[0], graph, jsxFormat)
|
||||
: selectionToJSX(nodeIds, graph, jsxFormat)
|
||||
|
||||
if (!jsxStr) {
|
||||
printError('Nothing to export (empty page or no visible nodes).')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const output = resolve(args.output ?? `${defaultName}.jsx`)
|
||||
await Bun.write(output, jsxStr)
|
||||
console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`))
|
||||
return
|
||||
}
|
||||
|
||||
const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase()
|
||||
const output = resolve(args.output ?? `${defaultName}.${ext}`)
|
||||
|
||||
if (format === 'SVG') {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export {
|
|||
|
||||
export {
|
||||
renderTree,
|
||||
renderJsx,
|
||||
renderJSX,
|
||||
renderTreeNode,
|
||||
buildComponent,
|
||||
Frame,
|
||||
|
|
@ -134,8 +134,9 @@ export {
|
|||
type TextProps,
|
||||
type StyleProps,
|
||||
type RenderResult,
|
||||
sceneNodeToJsx,
|
||||
selectionToJsx
|
||||
sceneNodeToJSX,
|
||||
selectionToJSX,
|
||||
type JSXFormat
|
||||
} from './render'
|
||||
export {
|
||||
parseFigmaClipboard,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { colorToHex } from '../color'
|
||||
import { DEFAULT_FONT_FAMILY } from '../constants'
|
||||
import {
|
||||
pxToSpacing,
|
||||
colorToTwClass,
|
||||
fontSizeToTw,
|
||||
fontWeightToTw,
|
||||
borderRadiusToTw,
|
||||
opacityToTw
|
||||
} from './tailwind'
|
||||
|
||||
import type { SceneGraph, SceneNode, Fill, Stroke, Effect, NodeType, Color } from '../scene-graph'
|
||||
|
||||
export type JSXFormat = 'openpencil' | 'tailwind'
|
||||
|
||||
const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
|
||||
FRAME: 'Frame',
|
||||
RECTANGLE: 'Rectangle',
|
||||
|
|
@ -20,6 +30,23 @@ const NODE_TYPE_TO_TAG: Partial<Record<NodeType, string>> = {
|
|||
INSTANCE: 'Frame'
|
||||
}
|
||||
|
||||
const NODE_TYPE_TO_TW_TAG: Partial<Record<NodeType, string>> = {
|
||||
FRAME: 'div',
|
||||
RECTANGLE: 'div',
|
||||
ROUNDED_RECTANGLE: 'div',
|
||||
ELLIPSE: 'div',
|
||||
TEXT: 'p',
|
||||
LINE: 'div',
|
||||
STAR: 'div',
|
||||
POLYGON: 'div',
|
||||
VECTOR: 'div',
|
||||
GROUP: 'div',
|
||||
SECTION: 'section',
|
||||
COMPONENT: 'div',
|
||||
COMPONENT_SET: 'div',
|
||||
INSTANCE: 'div'
|
||||
}
|
||||
|
||||
function formatColor(color: Color, opacity = 1): string {
|
||||
const hex = colorToHex(color)
|
||||
if (opacity < 1)
|
||||
|
|
@ -52,6 +79,40 @@ function formatShadow(e: Effect): string | null {
|
|||
return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}`
|
||||
}
|
||||
|
||||
function formatTailwindShadow(e: Effect): string | null {
|
||||
if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null
|
||||
const { r, g, b } = e.color
|
||||
const color = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${Number(e.color.a.toFixed(3))})`
|
||||
const inset = e.type === 'INNER_SHADOW' ? 'inset_' : ''
|
||||
const spread = e.spread !== 0 ? `_${e.spread}px` : ''
|
||||
return `${inset}${e.offset.x}px_${e.offset.y}px_${e.radius}px${spread}_${color}`
|
||||
}
|
||||
|
||||
function formatTailwindAngle(degrees: number): string {
|
||||
const rounded = Number(degrees.toFixed(2))
|
||||
const integer = Math.round(rounded)
|
||||
const named = new Set([0, 1, 2, 3, 6, 12, 45, 90, 180])
|
||||
if (rounded === integer && named.has(integer)) return String(integer)
|
||||
return `[${rounded}deg]`
|
||||
}
|
||||
|
||||
function formatTailwindFontFamily(fontFamily: string): string {
|
||||
const escaped = fontFamily.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
|
||||
return `['${escaped}']`
|
||||
}
|
||||
|
||||
const JSX_ENTITY: Record<string, string> = {
|
||||
'{': '{',
|
||||
'}': '}',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&'
|
||||
}
|
||||
|
||||
function escapeJSXText(text: string): string {
|
||||
return text.replace(/[{}<>&]/g, (c) => JSX_ENTITY[c]!)
|
||||
}
|
||||
|
||||
function formatProp(key: string, value: unknown): string {
|
||||
if (typeof value === 'string') return `${key}="${value}"`
|
||||
if (typeof value === 'number') return `${key}={${value}}`
|
||||
|
|
@ -59,58 +120,90 @@ function formatProp(key: string, value: unknown): string {
|
|||
return `${key}={${JSON.stringify(value)}}`
|
||||
}
|
||||
|
||||
function getNodeContext(node: SceneNode, graph: SceneGraph) {
|
||||
const parent = node.parentId ? graph.getNode(node.parentId) : null
|
||||
return {
|
||||
isAutoLayout: node.layoutMode !== 'NONE',
|
||||
parentIsAutoLayout: parent ? parent.layoutMode !== 'NONE' : false
|
||||
}
|
||||
}
|
||||
|
||||
type PaddingEdges = { pt: number; pr: number; pb: number; pl: number }
|
||||
|
||||
function collectPadding(node: SceneNode): PaddingEdges | null {
|
||||
const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node
|
||||
if (pt === 0 && pr === 0 && pb === 0 && pl === 0) return null
|
||||
return { pt, pr, pb, pl }
|
||||
}
|
||||
|
||||
function emitPadding<T>(
|
||||
edges: PaddingEdges,
|
||||
uniform: (v: number) => T,
|
||||
symmetric: (y: number, x: number) => T[],
|
||||
individual: (edges: PaddingEdges) => T[]
|
||||
): T[] {
|
||||
const { pt, pr, pb, pl } = edges
|
||||
if (pt === pr && pr === pb && pb === pl) return [uniform(pt)]
|
||||
if (pt === pb && pl === pr) return symmetric(pt, pl)
|
||||
return individual(edges)
|
||||
}
|
||||
|
||||
interface CornerRadii {
|
||||
tl: number
|
||||
tr: number
|
||||
br: number
|
||||
bl: number
|
||||
}
|
||||
|
||||
function collectCornerRadii(node: SceneNode): CornerRadii | null {
|
||||
if (node.cornerRadius <= 0) return null
|
||||
if (node.independentCorners) {
|
||||
return {
|
||||
tl: node.topLeftRadius,
|
||||
tr: node.topRightRadius,
|
||||
br: node.bottomRightRadius,
|
||||
bl: node.bottomLeftRadius
|
||||
}
|
||||
}
|
||||
const r = node.cornerRadius
|
||||
return { tl: r, tr: r, br: r, bl: r }
|
||||
}
|
||||
|
||||
// --- OpenPencil format ---
|
||||
|
||||
function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
|
||||
const props: [string, unknown][] = []
|
||||
const { isAutoLayout, parentIsAutoLayout } = getNodeContext(node, graph)
|
||||
|
||||
if (node.name && node.name !== node.type) {
|
||||
props.push(['name', node.name])
|
||||
}
|
||||
|
||||
if (node.layoutMode !== 'NONE') {
|
||||
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
|
||||
}
|
||||
|
||||
const isAutoLayout = node.layoutMode !== 'NONE'
|
||||
const parent = node.parentId ? graph.getNode(node.parentId) : null
|
||||
const parentIsAutoLayout = parent ? parent.layoutMode !== 'NONE' : false
|
||||
|
||||
if (isAutoLayout) {
|
||||
props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col'])
|
||||
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
|
||||
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
|
||||
|
||||
if (node.primaryAxisSizing === 'HUG') {
|
||||
/* hug is default with flex, omit */
|
||||
} else if (node.primaryAxisSizing === 'FILL') {
|
||||
if (node.primaryAxisSizing === 'FILL')
|
||||
props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill'])
|
||||
} else {
|
||||
else if (node.primaryAxisSizing !== 'HUG')
|
||||
props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]])
|
||||
}
|
||||
|
||||
if (node.counterAxisSizing === 'HUG') {
|
||||
/* hug is default, omit */
|
||||
} else if (node.counterAxisSizing === 'FILL') {
|
||||
if (node.counterAxisSizing === 'FILL')
|
||||
props.push([crossAxis === 'width' ? 'w' : 'h', 'fill'])
|
||||
} else {
|
||||
else if (node.counterAxisSizing !== 'HUG')
|
||||
props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]])
|
||||
}
|
||||
} else {
|
||||
if (node.width > 0) props.push(['w', node.width])
|
||||
if (node.height > 0) props.push(['h', node.height])
|
||||
}
|
||||
|
||||
if (parentIsAutoLayout && node.layoutGrow > 0) {
|
||||
props.push(['grow', node.layoutGrow])
|
||||
}
|
||||
|
||||
if (isAutoLayout && node.itemSpacing > 0) {
|
||||
props.push(['gap', node.itemSpacing])
|
||||
}
|
||||
if (parentIsAutoLayout && node.layoutGrow > 0) props.push(['grow', node.layoutGrow])
|
||||
if (isAutoLayout && node.itemSpacing > 0) props.push(['gap', node.itemSpacing])
|
||||
|
||||
if (isAutoLayout && node.layoutWrap === 'WRAP') {
|
||||
props.push(['wrap', true])
|
||||
if (node.counterAxisSpacing > 0) {
|
||||
props.push(['rowGap', node.counterAxisSpacing])
|
||||
}
|
||||
if (node.counterAxisSpacing > 0) props.push(['rowGap', node.counterAxisSpacing])
|
||||
}
|
||||
|
||||
if (isAutoLayout) {
|
||||
|
|
@ -121,22 +214,24 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
|
|||
if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center'])
|
||||
else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end'])
|
||||
else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch'])
|
||||
}
|
||||
|
||||
if (isAutoLayout) {
|
||||
const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node
|
||||
if (pt > 0 || pr > 0 || pb > 0 || pl > 0) {
|
||||
if (pt === pr && pr === pb && pb === pl) {
|
||||
props.push(['p', pt])
|
||||
} else if (pt === pb && pl === pr) {
|
||||
props.push(['py', pt])
|
||||
props.push(['px', pl])
|
||||
} else {
|
||||
if (pt > 0) props.push(['pt', pt])
|
||||
if (pr > 0) props.push(['pr', pr])
|
||||
if (pb > 0) props.push(['pb', pb])
|
||||
if (pl > 0) props.push(['pl', pl])
|
||||
}
|
||||
const pad = collectPadding(node)
|
||||
if (pad) {
|
||||
props.push(
|
||||
...emitPadding(
|
||||
pad,
|
||||
(v) => ['p', v] as [string, unknown],
|
||||
(y, x) => [['py', y], ['px', x]] as [string, unknown][],
|
||||
({ pt, pr, pb, pl }) => {
|
||||
const r: [string, unknown][] = []
|
||||
if (pt > 0) r.push(['pt', pt])
|
||||
if (pr > 0) r.push(['pr', pr])
|
||||
if (pb > 0) r.push(['pb', pb])
|
||||
if (pl > 0) r.push(['pl', pl])
|
||||
return r
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,24 +244,16 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
|
|||
if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight])
|
||||
}
|
||||
|
||||
if (node.cornerRadius > 0) {
|
||||
if (node.independentCorners) {
|
||||
const {
|
||||
topLeftRadius: tl,
|
||||
topRightRadius: tr,
|
||||
bottomRightRadius: br,
|
||||
bottomLeftRadius: bl
|
||||
} = node
|
||||
if (tl === tr && tr === br && br === bl) {
|
||||
props.push(['rounded', tl])
|
||||
} else {
|
||||
if (tl > 0) props.push(['roundedTL', tl])
|
||||
if (tr > 0) props.push(['roundedTR', tr])
|
||||
if (br > 0) props.push(['roundedBR', br])
|
||||
if (bl > 0) props.push(['roundedBL', bl])
|
||||
}
|
||||
const corners = collectCornerRadii(node)
|
||||
if (corners) {
|
||||
const { tl, tr, br, bl } = corners
|
||||
if (tl === tr && tr === br && br === bl) {
|
||||
props.push(['rounded', tl])
|
||||
} else {
|
||||
props.push(['rounded', node.cornerRadius])
|
||||
if (tl > 0) props.push(['roundedTL', tl])
|
||||
if (tr > 0) props.push(['roundedTR', tr])
|
||||
if (br > 0) props.push(['roundedBR', br])
|
||||
if (bl > 0) props.push(['roundedBL', bl])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +277,8 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
|
|||
|
||||
if (node.type === 'TEXT') {
|
||||
if (node.fontSize !== 14) props.push(['size', node.fontSize])
|
||||
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) props.push(['font', node.fontFamily])
|
||||
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY)
|
||||
props.push(['font', node.fontFamily])
|
||||
if (node.fontWeight !== 400) {
|
||||
if (node.fontWeight === 700) props.push(['weight', 'bold'])
|
||||
else if (node.fontWeight === 500) props.push(['weight', 'medium'])
|
||||
|
|
@ -218,69 +306,196 @@ function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] {
|
|||
return props
|
||||
}
|
||||
|
||||
function nodeToJsx(node: SceneNode, graph: SceneGraph, indent: number): string {
|
||||
const tag = NODE_TYPE_TO_TAG[node.type]
|
||||
// --- Tailwind CSS v4 format ---
|
||||
|
||||
function twRounded(prefix: string, px: number): string {
|
||||
const r = borderRadiusToTw(px)
|
||||
return r ? `${prefix}-${r}` : prefix
|
||||
}
|
||||
|
||||
function collectTailwindClasses(node: SceneNode, graph: SceneGraph): string[] {
|
||||
const classes: string[] = []
|
||||
const { isAutoLayout, parentIsAutoLayout } = getNodeContext(node, graph)
|
||||
|
||||
if (isAutoLayout) {
|
||||
classes.push('flex')
|
||||
if (node.layoutMode === 'VERTICAL') classes.push('flex-col')
|
||||
|
||||
const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height'
|
||||
const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width'
|
||||
const wProp = primaryAxis === 'width' ? 'w' : 'h'
|
||||
const hProp = crossAxis === 'width' ? 'w' : 'h'
|
||||
|
||||
if (node.primaryAxisSizing === 'FILL') classes.push(`${wProp}-full`)
|
||||
else if (node.primaryAxisSizing !== 'HUG')
|
||||
classes.push(`${wProp}-${pxToSpacing(node[primaryAxis])}`)
|
||||
|
||||
if (node.counterAxisSizing === 'FILL') classes.push(`${hProp}-full`)
|
||||
else if (node.counterAxisSizing !== 'HUG')
|
||||
classes.push(`${hProp}-${pxToSpacing(node[crossAxis])}`)
|
||||
} else {
|
||||
if (node.width > 0) classes.push(`w-${pxToSpacing(node.width)}`)
|
||||
if (node.height > 0) classes.push(`h-${pxToSpacing(node.height)}`)
|
||||
}
|
||||
|
||||
if (parentIsAutoLayout && node.layoutGrow > 0) classes.push('grow')
|
||||
if (isAutoLayout && node.itemSpacing > 0) classes.push(`gap-${pxToSpacing(node.itemSpacing)}`)
|
||||
|
||||
if (isAutoLayout && node.layoutWrap === 'WRAP') {
|
||||
classes.push('flex-wrap')
|
||||
if (node.counterAxisSpacing > 0) classes.push(`gap-y-${pxToSpacing(node.counterAxisSpacing)}`)
|
||||
}
|
||||
|
||||
if (isAutoLayout) {
|
||||
if (node.primaryAxisAlign === 'CENTER') classes.push('justify-center')
|
||||
else if (node.primaryAxisAlign === 'MAX') classes.push('justify-end')
|
||||
else if (node.primaryAxisAlign === 'SPACE_BETWEEN') classes.push('justify-between')
|
||||
|
||||
if (node.counterAxisAlign === 'CENTER') classes.push('items-center')
|
||||
else if (node.counterAxisAlign === 'MAX') classes.push('items-end')
|
||||
else if (node.counterAxisAlign === 'STRETCH') classes.push('items-stretch')
|
||||
|
||||
const pad = collectPadding(node)
|
||||
if (pad) {
|
||||
classes.push(
|
||||
...emitPadding(
|
||||
pad,
|
||||
(v) => `p-${pxToSpacing(v)}`,
|
||||
(y, x) => [`py-${pxToSpacing(y)}`, `px-${pxToSpacing(x)}`],
|
||||
({ pt, pr, pb, pl }) => {
|
||||
const r: string[] = []
|
||||
if (pt > 0) r.push(`pt-${pxToSpacing(pt)}`)
|
||||
if (pr > 0) r.push(`pr-${pxToSpacing(pr)}`)
|
||||
if (pb > 0) r.push(`pb-${pxToSpacing(pb)}`)
|
||||
if (pl > 0) r.push(`pl-${pxToSpacing(pl)}`)
|
||||
return r
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const bg = solidFillColor(node.fills)
|
||||
if (bg && node.type !== 'TEXT') classes.push(`bg-${colorToTwClass(bg)}`)
|
||||
|
||||
const stroke = solidStroke(node.strokes)
|
||||
if (stroke) {
|
||||
if (stroke.weight !== 1) classes.push(`border-${pxToSpacing(stroke.weight)}`)
|
||||
else classes.push('border')
|
||||
classes.push(`border-${colorToTwClass(stroke.color)}`)
|
||||
}
|
||||
|
||||
const corners = collectCornerRadii(node)
|
||||
if (corners) {
|
||||
const { tl, tr, br, bl } = corners
|
||||
if (tl === tr && tr === br && br === bl) {
|
||||
classes.push(twRounded('rounded', tl))
|
||||
} else {
|
||||
if (tl > 0) classes.push(twRounded('rounded-tl', tl))
|
||||
if (tr > 0) classes.push(twRounded('rounded-tr', tr))
|
||||
if (br > 0) classes.push(twRounded('rounded-br', br))
|
||||
if (bl > 0) classes.push(twRounded('rounded-bl', bl))
|
||||
}
|
||||
}
|
||||
|
||||
if (node.opacity < 1) classes.push(`opacity-${opacityToTw(node.opacity)}`)
|
||||
if (node.rotation !== 0) classes.push(`rotate-${formatTailwindAngle(node.rotation)}`)
|
||||
if (node.clipsContent) classes.push('overflow-hidden')
|
||||
|
||||
for (const effect of node.effects) {
|
||||
if (!effect.visible) continue
|
||||
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
|
||||
const shadow = formatTailwindShadow(effect)
|
||||
if (shadow) classes.push(`shadow-[${shadow}]`)
|
||||
} else if (effect.type === 'LAYER_BLUR' || effect.type === 'FOREGROUND_BLUR') {
|
||||
classes.push(`blur-[${effect.radius}px]`)
|
||||
} else if (effect.type === 'BACKGROUND_BLUR') {
|
||||
classes.push(`backdrop-blur-[${effect.radius}px]`)
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'TEXT') {
|
||||
classes.push(`text-${fontSizeToTw(node.fontSize)}`)
|
||||
if (node.fontFamily && node.fontFamily !== DEFAULT_FONT_FAMILY) {
|
||||
classes.push(`font-${formatTailwindFontFamily(node.fontFamily)}`)
|
||||
}
|
||||
if (node.fontWeight !== 400) classes.push(`font-${fontWeightToTw(node.fontWeight)}`)
|
||||
if (node.textAlignHorizontal !== 'LEFT') {
|
||||
classes.push(`text-${node.textAlignHorizontal.toLowerCase()}`)
|
||||
}
|
||||
const textColor = solidFillColor(node.fills)
|
||||
if (textColor) classes.push(`text-${colorToTwClass(textColor)}`)
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
// --- JSX rendering ---
|
||||
|
||||
function nodeToJSX(node: SceneNode, graph: SceneGraph, indent: number, format: JSXFormat): string {
|
||||
const tagMap = format === 'tailwind' ? NODE_TYPE_TO_TW_TAG : NODE_TYPE_TO_TAG
|
||||
const tag = tagMap[node.type]
|
||||
if (!tag) return ''
|
||||
|
||||
const prefix = ' '.repeat(indent)
|
||||
const props = collectProps(node, graph)
|
||||
const propsStr = props.map(([k, v]) => formatProp(k, v)).join(' ')
|
||||
const opening = propsStr ? `<${tag} ${propsStr}` : `<${tag}`
|
||||
let attrsStr: string
|
||||
|
||||
if (format === 'tailwind') {
|
||||
const classes = collectTailwindClasses(node, graph)
|
||||
const nameAttr = node.name && node.name !== node.type ? ` data-name="${node.name}"` : ''
|
||||
const classAttr = classes.length > 0 ? ` className="${classes.join(' ')}"` : ''
|
||||
attrsStr = `${nameAttr}${classAttr}`.trim()
|
||||
} else {
|
||||
const props = collectProps(node, graph)
|
||||
attrsStr = props.map(([k, v]) => formatProp(k, v)).join(' ')
|
||||
}
|
||||
|
||||
const opening = attrsStr ? `<${tag} ${attrsStr}` : `<${tag}`
|
||||
const children = graph.getChildren(node.id)
|
||||
const isText = node.type === 'TEXT'
|
||||
|
||||
if (isText) {
|
||||
if (node.type === 'TEXT') {
|
||||
const text = node.text
|
||||
if (!text) return `${prefix}${opening} />`
|
||||
const escaped = text.replace(/[{}<>&]/g, (c) =>
|
||||
c === '{'
|
||||
? '{'
|
||||
: c === '}'
|
||||
? '}'
|
||||
: c === '<'
|
||||
? '<'
|
||||
: c === '>'
|
||||
? '>'
|
||||
: '&'
|
||||
)
|
||||
const escaped = escapeJSXText(text)
|
||||
if (!escaped.includes('\n')) {
|
||||
return `${prefix}${opening}>${escaped}</${tag}>`
|
||||
}
|
||||
const lines = escaped.split('\n')
|
||||
const parts = [
|
||||
return [
|
||||
`${prefix}${opening}>`,
|
||||
...lines.map((l) => `${prefix} ${l}`),
|
||||
...escaped.split('\n').map((l) => `${prefix} ${l}`),
|
||||
`${prefix}</${tag}>`
|
||||
]
|
||||
return parts.join('\n')
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (children.length === 0) {
|
||||
return `${prefix}${opening} />`
|
||||
}
|
||||
if (children.length === 0) return `${prefix}${opening} />`
|
||||
|
||||
const childJsx = children
|
||||
const childJSX = children
|
||||
.filter((c) => c.visible)
|
||||
.map((c) => nodeToJsx(c, graph, indent + 1))
|
||||
.map((c) => nodeToJSX(c, graph, indent + 1, format))
|
||||
.filter(Boolean)
|
||||
|
||||
if (childJsx.length === 0) {
|
||||
return `${prefix}${opening} />`
|
||||
}
|
||||
if (childJSX.length === 0) return `${prefix}${opening} />`
|
||||
|
||||
return [`${prefix}${opening}>`, ...childJsx, `${prefix}</${tag}>`].join('\n')
|
||||
return [`${prefix}${opening}>`, ...childJSX, `${prefix}</${tag}>`].join('\n')
|
||||
}
|
||||
|
||||
export function sceneNodeToJsx(nodeId: string, graph: SceneGraph): string {
|
||||
export function sceneNodeToJSX(
|
||||
nodeId: string,
|
||||
graph: SceneGraph,
|
||||
format: JSXFormat = 'openpencil'
|
||||
): string {
|
||||
const node = graph.getNode(nodeId)
|
||||
if (!node) return ''
|
||||
return nodeToJsx(node, graph, 0)
|
||||
return nodeToJSX(node, graph, 0, format)
|
||||
}
|
||||
|
||||
export function selectionToJsx(nodeIds: string[], graph: SceneGraph): string {
|
||||
export function selectionToJSX(
|
||||
nodeIds: string[],
|
||||
graph: SceneGraph,
|
||||
format: JSXFormat = 'openpencil'
|
||||
): string {
|
||||
return nodeIds
|
||||
.map((id) => sceneNodeToJsx(id, graph))
|
||||
.map((id) => sceneNodeToJSX(id, graph, format))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,6 @@ export {
|
|||
|
||||
export { renderTree, type RenderResult } from './renderer'
|
||||
|
||||
export { renderJsx, renderTreeNode, buildComponent } from './render-jsx'
|
||||
export { renderJSX, renderTreeNode, buildComponent } from './render-jsx'
|
||||
|
||||
export { sceneNodeToJsx, selectionToJsx } from './export-jsx'
|
||||
export { sceneNodeToJSX, selectionToJSX, type JSXFormat } from './export-jsx'
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export async function buildComponent(jsxString: string): Promise<() => unknown>
|
|||
return new Function('React', result.code)(React) as () => unknown
|
||||
}
|
||||
|
||||
interface RenderJsxOptions {
|
||||
interface RenderJSXOptions {
|
||||
x?: number
|
||||
y?: number
|
||||
parentId?: string
|
||||
|
|
@ -46,10 +46,10 @@ interface RenderJsxOptions {
|
|||
* Render a JSX string into the scene graph.
|
||||
* For headless/CLI use — requires esbuild.
|
||||
*/
|
||||
export async function renderJsx(
|
||||
export async function renderJSX(
|
||||
graph: SceneGraph,
|
||||
jsxString: string,
|
||||
options?: RenderJsxOptions
|
||||
options?: RenderJSXOptions
|
||||
): Promise<RenderResult> {
|
||||
const Component = await buildComponent(jsxString)
|
||||
const element = React.createElement(Component, null)
|
||||
|
|
@ -69,7 +69,7 @@ export async function renderJsx(
|
|||
export function renderTreeNode(
|
||||
graph: SceneGraph,
|
||||
tree: TreeNode,
|
||||
options?: RenderJsxOptions
|
||||
options?: RenderJSXOptions
|
||||
): RenderResult {
|
||||
return renderTree(graph, tree, options)
|
||||
}
|
||||
|
|
|
|||
114
packages/core/src/render/tailwind.ts
Normal file
114
packages/core/src/render/tailwind.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Tailwind CSS v4 value resolvers.
|
||||
*
|
||||
* Converts design token values (px, hex colors, font sizes, etc.) into
|
||||
* Tailwind v4 utility class fragments.
|
||||
*
|
||||
* Tailwind v4 spacing is dynamic: `calc(var(--spacing) * N)` where
|
||||
* `--spacing` defaults to `0.25rem` (4px). Any integer works as a class
|
||||
* suffix, so we just divide by 4 instead of maintaining a lookup table.
|
||||
*
|
||||
* Architecture inspired by seanchas116/figma-to-tailwind (MIT)
|
||||
* https://github.com/seanchas116/figma-to-tailwind
|
||||
*/
|
||||
|
||||
const FONT_SIZE_TO_TW = new Map<number, string>([
|
||||
[12, 'xs'],
|
||||
[14, 'sm'],
|
||||
[16, 'base'],
|
||||
[18, 'lg'],
|
||||
[20, 'xl'],
|
||||
[24, '2xl'],
|
||||
[30, '3xl'],
|
||||
[36, '4xl'],
|
||||
[48, '5xl'],
|
||||
[60, '6xl'],
|
||||
[72, '7xl'],
|
||||
[96, '8xl'],
|
||||
[128, '9xl']
|
||||
])
|
||||
|
||||
const OPACITY_TO_TW = new Map<number, string>([
|
||||
[0, '0'],
|
||||
[5, '5'],
|
||||
[10, '10'],
|
||||
[15, '15'],
|
||||
[20, '20'],
|
||||
[25, '25'],
|
||||
[30, '30'],
|
||||
[35, '35'],
|
||||
[40, '40'],
|
||||
[45, '45'],
|
||||
[50, '50'],
|
||||
[55, '55'],
|
||||
[60, '60'],
|
||||
[65, '65'],
|
||||
[70, '70'],
|
||||
[75, '75'],
|
||||
[80, '80'],
|
||||
[85, '85'],
|
||||
[90, '90'],
|
||||
[95, '95'],
|
||||
[100, '100']
|
||||
])
|
||||
|
||||
const FONT_WEIGHT_TO_TW = new Map<number, string>([
|
||||
[100, 'thin'],
|
||||
[200, 'extralight'],
|
||||
[300, 'light'],
|
||||
[400, 'normal'],
|
||||
[500, 'medium'],
|
||||
[600, 'semibold'],
|
||||
[700, 'bold'],
|
||||
[800, 'extrabold'],
|
||||
[900, 'black']
|
||||
])
|
||||
|
||||
const RADIUS_PX_TO_TW = new Map<number, string>([
|
||||
[2, 'sm'],
|
||||
[4, 'DEFAULT'],
|
||||
[6, 'md'],
|
||||
[8, 'lg'],
|
||||
[12, 'xl'],
|
||||
[16, '2xl'],
|
||||
[24, '3xl'],
|
||||
[9999, 'full']
|
||||
])
|
||||
|
||||
export function pxToSpacing(px: number): string {
|
||||
if (px === 0) return '0'
|
||||
if (px === 1) return 'px'
|
||||
const n = px / 4
|
||||
if (Number.isInteger(n)) return String(n)
|
||||
if (n * 2 === Math.round(n * 2)) return String(n)
|
||||
return `[${px}px]`
|
||||
}
|
||||
|
||||
export function colorToTwClass(hex: string): string {
|
||||
const lower = hex.toLowerCase()
|
||||
if (lower === '#ffffff' || lower === '#fff') return 'white'
|
||||
if (lower === '#000000' || lower === '#000') return 'black'
|
||||
if (lower === '#00000000') return 'transparent'
|
||||
return `[${hex}]`
|
||||
}
|
||||
|
||||
export function fontSizeToTw(px: number): string {
|
||||
return FONT_SIZE_TO_TW.get(px) ?? `[${px}px]`
|
||||
}
|
||||
|
||||
export function fontWeightToTw(n: number): string {
|
||||
return FONT_WEIGHT_TO_TW.get(n) ?? `[${n}]`
|
||||
}
|
||||
|
||||
export function borderRadiusToTw(px: number): string {
|
||||
if (px >= 9999) return 'full'
|
||||
const name = RADIUS_PX_TO_TW.get(px)
|
||||
if (name === 'DEFAULT') return ''
|
||||
if (name) return name
|
||||
return `[${px}px]`
|
||||
}
|
||||
|
||||
export function opacityToTw(n: number): string {
|
||||
const pct = Math.round(n * 100)
|
||||
return OPACITY_TO_TW.get(pct) ?? `[${n}]`
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,17 +4,24 @@ import 'prismjs/components/prism-jsx'
|
|||
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { selectionToJsx } from '@open-pencil/core'
|
||||
import { selectionToJSX } from '@open-pencil/core'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
||||
import type { JSXFormat } from '@open-pencil/core'
|
||||
|
||||
const store = useEditorStore()
|
||||
const copied = ref(false)
|
||||
const jsxFormat = ref<JSXFormat>('openpencil')
|
||||
|
||||
function toggleFormat() {
|
||||
jsxFormat.value = jsxFormat.value === 'openpencil' ? 'tailwind' : 'openpencil'
|
||||
}
|
||||
|
||||
const jsxCode = computed(() => {
|
||||
void store.state.sceneVersion
|
||||
const ids = [...store.state.selectedIds]
|
||||
if (ids.length === 0) return ''
|
||||
return selectionToJsx(ids, store.graph)
|
||||
return selectionToJSX(ids, store.graph, jsxFormat.value)
|
||||
})
|
||||
|
||||
const highlightedLines = computed(() => {
|
||||
|
|
@ -51,7 +58,16 @@ watch(jsxCode, () => {
|
|||
data-test-id="code-panel-header"
|
||||
class="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5"
|
||||
>
|
||||
<span class="text-[11px] text-muted">JSX</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-muted">JSX</span>
|
||||
<button
|
||||
data-test-id="code-panel-format-toggle"
|
||||
class="rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
|
||||
@click="toggleFormat"
|
||||
>
|
||||
{{ jsxFormat === 'openpencil' ? 'OpenPencil' : 'Tailwind' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-test-id="code-panel-copy"
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
ContextMenuSubContent,
|
||||
ContextMenuPortal
|
||||
} from 'reka-ui'
|
||||
import { selectionToJsx, renderNodesToSVG } from '@open-pencil/core'
|
||||
import { selectionToJSX, renderNodesToSVG } from '@open-pencil/core'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { toast } from '@/composables/use-toast'
|
||||
|
|
@ -93,7 +93,7 @@ async function copyAsPNG() {
|
|||
|
||||
function copyAsJSX() {
|
||||
const ids = selectedIds()
|
||||
const jsx = selectionToJsx(ids, store.graph)
|
||||
const jsx = selectionToJSX(ids, store.graph)
|
||||
if (!jsx) return
|
||||
navigator.clipboard.writeText(jsx)
|
||||
toast.show('Copied as JSX')
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph, sceneNodeToJsx, selectionToJsx } from '@open-pencil/core'
|
||||
import { SceneGraph, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
|
||||
|
||||
function makeGraph() {
|
||||
const graph = new SceneGraph()
|
||||
|
|
@ -12,7 +12,7 @@ function pageId(graph: SceneGraph) {
|
|||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
describe('sceneNodeToJsx', () => {
|
||||
describe('sceneNodeToJSX', () => {
|
||||
test('basic rectangle', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
|
|
@ -20,7 +20,7 @@ describe('sceneNodeToJsx', () => {
|
|||
width: 100,
|
||||
height: 50
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toBe('<Rectangle name="Box" w={100} h={50} />')
|
||||
})
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ describe('sceneNodeToJsx', () => {
|
|||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
cornerRadius: 16
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('w={320}')
|
||||
expect(jsx).toContain('h={200}')
|
||||
expect(jsx).toContain('bg="#FFFFFF"')
|
||||
|
|
@ -55,7 +55,7 @@ describe('sceneNodeToJsx', () => {
|
|||
{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }
|
||||
]
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('<Text')
|
||||
expect(jsx).toContain('size={18}')
|
||||
expect(jsx).toContain('weight="bold"')
|
||||
|
|
@ -78,7 +78,7 @@ describe('sceneNodeToJsx', () => {
|
|||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG'
|
||||
})
|
||||
const jsx = sceneNodeToJsx(frame.id, graph)
|
||||
const jsx = sceneNodeToJSX(frame.id, graph)
|
||||
expect(jsx).toContain('flex="row"')
|
||||
expect(jsx).toContain('gap={16}')
|
||||
expect(jsx).toContain('p={12}')
|
||||
|
|
@ -113,7 +113,7 @@ describe('sceneNodeToJsx', () => {
|
|||
]
|
||||
})
|
||||
|
||||
const jsx = sceneNodeToJsx(frame.id, graph)
|
||||
const jsx = sceneNodeToJSX(frame.id, graph)
|
||||
expect(jsx).toContain('<Frame')
|
||||
expect(jsx).toContain('flex="col"')
|
||||
expect(jsx).toContain('gap={8}')
|
||||
|
|
@ -137,7 +137,7 @@ describe('sceneNodeToJsx', () => {
|
|||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const jsx = sceneNodeToJsx(frame.id, graph)
|
||||
const jsx = sceneNodeToJSX(frame.id, graph)
|
||||
expect(jsx).toContain('py={8}')
|
||||
expect(jsx).toContain('px={16}')
|
||||
expect(jsx).not.toContain('pt=')
|
||||
|
|
@ -157,7 +157,7 @@ describe('sceneNodeToJsx', () => {
|
|||
bottomRightRadius: 0,
|
||||
bottomLeftRadius: 20
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('roundedTL={20}')
|
||||
expect(jsx).toContain('roundedBL={20}')
|
||||
expect(jsx).not.toContain('roundedTR=')
|
||||
|
|
@ -172,7 +172,7 @@ describe('sceneNodeToJsx', () => {
|
|||
opacity: 0.5,
|
||||
rotation: 45
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('opacity={0.5}')
|
||||
expect(jsx).toContain('rotate={45}')
|
||||
})
|
||||
|
|
@ -186,7 +186,7 @@ describe('sceneNodeToJsx', () => {
|
|||
{ color: { r: 1, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'INSIDE' as const }
|
||||
]
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('stroke="#FF0000"')
|
||||
expect(jsx).toContain('strokeWidth={2}')
|
||||
})
|
||||
|
|
@ -209,7 +209,7 @@ describe('sceneNodeToJsx', () => {
|
|||
height: 50,
|
||||
visible: false
|
||||
})
|
||||
const jsx = sceneNodeToJsx(frame.id, graph)
|
||||
const jsx = sceneNodeToJSX(frame.id, graph)
|
||||
expect(jsx).toContain('Visible')
|
||||
expect(jsx).not.toContain('Hidden')
|
||||
})
|
||||
|
|
@ -222,7 +222,7 @@ describe('sceneNodeToJsx', () => {
|
|||
height: 20,
|
||||
text: ''
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('/>')
|
||||
expect(jsx).not.toContain('</Text>')
|
||||
})
|
||||
|
|
@ -236,7 +236,7 @@ describe('sceneNodeToJsx', () => {
|
|||
rotation: 0,
|
||||
cornerRadius: 0
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).not.toContain('opacity')
|
||||
expect(jsx).not.toContain('rotate')
|
||||
expect(jsx).not.toContain('rounded')
|
||||
|
|
@ -266,13 +266,13 @@ describe('sceneNodeToJsx', () => {
|
|||
}
|
||||
]
|
||||
})
|
||||
const jsx = sceneNodeToJsx(node.id, graph)
|
||||
const jsx = sceneNodeToJSX(node.id, graph)
|
||||
expect(jsx).toContain('shadow="0 4 8')
|
||||
expect(jsx).toContain('blur={4}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectionToJsx', () => {
|
||||
describe('selectionToJSX', () => {
|
||||
test('multiple nodes separated by blank lines', () => {
|
||||
const graph = makeGraph()
|
||||
const a = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
|
|
@ -285,7 +285,7 @@ describe('selectionToJsx', () => {
|
|||
width: 20,
|
||||
height: 20
|
||||
})
|
||||
const jsx = selectionToJsx([a.id, b.id], graph)
|
||||
const jsx = selectionToJSX([a.id, b.id], graph)
|
||||
expect(jsx).toContain('<Rectangle')
|
||||
expect(jsx).toContain('<Ellipse')
|
||||
expect(jsx).toContain('\n\n')
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect, it } from 'bun:test'
|
|||
import {
|
||||
SceneGraph,
|
||||
renderTree,
|
||||
renderJsx,
|
||||
renderJSX,
|
||||
renderTreeNode,
|
||||
Frame,
|
||||
Text,
|
||||
|
|
@ -311,7 +311,7 @@ describe('renderTreeNode', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('renderJsx (string → scene graph)', () => {
|
||||
describe('renderJSX (string → scene graph)', () => {
|
||||
it('renders JSX string', async () => {
|
||||
const g = createGraph()
|
||||
const jsx = `
|
||||
|
|
@ -319,7 +319,7 @@ describe('renderJsx (string → scene graph)', () => {
|
|||
<Text name="Hello" size={16} color="#000">World</Text>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJsx(g, jsx)
|
||||
const result = await renderJSX(g, jsx)
|
||||
|
||||
expect(result.name).toBe('Test')
|
||||
const node = g.nodes.get(result.id)!
|
||||
|
|
@ -339,7 +339,7 @@ describe('renderJsx (string → scene graph)', () => {
|
|||
<Text name="Description" size={14} color="#6B7280">Lorem ipsum</Text>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJsx(g, jsx)
|
||||
const result = await renderJSX(g, jsx)
|
||||
const card = g.nodes.get(result.id)!
|
||||
|
||||
expect(card.layoutMode).toBe('VERTICAL')
|
||||
|
|
@ -348,7 +348,7 @@ describe('renderJsx (string → scene graph)', () => {
|
|||
|
||||
it('renders with position', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJsx(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
|
||||
const result = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
|
||||
const node = g.nodes.get(result.id)!
|
||||
|
||||
expect(node.x).toBe(100)
|
||||
|
|
|
|||
512
tests/engine/tailwind-jsx.test.ts
Normal file
512
tests/engine/tailwind-jsx.test.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { SceneGraph, sceneNodeToJSX, selectionToJSX } from '@open-pencil/core'
|
||||
|
||||
function makeGraph() {
|
||||
const graph = new SceneGraph()
|
||||
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
|
||||
return graph
|
||||
}
|
||||
|
||||
function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
function tw(graph: SceneGraph, nodeId: string) {
|
||||
return sceneNodeToJSX(nodeId, graph, 'tailwind')
|
||||
}
|
||||
|
||||
describe('Tailwind JSX export', () => {
|
||||
test('basic rectangle — div with w/h', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
name: 'Box',
|
||||
width: 100,
|
||||
height: 48
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('<div')
|
||||
expect(jsx).toContain('data-name="Box"')
|
||||
expect(jsx).toContain('w-25')
|
||||
expect(jsx).toContain('h-12')
|
||||
expect(jsx).not.toContain('<Rectangle')
|
||||
})
|
||||
|
||||
test('spacing uses v4 multiplier (px / 4)', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 16,
|
||||
height: 2
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('w-4')
|
||||
expect(jsx).toContain('h-0.5')
|
||||
})
|
||||
|
||||
test('non-standard spacing falls back to arbitrary value', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 37,
|
||||
height: 100
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('w-[37px]')
|
||||
expect(jsx).toContain('h-25')
|
||||
})
|
||||
|
||||
test('1px uses w-px', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 1,
|
||||
height: 1
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('w-px')
|
||||
expect(jsx).toContain('h-px')
|
||||
})
|
||||
|
||||
test('fill and stroke', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }],
|
||||
strokes: [
|
||||
{
|
||||
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
align: 'INSIDE' as const
|
||||
}
|
||||
]
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('bg-white')
|
||||
expect(jsx).toContain('border-0.5')
|
||||
expect(jsx).toContain('border-[#FF0000]')
|
||||
})
|
||||
|
||||
test('text node uses <p> with text classes', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
name: 'Title',
|
||||
width: 200,
|
||||
height: 24,
|
||||
text: 'Hello World',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('<p')
|
||||
expect(jsx).toContain('text-lg')
|
||||
expect(jsx).toContain('font-bold')
|
||||
expect(jsx).toContain('text-black')
|
||||
expect(jsx).toContain('>Hello World</p>')
|
||||
expect(jsx).not.toContain('<Text')
|
||||
})
|
||||
|
||||
test('auto-layout frame → flex classes', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Row',
|
||||
width: 400,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
itemSpacing: 16,
|
||||
paddingTop: 12,
|
||||
paddingRight: 12,
|
||||
paddingBottom: 12,
|
||||
paddingLeft: 12,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'HUG'
|
||||
})
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('<div')
|
||||
expect(jsx).toContain('flex ')
|
||||
expect(jsx).not.toContain('flex-col')
|
||||
expect(jsx).toContain('gap-4')
|
||||
expect(jsx).toContain('p-3')
|
||||
expect(jsx).toContain('w-100')
|
||||
})
|
||||
|
||||
test('vertical auto-layout', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 320,
|
||||
height: 200,
|
||||
layoutMode: 'VERTICAL',
|
||||
itemSpacing: 8,
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('flex ')
|
||||
expect(jsx).toContain('flex-col')
|
||||
expect(jsx).toContain('gap-2')
|
||||
expect(jsx).toContain('w-80')
|
||||
})
|
||||
|
||||
test('asymmetric padding', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
paddingTop: 8,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 16,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('py-2')
|
||||
expect(jsx).toContain('px-4')
|
||||
expect(jsx).not.toContain('pt-')
|
||||
expect(jsx).not.toContain('pb-')
|
||||
})
|
||||
|
||||
test('justify and items alignment', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisAlign: 'CENTER',
|
||||
counterAxisAlign: 'CENTER',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('justify-center')
|
||||
expect(jsx).toContain('items-center')
|
||||
})
|
||||
|
||||
test('border radius — named values', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
cornerRadius: 8
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
test('border radius — default (4px) omits suffix', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
cornerRadius: 4
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toMatch(/\brounded\b/)
|
||||
expect(jsx).not.toContain('rounded-')
|
||||
})
|
||||
|
||||
test('border radius — full', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
cornerRadius: 9999
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('rounded-full')
|
||||
})
|
||||
|
||||
test('border radius — arbitrary', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
cornerRadius: 5
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('rounded-[5px]')
|
||||
})
|
||||
|
||||
test('independent corners', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 40,
|
||||
cornerRadius: 20,
|
||||
independentCorners: true,
|
||||
topLeftRadius: 8,
|
||||
topRightRadius: 0,
|
||||
bottomRightRadius: 0,
|
||||
bottomLeftRadius: 8
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('rounded-tl-lg')
|
||||
expect(jsx).toContain('rounded-bl-lg')
|
||||
expect(jsx).not.toContain('rounded-tr')
|
||||
expect(jsx).not.toContain('rounded-br')
|
||||
})
|
||||
|
||||
test('opacity and rotation', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 50,
|
||||
height: 50,
|
||||
opacity: 0.5,
|
||||
rotation: 45
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('opacity-50')
|
||||
expect(jsx).toContain('rotate-45')
|
||||
})
|
||||
|
||||
test('non-standard opacity and rotation use arbitrary values', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 50,
|
||||
height: 50,
|
||||
opacity: 0.37,
|
||||
rotation: 13
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('opacity-[0.37]')
|
||||
expect(jsx).toContain('rotate-[13deg]')
|
||||
})
|
||||
|
||||
test('overflow hidden', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
clipsContent: true
|
||||
})
|
||||
expect(tw(graph, frame.id)).toContain('overflow-hidden')
|
||||
})
|
||||
|
||||
test('shadow emits arbitrary shadow class', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
effects: [
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('shadow-[0px_4px_8px_rgba(0,0,0,0.25)]')
|
||||
})
|
||||
|
||||
test('inner shadow includes inset and spread', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
effects: [
|
||||
{
|
||||
type: 'INNER_SHADOW',
|
||||
color: { r: 1, g: 0, b: 0, a: 0.5 },
|
||||
offset: { x: 1, y: 2 },
|
||||
radius: 3,
|
||||
spread: 4,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('shadow-[inset_1px_2px_3px_4px_rgba(255,0,0,0.5)]')
|
||||
})
|
||||
|
||||
test('blur emits blur class', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
effects: [
|
||||
{
|
||||
type: 'LAYER_BLUR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: 4,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('blur-[4px]')
|
||||
})
|
||||
|
||||
test('font size — named values', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
width: 200,
|
||||
height: 24,
|
||||
text: 'Hello',
|
||||
fontSize: 24,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('text-2xl')
|
||||
})
|
||||
|
||||
test('font size — arbitrary', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
width: 200,
|
||||
height: 24,
|
||||
text: 'Hello',
|
||||
fontSize: 22,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('text-[22px]')
|
||||
})
|
||||
|
||||
test('font weight — named values', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
width: 100,
|
||||
height: 20,
|
||||
text: 'X',
|
||||
fontWeight: 600
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain('font-semibold')
|
||||
})
|
||||
|
||||
test('font family keeps original spaces via arbitrary value', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
width: 100,
|
||||
height: 20,
|
||||
text: 'X',
|
||||
fontFamily: 'IBM Plex Sans'
|
||||
})
|
||||
expect(tw(graph, node.id)).toContain("font-['IBM Plex Sans']")
|
||||
})
|
||||
|
||||
test('section uses <section> tag', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('SECTION', pageId(graph), {
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('<section')
|
||||
})
|
||||
|
||||
test('frame with children renders nested', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
name: 'Card',
|
||||
width: 320,
|
||||
height: 200,
|
||||
layoutMode: 'VERTICAL',
|
||||
itemSpacing: 8,
|
||||
primaryAxisSizing: 'HUG',
|
||||
counterAxisSizing: 'FIXED',
|
||||
fills: [{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.95, a: 1 }, opacity: 1, visible: true }],
|
||||
cornerRadius: 12
|
||||
})
|
||||
graph.createNode('TEXT', frame.id, {
|
||||
name: 'Title',
|
||||
width: 200,
|
||||
height: 20,
|
||||
text: 'Card Title',
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('<div')
|
||||
expect(jsx).toContain('flex flex-col')
|
||||
expect(jsx).toContain('gap-2')
|
||||
expect(jsx).toContain('rounded-xl')
|
||||
expect(jsx).toContain(' <p')
|
||||
expect(jsx).toContain('text-base')
|
||||
expect(jsx).toContain('font-bold')
|
||||
expect(jsx).toContain('>Card Title</p>')
|
||||
expect(jsx).toContain('</div>')
|
||||
})
|
||||
|
||||
test('selectionToJSX with tailwind format', () => {
|
||||
const graph = makeGraph()
|
||||
const a = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
name: 'A',
|
||||
width: 40,
|
||||
height: 40
|
||||
})
|
||||
const b = graph.createNode('ELLIPSE', pageId(graph), {
|
||||
name: 'B',
|
||||
width: 80,
|
||||
height: 80
|
||||
})
|
||||
const jsx = selectionToJSX([a.id, b.id], graph, 'tailwind')
|
||||
expect(jsx).toContain('<div')
|
||||
expect(jsx).not.toContain('<Rectangle')
|
||||
expect(jsx).not.toContain('<Ellipse')
|
||||
expect(jsx).toContain('\n\n')
|
||||
})
|
||||
|
||||
test('grow emits grow class', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 400,
|
||||
height: 100,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const child = graph.createNode('RECTANGLE', frame.id, {
|
||||
width: 100,
|
||||
height: 50,
|
||||
layoutGrow: 1
|
||||
})
|
||||
const jsx = tw(graph, child.id)
|
||||
expect(jsx).toContain('grow')
|
||||
})
|
||||
|
||||
test('wrap emits flex-wrap', () => {
|
||||
const graph = makeGraph()
|
||||
const frame = graph.createNode('FRAME', pageId(graph), {
|
||||
width: 400,
|
||||
height: 400,
|
||||
layoutMode: 'HORIZONTAL',
|
||||
layoutWrap: 'WRAP',
|
||||
counterAxisSpacing: 12,
|
||||
primaryAxisSizing: 'FIXED',
|
||||
counterAxisSizing: 'FIXED'
|
||||
})
|
||||
const jsx = tw(graph, frame.id)
|
||||
expect(jsx).toContain('flex-wrap')
|
||||
expect(jsx).toContain('gap-y-3')
|
||||
})
|
||||
|
||||
test('default values are omitted', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 100,
|
||||
height: 100,
|
||||
opacity: 1,
|
||||
rotation: 0,
|
||||
cornerRadius: 0
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).not.toContain('opacity')
|
||||
expect(jsx).not.toContain('rotate')
|
||||
expect(jsx).not.toContain('rounded')
|
||||
})
|
||||
|
||||
test('white color uses named class', () => {
|
||||
const graph = makeGraph()
|
||||
const node = graph.createNode('TEXT', pageId(graph), {
|
||||
width: 100,
|
||||
height: 20,
|
||||
text: 'X',
|
||||
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
|
||||
})
|
||||
const jsx = tw(graph, node.id)
|
||||
expect(jsx).toContain('text-white')
|
||||
expect(jsx).not.toContain('#')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue