@zseven-w/pen-codegen 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/package.json +25 -0
- package/src/__tests__/codegen.test.ts +89 -0
- package/src/compose-generator.ts +807 -0
- package/src/css-variables-generator.ts +138 -0
- package/src/flutter-generator.ts +581 -0
- package/src/html-generator.ts +403 -0
- package/src/index.ts +26 -0
- package/src/react-generator.ts +401 -0
- package/src/react-native-generator.ts +569 -0
- package/src/svelte-generator.ts +296 -0
- package/src/swiftui-generator.ts +754 -0
- package/src/vue-generator.ts +308 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
|
|
2
|
+
import { getActivePageChildren } from '@zseven-w/pen-core'
|
|
3
|
+
import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@zseven-w/pen-types'
|
|
4
|
+
import { isVariableRef } from '@zseven-w/pen-core'
|
|
5
|
+
import { variableNameToCSS } from './css-variables-generator.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts PenDocument nodes to React Native code with inline styles.
|
|
9
|
+
* $variable references are output as /* var(--name) */ comments.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Convert a `$variable` ref to a comment placeholder, or return the raw value. */
|
|
13
|
+
function varOrLiteral(value: string): string {
|
|
14
|
+
if (isVariableRef(value)) {
|
|
15
|
+
return `var(${variableNameToCSS(value.slice(1))})`
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function indent(depth: number): string {
|
|
21
|
+
return ' '.repeat(depth)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function kebabToPascal(name: string): string {
|
|
25
|
+
return name.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Return a hex color string, or a comment for variable refs. */
|
|
29
|
+
function hexColor(value: string): string {
|
|
30
|
+
if (isVariableRef(value)) {
|
|
31
|
+
return `/* ${varOrLiteral(value)} */ '#000000'`
|
|
32
|
+
}
|
|
33
|
+
return `'${value}'`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract backgroundColor from fills. */
|
|
37
|
+
function fillToStyle(fills: PenFill[] | undefined): Record<string, string> {
|
|
38
|
+
if (!fills || fills.length === 0) return {}
|
|
39
|
+
const fill = fills[0]
|
|
40
|
+
if (fill.type === 'solid') {
|
|
41
|
+
return { backgroundColor: hexColor(fill.color) }
|
|
42
|
+
}
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Extract text color from fills. */
|
|
47
|
+
function fillToTextColor(fills: PenFill[] | undefined): Record<string, string> {
|
|
48
|
+
if (!fills || fills.length === 0) return {}
|
|
49
|
+
const fill = fills[0]
|
|
50
|
+
if (fill.type === 'solid') {
|
|
51
|
+
return { color: hexColor(fill.color) }
|
|
52
|
+
}
|
|
53
|
+
return {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Extract border styles from stroke. */
|
|
57
|
+
function strokeToStyle(stroke: PenStroke | undefined): Record<string, string> {
|
|
58
|
+
if (!stroke) return {}
|
|
59
|
+
const styles: Record<string, string> = {}
|
|
60
|
+
if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) {
|
|
61
|
+
styles.borderWidth = `/* ${varOrLiteral(stroke.thickness)} */ 1`
|
|
62
|
+
} else {
|
|
63
|
+
const thickness = typeof stroke.thickness === 'number'
|
|
64
|
+
? stroke.thickness
|
|
65
|
+
: stroke.thickness[0]
|
|
66
|
+
styles.borderWidth = String(thickness)
|
|
67
|
+
}
|
|
68
|
+
if (stroke.fill && stroke.fill.length > 0) {
|
|
69
|
+
const sf = stroke.fill[0]
|
|
70
|
+
if (sf.type === 'solid') {
|
|
71
|
+
styles.borderColor = hexColor(sf.color)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return styles
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Extract shadow styles from effects. */
|
|
78
|
+
function effectsToStyle(effects: PenEffect[] | undefined): Record<string, string> {
|
|
79
|
+
if (!effects || effects.length === 0) return {}
|
|
80
|
+
const styles: Record<string, string> = {}
|
|
81
|
+
for (const effect of effects) {
|
|
82
|
+
if (effect.type === 'shadow') {
|
|
83
|
+
const s = effect as ShadowEffect
|
|
84
|
+
styles.shadowColor = `'${s.color}'`
|
|
85
|
+
styles.shadowOffset = `{ width: ${s.offsetX}, height: ${s.offsetY} }`
|
|
86
|
+
styles.shadowOpacity = '1'
|
|
87
|
+
styles.shadowRadius = String(s.blur)
|
|
88
|
+
styles.elevation = String(Math.max(1, Math.round(s.blur / 2)))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return styles
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract borderRadius styles from corner radius. */
|
|
95
|
+
function cornerRadiusToStyle(
|
|
96
|
+
cr: number | [number, number, number, number] | undefined,
|
|
97
|
+
): Record<string, string> {
|
|
98
|
+
if (cr === undefined) return {}
|
|
99
|
+
if (typeof cr === 'number') {
|
|
100
|
+
return cr === 0 ? {} : { borderRadius: String(cr) }
|
|
101
|
+
}
|
|
102
|
+
const [tl, tr, br, bl] = cr
|
|
103
|
+
if (tl === tr && tr === br && br === bl) {
|
|
104
|
+
return tl === 0 ? {} : { borderRadius: String(tl) }
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
borderTopLeftRadius: String(tl),
|
|
108
|
+
borderTopRightRadius: String(tr),
|
|
109
|
+
borderBottomRightRadius: String(br),
|
|
110
|
+
borderBottomLeftRadius: String(bl),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Extract layout styles from container props. */
|
|
115
|
+
function layoutToStyle(node: ContainerProps): Record<string, string> {
|
|
116
|
+
const styles: Record<string, string> = {}
|
|
117
|
+
if (node.layout === 'vertical') {
|
|
118
|
+
styles.flexDirection = "'column'"
|
|
119
|
+
} else if (node.layout === 'horizontal') {
|
|
120
|
+
styles.flexDirection = "'row'"
|
|
121
|
+
}
|
|
122
|
+
if (node.gap !== undefined) {
|
|
123
|
+
if (typeof node.gap === 'string' && isVariableRef(node.gap)) {
|
|
124
|
+
styles.gap = `/* ${varOrLiteral(node.gap)} */ 0`
|
|
125
|
+
} else if (typeof node.gap === 'number' && node.gap > 0) {
|
|
126
|
+
styles.gap = String(node.gap)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (node.padding !== undefined) {
|
|
130
|
+
if (typeof node.padding === 'string' && isVariableRef(node.padding)) {
|
|
131
|
+
styles.padding = `/* ${varOrLiteral(node.padding)} */ 0`
|
|
132
|
+
} else if (typeof node.padding === 'number') {
|
|
133
|
+
styles.padding = String(node.padding)
|
|
134
|
+
} else if (Array.isArray(node.padding)) {
|
|
135
|
+
if (node.padding.length === 2) {
|
|
136
|
+
styles.paddingVertical = String(node.padding[0])
|
|
137
|
+
styles.paddingHorizontal = String(node.padding[1])
|
|
138
|
+
} else if (node.padding.length === 4) {
|
|
139
|
+
styles.paddingTop = String(node.padding[0])
|
|
140
|
+
styles.paddingRight = String(node.padding[1])
|
|
141
|
+
styles.paddingBottom = String(node.padding[2])
|
|
142
|
+
styles.paddingLeft = String(node.padding[3])
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (node.justifyContent) {
|
|
147
|
+
const jcMap: Record<string, string> = {
|
|
148
|
+
start: "'flex-start'",
|
|
149
|
+
center: "'center'",
|
|
150
|
+
end: "'flex-end'",
|
|
151
|
+
space_between: "'space-between'",
|
|
152
|
+
space_around: "'space-around'",
|
|
153
|
+
}
|
|
154
|
+
if (jcMap[node.justifyContent]) styles.justifyContent = jcMap[node.justifyContent]
|
|
155
|
+
}
|
|
156
|
+
if (node.alignItems) {
|
|
157
|
+
const aiMap: Record<string, string> = {
|
|
158
|
+
start: "'flex-start'",
|
|
159
|
+
center: "'center'",
|
|
160
|
+
end: "'flex-end'",
|
|
161
|
+
}
|
|
162
|
+
if (aiMap[node.alignItems]) styles.alignItems = aiMap[node.alignItems]
|
|
163
|
+
}
|
|
164
|
+
if (node.clipContent) {
|
|
165
|
+
styles.overflow = "'hidden'"
|
|
166
|
+
}
|
|
167
|
+
return styles
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Extract text-specific styles. */
|
|
171
|
+
function textToStyle(node: TextNode): Record<string, string> {
|
|
172
|
+
const styles: Record<string, string> = {}
|
|
173
|
+
if (node.fontSize) styles.fontSize = String(node.fontSize)
|
|
174
|
+
if (node.fontWeight) {
|
|
175
|
+
const w = typeof node.fontWeight === 'number' ? node.fontWeight : parseInt(node.fontWeight, 10)
|
|
176
|
+
if (!isNaN(w)) styles.fontWeight = `'${w}'`
|
|
177
|
+
}
|
|
178
|
+
if (node.fontStyle === 'italic') styles.fontStyle = "'italic'"
|
|
179
|
+
if (node.textAlign) {
|
|
180
|
+
const taMap: Record<string, string> = {
|
|
181
|
+
left: "'left'",
|
|
182
|
+
center: "'center'",
|
|
183
|
+
right: "'right'",
|
|
184
|
+
}
|
|
185
|
+
if (taMap[node.textAlign]) styles.textAlign = taMap[node.textAlign]
|
|
186
|
+
}
|
|
187
|
+
if (node.fontFamily) styles.fontFamily = `'${node.fontFamily}'`
|
|
188
|
+
if (node.letterSpacing) styles.letterSpacing = String(node.letterSpacing)
|
|
189
|
+
if (node.lineHeight && node.fontSize) {
|
|
190
|
+
styles.lineHeight = String(Math.round(node.fontSize * node.lineHeight))
|
|
191
|
+
}
|
|
192
|
+
if (node.underline && node.strikethrough) {
|
|
193
|
+
styles.textDecorationLine = "'underline line-through'"
|
|
194
|
+
} else if (node.underline) {
|
|
195
|
+
styles.textDecorationLine = "'underline'"
|
|
196
|
+
} else if (node.strikethrough) {
|
|
197
|
+
styles.textDecorationLine = "'line-through'"
|
|
198
|
+
}
|
|
199
|
+
return styles
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Format a style object as an inline style string. */
|
|
203
|
+
function formatStyle(styles: Record<string, string>): string {
|
|
204
|
+
const entries = Object.entries(styles)
|
|
205
|
+
if (entries.length === 0) return '{}'
|
|
206
|
+
const parts = entries.map(([k, v]) => {
|
|
207
|
+
// Values that are already quoted, numeric, or contain special syntax
|
|
208
|
+
if (
|
|
209
|
+
v.startsWith("'") ||
|
|
210
|
+
v.startsWith('"') ||
|
|
211
|
+
v.startsWith('{') ||
|
|
212
|
+
v.startsWith('/*') ||
|
|
213
|
+
/^-?\d+(\.\d+)?$/.test(v)
|
|
214
|
+
) {
|
|
215
|
+
return `${k}: ${v}`
|
|
216
|
+
}
|
|
217
|
+
return `${k}: ${v}`
|
|
218
|
+
})
|
|
219
|
+
return `{ ${parts.join(', ')} }`
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getTextContent(node: TextNode): string {
|
|
223
|
+
if (typeof node.content === 'string') return node.content
|
|
224
|
+
return node.content.map((s) => s.text).join('')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function escapeJSX(text: string): string {
|
|
228
|
+
return text
|
|
229
|
+
.replace(/&/g, '&')
|
|
230
|
+
.replace(/</g, '<')
|
|
231
|
+
.replace(/>/g, '>')
|
|
232
|
+
.replace(/{/g, '{')
|
|
233
|
+
.replace(/}/g, '}')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Check if any node in the tree is a path or polygon. */
|
|
237
|
+
function hasSvgNodes(nodes: PenNode[]): boolean {
|
|
238
|
+
for (const node of nodes) {
|
|
239
|
+
if (node.type === 'path' || node.type === 'polygon') return true
|
|
240
|
+
if ('children' in node && node.children) {
|
|
241
|
+
if (hasSvgNodes(node.children)) return true
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return false
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Collect common position/opacity/rotation styles. */
|
|
248
|
+
function commonStyles(node: PenNode): Record<string, string> {
|
|
249
|
+
const styles: Record<string, string> = {}
|
|
250
|
+
if (node.x !== undefined || node.y !== undefined) {
|
|
251
|
+
styles.position = "'absolute'"
|
|
252
|
+
if (node.x !== undefined) styles.left = String(node.x)
|
|
253
|
+
if (node.y !== undefined) styles.top = String(node.y)
|
|
254
|
+
}
|
|
255
|
+
if (node.opacity !== undefined && node.opacity !== 1) {
|
|
256
|
+
if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) {
|
|
257
|
+
styles.opacity = `/* ${varOrLiteral(node.opacity)} */ 1`
|
|
258
|
+
} else if (typeof node.opacity === 'number') {
|
|
259
|
+
styles.opacity = String(node.opacity)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (node.rotation) {
|
|
263
|
+
styles.transform = `[{ rotate: '${node.rotation}deg' }]`
|
|
264
|
+
}
|
|
265
|
+
return styles
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Main node renderer. Returns JSX string for a single node. */
|
|
269
|
+
function generateNodeRN(node: PenNode, depth: number): string {
|
|
270
|
+
const pad = indent(depth)
|
|
271
|
+
|
|
272
|
+
switch (node.type) {
|
|
273
|
+
case 'frame':
|
|
274
|
+
case 'rectangle':
|
|
275
|
+
case 'group': {
|
|
276
|
+
const styles: Record<string, string> = {
|
|
277
|
+
...commonStyles(node),
|
|
278
|
+
}
|
|
279
|
+
if (typeof node.width === 'number') styles.width = String(node.width)
|
|
280
|
+
if (typeof node.height === 'number') styles.height = String(node.height)
|
|
281
|
+
Object.assign(styles, fillToStyle(node.fill))
|
|
282
|
+
Object.assign(styles, strokeToStyle(node.stroke))
|
|
283
|
+
Object.assign(styles, cornerRadiusToStyle(node.cornerRadius))
|
|
284
|
+
Object.assign(styles, effectsToStyle(node.effects))
|
|
285
|
+
Object.assign(styles, layoutToStyle(node))
|
|
286
|
+
|
|
287
|
+
const childNodes = node.children ?? []
|
|
288
|
+
const comment = node.name ? `${pad}{/* ${node.name} */}\n` : ''
|
|
289
|
+
if (childNodes.length === 0) {
|
|
290
|
+
return `${comment}${pad}<View style=${formatStyle(styles)} />`
|
|
291
|
+
}
|
|
292
|
+
const childrenJSX = childNodes
|
|
293
|
+
.map((c) => generateNodeRN(c, depth + 1))
|
|
294
|
+
.join('\n')
|
|
295
|
+
return `${comment}${pad}<View style=${formatStyle(styles)}>\n${childrenJSX}\n${pad}</View>`
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'ellipse': {
|
|
299
|
+
const w = typeof node.width === 'number' ? node.width : 100
|
|
300
|
+
const h = typeof node.height === 'number' ? node.height : 100
|
|
301
|
+
const styles: Record<string, string> = {
|
|
302
|
+
...commonStyles(node),
|
|
303
|
+
width: String(w),
|
|
304
|
+
height: String(h),
|
|
305
|
+
borderRadius: String(Math.min(w, h) / 2),
|
|
306
|
+
}
|
|
307
|
+
Object.assign(styles, fillToStyle(node.fill))
|
|
308
|
+
Object.assign(styles, strokeToStyle(node.stroke))
|
|
309
|
+
Object.assign(styles, effectsToStyle(node.effects))
|
|
310
|
+
return `${pad}<View style=${formatStyle(styles)} />`
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
case 'text': {
|
|
314
|
+
const styles: Record<string, string> = {
|
|
315
|
+
...commonStyles(node),
|
|
316
|
+
}
|
|
317
|
+
if (typeof node.width === 'number') styles.width = String(node.width)
|
|
318
|
+
if (typeof node.height === 'number') styles.height = String(node.height)
|
|
319
|
+
Object.assign(styles, fillToTextColor(node.fill))
|
|
320
|
+
Object.assign(styles, textToStyle(node))
|
|
321
|
+
Object.assign(styles, effectsToStyle(node.effects))
|
|
322
|
+
|
|
323
|
+
const text = escapeJSX(getTextContent(node))
|
|
324
|
+
return `${pad}<Text style=${formatStyle(styles)}>${text}</Text>`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case 'line': {
|
|
328
|
+
const lineNode = node as LineNode
|
|
329
|
+
const w = lineNode.x2 !== undefined ? Math.abs(lineNode.x2 - (lineNode.x ?? 0)) : 0
|
|
330
|
+
const styles: Record<string, string> = {
|
|
331
|
+
...commonStyles(node),
|
|
332
|
+
width: String(w),
|
|
333
|
+
}
|
|
334
|
+
if (lineNode.stroke) {
|
|
335
|
+
const thickness = typeof lineNode.stroke.thickness === 'number'
|
|
336
|
+
? lineNode.stroke.thickness
|
|
337
|
+
: typeof lineNode.stroke.thickness === 'string'
|
|
338
|
+
? 1
|
|
339
|
+
: lineNode.stroke.thickness[0]
|
|
340
|
+
styles.height = String(thickness)
|
|
341
|
+
if (lineNode.stroke.fill && lineNode.stroke.fill.length > 0) {
|
|
342
|
+
const sf = lineNode.stroke.fill[0]
|
|
343
|
+
if (sf.type === 'solid') {
|
|
344
|
+
styles.backgroundColor = hexColor(sf.color)
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
styles.backgroundColor = "'#999999'"
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
styles.height = '1'
|
|
351
|
+
styles.backgroundColor = "'#999999'"
|
|
352
|
+
}
|
|
353
|
+
return `${pad}<View style=${formatStyle(styles)} />`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
case 'path': {
|
|
357
|
+
const pathNode = node as PathNode
|
|
358
|
+
const w = typeof pathNode.width === 'number' ? pathNode.width : 100
|
|
359
|
+
const h = typeof pathNode.height === 'number' ? pathNode.height : 100
|
|
360
|
+
const fillColor = pathNode.fill?.[0]?.type === 'solid'
|
|
361
|
+
? varOrLiteral(pathNode.fill[0].color)
|
|
362
|
+
: 'currentColor'
|
|
363
|
+
const posStyles = commonStyles(node)
|
|
364
|
+
const posStr = Object.keys(posStyles).length > 0
|
|
365
|
+
? ` style=${formatStyle(posStyles)}`
|
|
366
|
+
: ''
|
|
367
|
+
const viewTag = Object.keys(posStyles).length > 0 ? 'View' : null
|
|
368
|
+
const svgContent = [
|
|
369
|
+
`${pad}${viewTag ? `<View${posStr}>` : ''}`,
|
|
370
|
+
`${pad}${viewTag ? ' ' : ''}<Svg width={${w}} height={${h}} viewBox="0 0 ${w} ${h}">`,
|
|
371
|
+
`${pad}${viewTag ? ' ' : ' '}<SvgPath d="${pathNode.d}" fill="${fillColor}" />`,
|
|
372
|
+
`${pad}${viewTag ? ' ' : ''}</Svg>`,
|
|
373
|
+
]
|
|
374
|
+
if (viewTag) svgContent.push(`${pad}</View>`)
|
|
375
|
+
return svgContent.filter(Boolean).join('\n')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
case 'polygon': {
|
|
379
|
+
const polyNode = node as PolygonNode
|
|
380
|
+
const w = typeof polyNode.width === 'number' ? polyNode.width : 100
|
|
381
|
+
const h = typeof polyNode.height === 'number' ? polyNode.height : 100
|
|
382
|
+
const fillColor = polyNode.fill?.[0]?.type === 'solid'
|
|
383
|
+
? varOrLiteral(polyNode.fill[0].color)
|
|
384
|
+
: 'none'
|
|
385
|
+
const sides = polyNode.polygonCount
|
|
386
|
+
const points = polygonPoints(sides, w, h)
|
|
387
|
+
const posStyles = commonStyles(node)
|
|
388
|
+
const posStr = Object.keys(posStyles).length > 0
|
|
389
|
+
? ` style=${formatStyle(posStyles)}`
|
|
390
|
+
: ''
|
|
391
|
+
const viewTag = Object.keys(posStyles).length > 0 ? 'View' : null
|
|
392
|
+
const svgContent = [
|
|
393
|
+
`${pad}${viewTag ? `<View${posStr}>` : ''}`,
|
|
394
|
+
`${pad}${viewTag ? ' ' : ''}<Svg width={${w}} height={${h}} viewBox="0 0 ${w} ${h}">`,
|
|
395
|
+
`${pad}${viewTag ? ' ' : ' '}<SvgPolygon points="${points}" fill="${fillColor}" />`,
|
|
396
|
+
`${pad}${viewTag ? ' ' : ''}</Svg>`,
|
|
397
|
+
]
|
|
398
|
+
if (viewTag) svgContent.push(`${pad}</View>`)
|
|
399
|
+
return svgContent.filter(Boolean).join('\n')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
case 'image': {
|
|
403
|
+
const imgNode = node as ImageNode
|
|
404
|
+
const styles: Record<string, string> = {
|
|
405
|
+
...commonStyles(node),
|
|
406
|
+
}
|
|
407
|
+
if (typeof imgNode.width === 'number') styles.width = String(imgNode.width)
|
|
408
|
+
if (typeof imgNode.height === 'number') styles.height = String(imgNode.height)
|
|
409
|
+
if (imgNode.objectFit === 'fit') {
|
|
410
|
+
styles.resizeMode = "'contain'"
|
|
411
|
+
} else if (imgNode.objectFit === 'fill') {
|
|
412
|
+
styles.resizeMode = "'stretch'"
|
|
413
|
+
} else {
|
|
414
|
+
styles.resizeMode = "'cover'"
|
|
415
|
+
}
|
|
416
|
+
Object.assign(styles, cornerRadiusToStyle(imgNode.cornerRadius))
|
|
417
|
+
Object.assign(styles, effectsToStyle(imgNode.effects))
|
|
418
|
+
|
|
419
|
+
const src = imgNode.src
|
|
420
|
+
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) {
|
|
421
|
+
return `${pad}<Image source={{ uri: '${src}' }} style=${formatStyle(styles)} />`
|
|
422
|
+
}
|
|
423
|
+
return `${pad}<Image source={require('${src}')} style=${formatStyle(styles)} />`
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
case 'icon_font': {
|
|
427
|
+
const size = typeof node.width === 'number' ? node.width : 24
|
|
428
|
+
const color = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor'
|
|
429
|
+
const iconComp = kebabToPascal(node.iconFontName || 'circle')
|
|
430
|
+
return `${pad}<${iconComp} size={${size}} color="${color}" />`
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case 'ref':
|
|
434
|
+
return `${pad}{/* Ref: ${node.ref} */}`
|
|
435
|
+
|
|
436
|
+
default:
|
|
437
|
+
return `${pad}{/* Unknown node */}`
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Generate polygon points string for SVG. */
|
|
442
|
+
function polygonPoints(sides: number, w: number, h: number): string {
|
|
443
|
+
const cx = w / 2
|
|
444
|
+
const cy = h / 2
|
|
445
|
+
const r = Math.min(w, h) / 2
|
|
446
|
+
const points: string[] = []
|
|
447
|
+
for (let i = 0; i < sides; i++) {
|
|
448
|
+
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2
|
|
449
|
+
const px = cx + r * Math.cos(angle)
|
|
450
|
+
const py = cy + r * Math.sin(angle)
|
|
451
|
+
points.push(`${px.toFixed(1)},${py.toFixed(1)}`)
|
|
452
|
+
}
|
|
453
|
+
return points.join(' ')
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function generateReactNativeCode(
|
|
457
|
+
nodes: PenNode[],
|
|
458
|
+
componentName = 'GeneratedDesign',
|
|
459
|
+
): string {
|
|
460
|
+
if (nodes.length === 0) {
|
|
461
|
+
return [
|
|
462
|
+
"import React from 'react'",
|
|
463
|
+
"import { View } from 'react-native'",
|
|
464
|
+
'',
|
|
465
|
+
`export function ${componentName}() {`,
|
|
466
|
+
' return <View style={{ position: \'relative\' }} />',
|
|
467
|
+
'}',
|
|
468
|
+
'',
|
|
469
|
+
].join('\n')
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Compute bounding box for root wrapper
|
|
473
|
+
let maxW = 0
|
|
474
|
+
let maxH = 0
|
|
475
|
+
for (const node of nodes) {
|
|
476
|
+
const x = node.x ?? 0
|
|
477
|
+
const y = node.y ?? 0
|
|
478
|
+
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
479
|
+
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
480
|
+
maxW = Math.max(maxW, x + w)
|
|
481
|
+
maxH = Math.max(maxH, y + h)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const needsSvg = hasSvgNodes(nodes)
|
|
485
|
+
|
|
486
|
+
// Build imports
|
|
487
|
+
const rnImports = new Set<string>(['View'])
|
|
488
|
+
collectImports(nodes, rnImports)
|
|
489
|
+
const rnImportList = Array.from(rnImports).sort()
|
|
490
|
+
|
|
491
|
+
const lines: string[] = [
|
|
492
|
+
"import React from 'react'",
|
|
493
|
+
`import { ${rnImportList.join(', ')} } from 'react-native'`,
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
if (needsSvg) {
|
|
497
|
+
const svgImports: string[] = ['default as Svg']
|
|
498
|
+
if (hasNodeType(nodes, 'path')) svgImports.push('Path as SvgPath')
|
|
499
|
+
if (hasNodeType(nodes, 'polygon')) svgImports.push('Polygon as SvgPolygon')
|
|
500
|
+
lines.push(`import Svg, { ${svgImports.filter((s) => s !== 'default as Svg').join(', ')} } from 'react-native-svg'`)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
lines.push('')
|
|
504
|
+
|
|
505
|
+
// Root wrapper styles
|
|
506
|
+
const rootStyles: Record<string, string> = { position: "'relative'" }
|
|
507
|
+
if (maxW > 0) rootStyles.width = String(maxW)
|
|
508
|
+
if (maxH > 0) rootStyles.height = String(maxH)
|
|
509
|
+
|
|
510
|
+
const childrenJSX = nodes
|
|
511
|
+
.map((n) => generateNodeRN(n, 2))
|
|
512
|
+
.join('\n')
|
|
513
|
+
|
|
514
|
+
lines.push(`export function ${componentName}() {`)
|
|
515
|
+
lines.push(' return (')
|
|
516
|
+
lines.push(` <View style=${formatStyle(rootStyles)}>`)
|
|
517
|
+
lines.push(childrenJSX)
|
|
518
|
+
lines.push(' </View>')
|
|
519
|
+
lines.push(' )')
|
|
520
|
+
lines.push('}')
|
|
521
|
+
lines.push('')
|
|
522
|
+
|
|
523
|
+
return lines.join('\n')
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Collect required react-native imports by walking the node tree. */
|
|
527
|
+
function collectImports(nodes: PenNode[], imports: Set<string>): void {
|
|
528
|
+
for (const node of nodes) {
|
|
529
|
+
switch (node.type) {
|
|
530
|
+
case 'text':
|
|
531
|
+
imports.add('Text')
|
|
532
|
+
break
|
|
533
|
+
case 'image':
|
|
534
|
+
imports.add('Image')
|
|
535
|
+
break
|
|
536
|
+
case 'frame':
|
|
537
|
+
case 'rectangle':
|
|
538
|
+
case 'group':
|
|
539
|
+
case 'ellipse':
|
|
540
|
+
case 'line':
|
|
541
|
+
// View is already included
|
|
542
|
+
break
|
|
543
|
+
}
|
|
544
|
+
if ('children' in node && node.children) {
|
|
545
|
+
collectImports(node.children, imports)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Check if any node in the tree matches a given type. */
|
|
551
|
+
function hasNodeType(nodes: PenNode[], type: string): boolean {
|
|
552
|
+
for (const node of nodes) {
|
|
553
|
+
if (node.type === type) return true
|
|
554
|
+
if ('children' in node && node.children) {
|
|
555
|
+
if (hasNodeType(node.children, type)) return true
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function generateReactNativeFromDocument(
|
|
562
|
+
doc: PenDocument,
|
|
563
|
+
activePageId?: string | null,
|
|
564
|
+
): string {
|
|
565
|
+
const children = activePageId !== undefined
|
|
566
|
+
? getActivePageChildren(doc, activePageId)
|
|
567
|
+
: doc.children
|
|
568
|
+
return generateReactNativeCode(children, 'GeneratedDesign')
|
|
569
|
+
}
|