@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,581 @@
|
|
|
1
|
+
import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, EllipseNode, LineNode, PathNode, PolygonNode } from '@zseven-w/pen-types'
|
|
2
|
+
import { getActivePageChildren } from '@zseven-w/pen-core'
|
|
3
|
+
import type { PenFill, PenStroke, PenEffect, ShadowEffect, BlurEffect } 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 Flutter (Dart) code.
|
|
9
|
+
* $variable references are output as var(--name) comments for manual mapping.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function varOrLiteral(value: string): string {
|
|
13
|
+
if (isVariableRef(value)) return `var(${variableNameToCSS(value.slice(1))})`
|
|
14
|
+
return value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function indent(depth: number): string {
|
|
18
|
+
return ' '.repeat(depth)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hexToFlutterColor(hex: string): string {
|
|
22
|
+
if (hex.startsWith('$')) return `Color(0x00000000) /* ${varOrLiteral(hex)} */`
|
|
23
|
+
const clean = hex.replace('#', '')
|
|
24
|
+
if (clean.length === 6) return `Color(0xFF${clean.toUpperCase()})`
|
|
25
|
+
if (clean.length === 8) {
|
|
26
|
+
const [rr, gg, bb, aa] = [clean.substring(0, 2), clean.substring(2, 4), clean.substring(4, 6), clean.substring(6, 8)]
|
|
27
|
+
return `Color(0x${aa.toUpperCase()}${rr.toUpperCase()}${gg.toUpperCase()}${bb.toUpperCase()})`
|
|
28
|
+
}
|
|
29
|
+
return `Color(0x00000000) /* ${hex} */`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeDartString(text: string): string {
|
|
33
|
+
return text.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$').replace(/\n/g, '\\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getTextContent(node: TextNode): string {
|
|
37
|
+
if (typeof node.content === 'string') return node.content
|
|
38
|
+
return node.content.map((s) => s.text).join('')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fillToDecoration(fills: PenFill[] | undefined): { color?: string; gradient?: string } | null {
|
|
42
|
+
if (!fills || fills.length === 0) return null
|
|
43
|
+
const fill = fills[0]
|
|
44
|
+
if (fill.type === 'solid') return { color: hexToFlutterColor(fill.color) }
|
|
45
|
+
if (fill.type === 'linear_gradient') {
|
|
46
|
+
if (!fill.stops?.length) return null
|
|
47
|
+
const colors = fill.stops.map((s) => hexToFlutterColor(s.color)).join(', ')
|
|
48
|
+
return { gradient: `LinearGradient(colors: [${colors}])` }
|
|
49
|
+
}
|
|
50
|
+
if (fill.type === 'radial_gradient') {
|
|
51
|
+
if (!fill.stops?.length) return null
|
|
52
|
+
const colors = fill.stops.map((s) => hexToFlutterColor(s.color)).join(', ')
|
|
53
|
+
return { gradient: `RadialGradient(colors: [${colors}])` }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function fillColorOnly(fills: PenFill[] | undefined): string | null {
|
|
59
|
+
if (!fills || fills.length === 0) return null
|
|
60
|
+
const fill = fills[0]
|
|
61
|
+
return fill.type === 'solid' ? hexToFlutterColor(fill.color) : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cornerRadiusToFlutter(cr: number | [number, number, number, number] | undefined): string | null {
|
|
65
|
+
if (cr === undefined) return null
|
|
66
|
+
if (typeof cr === 'number') return cr > 0 ? `BorderRadius.circular(${cr})` : null
|
|
67
|
+
const [tl, tr, br, bl] = cr
|
|
68
|
+
if (tl === tr && tr === br && br === bl) return tl > 0 ? `BorderRadius.circular(${tl})` : null
|
|
69
|
+
return `BorderRadius.only(topLeft: Radius.circular(${tl}), topRight: Radius.circular(${tr}), bottomRight: Radius.circular(${br}), bottomLeft: Radius.circular(${bl}))`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function strokeToFlutterBorder(stroke: PenStroke | undefined): string | null {
|
|
73
|
+
if (!stroke) return null
|
|
74
|
+
const thickness = typeof stroke.thickness === 'number'
|
|
75
|
+
? stroke.thickness
|
|
76
|
+
: typeof stroke.thickness === 'string' ? stroke.thickness : stroke.thickness[0]
|
|
77
|
+
const thicknessStr = typeof thickness === 'string' && isVariableRef(thickness)
|
|
78
|
+
? `/* ${varOrLiteral(thickness)} */ 1` : String(thickness)
|
|
79
|
+
let strokeColor = 'Colors.grey'
|
|
80
|
+
if (stroke.fill && stroke.fill.length > 0 && stroke.fill[0].type === 'solid') {
|
|
81
|
+
strokeColor = hexToFlutterColor(stroke.fill[0].color)
|
|
82
|
+
}
|
|
83
|
+
return `Border.all(color: ${strokeColor}, width: ${thicknessStr})`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function effectsToBoxShadows(effects: PenEffect[] | undefined): string[] {
|
|
87
|
+
if (!effects || effects.length === 0) return []
|
|
88
|
+
const shadows: string[] = []
|
|
89
|
+
for (const effect of effects) {
|
|
90
|
+
if (effect.type === 'shadow') {
|
|
91
|
+
const s = effect as ShadowEffect
|
|
92
|
+
shadows.push(`BoxShadow(color: ${hexToFlutterColor(s.color)}, blurRadius: ${s.blur}, offset: Offset(${s.offsetX}, ${s.offsetY}))`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return shadows
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasBlurEffect(effects: PenEffect[] | undefined): BlurEffect | null {
|
|
99
|
+
if (!effects) return null
|
|
100
|
+
const found = effects.find((e) => e.type === 'blur' || e.type === 'background_blur')
|
|
101
|
+
return found ? (found as BlurEffect) : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function paddingToFlutter(
|
|
105
|
+
padding: number | [number, number] | [number, number, number, number] | string | undefined,
|
|
106
|
+
): string | null {
|
|
107
|
+
if (padding === undefined) return null
|
|
108
|
+
if (typeof padding === 'string' && isVariableRef(padding)) return `EdgeInsets.all(/* ${varOrLiteral(padding)} */ 0)`
|
|
109
|
+
if (typeof padding === 'number') return padding > 0 ? `EdgeInsets.all(${padding})` : null
|
|
110
|
+
if (Array.isArray(padding)) {
|
|
111
|
+
if (padding.length === 2) return `EdgeInsets.symmetric(vertical: ${padding[0]}, horizontal: ${padding[1]})`
|
|
112
|
+
if (padding.length === 4) {
|
|
113
|
+
const [top, right, bottom, left] = padding
|
|
114
|
+
return `EdgeInsets.fromLTRB(${left}, ${top}, ${right}, ${bottom})`
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function crossAxisToFlutter(alignItems: string | undefined): string | null {
|
|
121
|
+
if (!alignItems) return null
|
|
122
|
+
const m: Record<string, string> = { start: 'CrossAxisAlignment.start', center: 'CrossAxisAlignment.center', end: 'CrossAxisAlignment.end' }
|
|
123
|
+
return m[alignItems] ?? null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mainAxisToFlutter(justifyContent: string | undefined): string | null {
|
|
127
|
+
if (!justifyContent) return null
|
|
128
|
+
const m: Record<string, string> = {
|
|
129
|
+
start: 'MainAxisAlignment.start', center: 'MainAxisAlignment.center', end: 'MainAxisAlignment.end',
|
|
130
|
+
space_between: 'MainAxisAlignment.spaceBetween', space_around: 'MainAxisAlignment.spaceAround',
|
|
131
|
+
}
|
|
132
|
+
return m[justifyContent] ?? null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fontWeightToFlutter(weight: number | string | undefined): string | null {
|
|
136
|
+
if (weight === undefined) return null
|
|
137
|
+
const w = typeof weight === 'number' ? weight : parseInt(weight, 10)
|
|
138
|
+
if (isNaN(w)) return null
|
|
139
|
+
if (w <= 100) return 'FontWeight.w100'
|
|
140
|
+
if (w <= 200) return 'FontWeight.w200'
|
|
141
|
+
if (w <= 300) return 'FontWeight.w300'
|
|
142
|
+
if (w <= 400) return 'FontWeight.w400'
|
|
143
|
+
if (w <= 500) return 'FontWeight.w500'
|
|
144
|
+
if (w <= 600) return 'FontWeight.w600'
|
|
145
|
+
if (w <= 700) return 'FontWeight.w700'
|
|
146
|
+
if (w <= 800) return 'FontWeight.w800'
|
|
147
|
+
return 'FontWeight.w900'
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function textAlignToFlutter(align: string | undefined): string | null {
|
|
151
|
+
if (!align) return null
|
|
152
|
+
const m: Record<string, string> = { left: 'TextAlign.left', center: 'TextAlign.center', right: 'TextAlign.right', justify: 'TextAlign.justify' }
|
|
153
|
+
return m[align] ?? null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildBoxDecoration(
|
|
157
|
+
fills: PenFill[] | undefined, cornerRadius: number | [number, number, number, number] | undefined,
|
|
158
|
+
stroke: PenStroke | undefined, effects: PenEffect[] | undefined, pad: string,
|
|
159
|
+
): string | null {
|
|
160
|
+
const fillResult = fillToDecoration(fills)
|
|
161
|
+
const borderRadius = cornerRadiusToFlutter(cornerRadius)
|
|
162
|
+
const border = strokeToFlutterBorder(stroke)
|
|
163
|
+
const shadows = effectsToBoxShadows(effects)
|
|
164
|
+
if (!fillResult && !borderRadius && !border && shadows.length === 0) return null
|
|
165
|
+
|
|
166
|
+
const parts: string[] = []
|
|
167
|
+
if (fillResult?.color) parts.push(`${pad} color: ${fillResult.color},`)
|
|
168
|
+
if (fillResult?.gradient) parts.push(`${pad} gradient: ${fillResult.gradient},`)
|
|
169
|
+
if (borderRadius) parts.push(`${pad} borderRadius: ${borderRadius},`)
|
|
170
|
+
if (border) parts.push(`${pad} border: ${border},`)
|
|
171
|
+
if (shadows.length > 0) {
|
|
172
|
+
parts.push(`${pad} boxShadow: [`)
|
|
173
|
+
for (const s of shadows) parts.push(`${pad} ${s},`)
|
|
174
|
+
parts.push(`${pad} ],`)
|
|
175
|
+
}
|
|
176
|
+
return `BoxDecoration(\n${parts.join('\n')}\n${pad} )`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Wrapper helpers
|
|
180
|
+
function wrapOpacity(widget: string, node: PenNode, depth: number): string {
|
|
181
|
+
if (node.opacity === undefined || node.opacity === 1) return widget
|
|
182
|
+
const pad = indent(depth)
|
|
183
|
+
if (typeof node.opacity === 'string' && isVariableRef(node.opacity))
|
|
184
|
+
return `${pad}Opacity(\n${pad} opacity: /* ${varOrLiteral(node.opacity)} */ 1.0,\n${pad} child: ${widget.trimStart()},\n${pad})`
|
|
185
|
+
if (typeof node.opacity === 'number')
|
|
186
|
+
return `${pad}Opacity(\n${pad} opacity: ${node.opacity},\n${pad} child: ${widget.trimStart()},\n${pad})`
|
|
187
|
+
return widget
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function wrapRotation(widget: string, node: PenNode, depth: number): string {
|
|
191
|
+
if (!node.rotation) return widget
|
|
192
|
+
const pad = indent(depth)
|
|
193
|
+
return `${pad}Transform.rotate(\n${pad} angle: ${node.rotation} * pi / 180,\n${pad} child: ${widget.trimStart()},\n${pad})`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function wrapBlur(widget: string, effects: PenEffect[] | undefined, depth: number): string {
|
|
197
|
+
const blur = hasBlurEffect(effects)
|
|
198
|
+
if (!blur) return widget
|
|
199
|
+
const pad = indent(depth)
|
|
200
|
+
const r = blur.radius ?? 0
|
|
201
|
+
return `${pad}BackdropFilter(\n${pad} filter: ImageFilter.blur(sigmaX: ${r}, sigmaY: ${r}),\n${pad} child: ${widget.trimStart()},\n${pad})`
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function applyWrappers(widget: string, node: PenNode, depth: number): string {
|
|
205
|
+
let result = wrapBlur(widget, (node as any).effects, depth)
|
|
206
|
+
result = wrapOpacity(result, node, depth)
|
|
207
|
+
result = wrapRotation(result, node, depth)
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Node generators
|
|
212
|
+
function generateNodeFlutter(node: PenNode, depth: number): string {
|
|
213
|
+
switch (node.type) {
|
|
214
|
+
case 'frame': case 'rectangle': case 'group': return generateContainerFlutter(node, depth)
|
|
215
|
+
case 'ellipse': return generateEllipseFlutter(node as EllipseNode, depth)
|
|
216
|
+
case 'text': return generateTextFlutter(node as TextNode, depth)
|
|
217
|
+
case 'line': return generateLineFlutter(node as LineNode, depth)
|
|
218
|
+
case 'path': return generatePathFlutter(node as PathNode, depth)
|
|
219
|
+
case 'polygon': return generatePolygonFlutter(node as PolygonNode, depth)
|
|
220
|
+
case 'image': return generateImageFlutter(node as ImageNode, depth)
|
|
221
|
+
case 'icon_font': {
|
|
222
|
+
const size = typeof node.width === 'number' ? node.width : 24
|
|
223
|
+
const color = node.fill?.[0]?.type === 'solid' ? node.fill[0].color : null
|
|
224
|
+
const iconName = (node.iconFontName || 'circle').replace(/-/g, '_')
|
|
225
|
+
const colorStr = color ? `, color: Color(0xFF${color.replace('#', '')})` : ''
|
|
226
|
+
return `${indent(depth)}Icon(LucideIcons.${iconName}, size: ${size}${colorStr})`
|
|
227
|
+
}
|
|
228
|
+
case 'ref': return `${indent(depth)}// Ref: ${(node as any).ref}`
|
|
229
|
+
default: return `${indent(depth)}// Unsupported node type`
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function generateContainerFlutter(node: PenNode & ContainerProps, depth: number): string {
|
|
234
|
+
const pad = indent(depth)
|
|
235
|
+
const children = node.children ?? []
|
|
236
|
+
const hasLayout = node.layout === 'vertical' || node.layout === 'horizontal'
|
|
237
|
+
const gap = typeof node.gap === 'number' ? node.gap : 0
|
|
238
|
+
const gapIsVar = typeof node.gap === 'string' && isVariableRef(node.gap)
|
|
239
|
+
const gapComment = gapIsVar ? ` /* ${varOrLiteral(node.gap as string)} */` : ''
|
|
240
|
+
const decoration = buildBoxDecoration(node.fill, node.cornerRadius, node.stroke, node.effects, pad)
|
|
241
|
+
const paddingStr = paddingToFlutter(node.padding)
|
|
242
|
+
const comment = node.name ? `${pad}// ${node.name}\n` : ''
|
|
243
|
+
|
|
244
|
+
let innerWidget: string
|
|
245
|
+
|
|
246
|
+
if (children.length === 0 && !hasLayout) {
|
|
247
|
+
innerWidget = buildContainer(pad, decoration, paddingStr, node, null)
|
|
248
|
+
} else if (hasLayout) {
|
|
249
|
+
const isVertical = node.layout === 'vertical'
|
|
250
|
+
const layoutType = isVertical ? 'Column' : 'Row'
|
|
251
|
+
const crossAxis = crossAxisToFlutter(node.alignItems)
|
|
252
|
+
const mainAxis = mainAxisToFlutter(node.justifyContent)
|
|
253
|
+
const layoutParams: string[] = []
|
|
254
|
+
if (mainAxis) layoutParams.push(`${pad} mainAxisAlignment: ${mainAxis},`)
|
|
255
|
+
if (crossAxis) layoutParams.push(`${pad} crossAxisAlignment: ${crossAxis},`)
|
|
256
|
+
layoutParams.push(`${pad} mainAxisSize: MainAxisSize.min,`)
|
|
257
|
+
|
|
258
|
+
const childWidgets: string[] = []
|
|
259
|
+
for (let i = 0; i < children.length; i++) {
|
|
260
|
+
childWidgets.push(generateNodeFlutter(children[i], depth + 2))
|
|
261
|
+
if (i < children.length - 1 && (gap > 0 || gapIsVar)) {
|
|
262
|
+
const spacer = isVertical
|
|
263
|
+
? `${indent(depth + 2)}SizedBox(height: ${gapIsVar ? `0${gapComment}` : gap}),`
|
|
264
|
+
: `${indent(depth + 2)}SizedBox(width: ${gapIsVar ? `0${gapComment}` : gap}),`
|
|
265
|
+
childWidgets.push(spacer)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const layoutWidget = [
|
|
269
|
+
`${pad} ${layoutType}(`, ...layoutParams,
|
|
270
|
+
`${pad} children: [`, ...childWidgets.map((c) => c + ','),
|
|
271
|
+
`${pad} ],`, `${pad} )`,
|
|
272
|
+
].join('\n')
|
|
273
|
+
innerWidget = buildContainer(pad, decoration, paddingStr, node, layoutWidget)
|
|
274
|
+
} else {
|
|
275
|
+
const childWidgets = children.map((c) => {
|
|
276
|
+
const childStr = generateNodeFlutter(c, depth + 3)
|
|
277
|
+
const cx = c.x ?? 0, cy = c.y ?? 0
|
|
278
|
+
if (cx !== 0 || cy !== 0) {
|
|
279
|
+
const cPad = indent(depth + 2)
|
|
280
|
+
return `${cPad}Positioned(\n${cPad} left: ${cx},\n${cPad} top: ${cy},\n${cPad} child: ${childStr.trimStart()},\n${cPad})`
|
|
281
|
+
}
|
|
282
|
+
return childStr
|
|
283
|
+
})
|
|
284
|
+
const stackWidget = [
|
|
285
|
+
`${pad} Stack(`, `${pad} children: [`,
|
|
286
|
+
...childWidgets.map((c) => c + ','), `${pad} ],`, `${pad} )`,
|
|
287
|
+
].join('\n')
|
|
288
|
+
innerWidget = buildContainer(pad, decoration, paddingStr, node, stackWidget)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return `${comment}${applyWrappers(innerWidget, node, depth)}`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function buildContainer(
|
|
295
|
+
pad: string, decoration: string | null, paddingStr: string | null,
|
|
296
|
+
node: PenNode & ContainerProps, child: string | null,
|
|
297
|
+
): string {
|
|
298
|
+
const parts: string[] = [`${pad}Container(`]
|
|
299
|
+
if (typeof node.width === 'number') parts.push(`${pad} width: ${node.width},`)
|
|
300
|
+
if (typeof node.height === 'number') parts.push(`${pad} height: ${node.height},`)
|
|
301
|
+
if (paddingStr) parts.push(`${pad} padding: ${paddingStr},`)
|
|
302
|
+
if (decoration) parts.push(`${pad} decoration: ${decoration},`)
|
|
303
|
+
if (node.clipContent) parts.push(`${pad} clipBehavior: Clip.hardEdge,`)
|
|
304
|
+
if (child) parts.push(`${pad} child: ${child.trimStart()},`)
|
|
305
|
+
parts.push(`${pad})`)
|
|
306
|
+
return parts.join('\n')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function generateEllipseFlutter(node: EllipseNode, depth: number): string {
|
|
310
|
+
const pad = indent(depth)
|
|
311
|
+
const w = typeof node.width === 'number' ? node.width : undefined
|
|
312
|
+
const h = typeof node.height === 'number' ? node.height : undefined
|
|
313
|
+
const fillResult = fillToDecoration(node.fill)
|
|
314
|
+
const shadows = effectsToBoxShadows(node.effects)
|
|
315
|
+
const border = strokeToFlutterBorder(node.stroke)
|
|
316
|
+
|
|
317
|
+
const decParts: string[] = [`${pad} shape: BoxShape.circle,`]
|
|
318
|
+
if (fillResult?.color) decParts.push(`${pad} color: ${fillResult.color},`)
|
|
319
|
+
if (fillResult?.gradient) decParts.push(`${pad} gradient: ${fillResult.gradient},`)
|
|
320
|
+
if (border) decParts.push(`${pad} border: ${border},`)
|
|
321
|
+
if (shadows.length > 0) {
|
|
322
|
+
decParts.push(`${pad} boxShadow: [`)
|
|
323
|
+
for (const s of shadows) decParts.push(`${pad} ${s},`)
|
|
324
|
+
decParts.push(`${pad} ],`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const parts: string[] = [`${pad}Container(`]
|
|
328
|
+
if (w !== undefined) parts.push(`${pad} width: ${w},`)
|
|
329
|
+
if (h !== undefined) parts.push(`${pad} height: ${h},`)
|
|
330
|
+
parts.push(`${pad} decoration: BoxDecoration(\n${decParts.join('\n')}\n${pad} ),`)
|
|
331
|
+
parts.push(`${pad})`)
|
|
332
|
+
return applyWrappers(parts.join('\n'), node, depth)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function generateTextFlutter(node: TextNode, depth: number): string {
|
|
336
|
+
const pad = indent(depth)
|
|
337
|
+
const text = escapeDartString(getTextContent(node))
|
|
338
|
+
const styleParts: string[] = []
|
|
339
|
+
if (node.fontSize) styleParts.push(`fontSize: ${node.fontSize}`)
|
|
340
|
+
const fw = fontWeightToFlutter(node.fontWeight)
|
|
341
|
+
if (fw) styleParts.push(`fontWeight: ${fw}`)
|
|
342
|
+
if (node.fontStyle === 'italic') styleParts.push('fontStyle: FontStyle.italic')
|
|
343
|
+
if (node.fontFamily) styleParts.push(`fontFamily: '${escapeDartString(node.fontFamily)}'`)
|
|
344
|
+
if (node.letterSpacing) styleParts.push(`letterSpacing: ${node.letterSpacing}`)
|
|
345
|
+
if (node.lineHeight && node.fontSize) styleParts.push(`height: ${node.lineHeight}`)
|
|
346
|
+
const textColor = fillColorOnly(node.fill)
|
|
347
|
+
if (textColor) styleParts.push(`color: ${textColor}`)
|
|
348
|
+
|
|
349
|
+
const decorations: string[] = []
|
|
350
|
+
if (node.underline) decorations.push('TextDecoration.underline')
|
|
351
|
+
if (node.strikethrough) decorations.push('TextDecoration.lineThrough')
|
|
352
|
+
if (decorations.length === 1) styleParts.push(`decoration: ${decorations[0]}`)
|
|
353
|
+
else if (decorations.length > 1) styleParts.push(`decoration: TextDecoration.combine([${decorations.join(', ')}])`)
|
|
354
|
+
|
|
355
|
+
const textAlign = textAlignToFlutter(node.textAlign)
|
|
356
|
+
const params: string[] = [`${pad} '${text}'`]
|
|
357
|
+
if (textAlign) params.push(`${pad} textAlign: ${textAlign}`)
|
|
358
|
+
if (styleParts.length > 0) params.push(`${pad} style: TextStyle(${styleParts.join(', ')})`)
|
|
359
|
+
|
|
360
|
+
const textWidget = `${pad}Text(\n${params.join(',\n')},\n${pad})`
|
|
361
|
+
let widget: string
|
|
362
|
+
if (typeof node.width === 'number' || typeof node.height === 'number') {
|
|
363
|
+
const sp: string[] = [`${pad}SizedBox(`]
|
|
364
|
+
if (typeof node.width === 'number') sp.push(`${pad} width: ${node.width},`)
|
|
365
|
+
if (typeof node.height === 'number') sp.push(`${pad} height: ${node.height},`)
|
|
366
|
+
sp.push(`${pad} child: ${textWidget.trimStart()},`)
|
|
367
|
+
sp.push(`${pad})`)
|
|
368
|
+
widget = sp.join('\n')
|
|
369
|
+
} else {
|
|
370
|
+
widget = textWidget
|
|
371
|
+
}
|
|
372
|
+
return applyWrappers(widget, node, depth)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function generateLineFlutter(node: LineNode, depth: number): string {
|
|
376
|
+
const pad = indent(depth)
|
|
377
|
+
const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : undefined
|
|
378
|
+
const thickness = node.stroke
|
|
379
|
+
? (typeof node.stroke.thickness === 'number' ? node.stroke.thickness
|
|
380
|
+
: typeof node.stroke.thickness === 'string' ? 1 : node.stroke.thickness[0])
|
|
381
|
+
: 1
|
|
382
|
+
let color = 'Colors.grey'
|
|
383
|
+
if (node.stroke?.fill && node.stroke.fill.length > 0 && node.stroke.fill[0].type === 'solid')
|
|
384
|
+
color = hexToFlutterColor(node.stroke.fill[0].color)
|
|
385
|
+
|
|
386
|
+
const parts: string[] = [`${pad}Container(`]
|
|
387
|
+
if (w !== undefined) parts.push(`${pad} width: ${w},`)
|
|
388
|
+
parts.push(`${pad} height: ${thickness},`)
|
|
389
|
+
parts.push(`${pad} color: ${color},`)
|
|
390
|
+
parts.push(`${pad})`)
|
|
391
|
+
return applyWrappers(parts.join('\n'), node, depth)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function generatePathFlutter(node: PathNode, depth: number): string {
|
|
395
|
+
const pad = indent(depth)
|
|
396
|
+
const fillColor = fillColorOnly(node.fill) ?? 'Colors.black'
|
|
397
|
+
const w = typeof node.width === 'number' ? node.width : 24
|
|
398
|
+
const h = typeof node.height === 'number' ? node.height : 24
|
|
399
|
+
const widget = [
|
|
400
|
+
`${pad}// ${node.name ?? 'Path'}`,
|
|
401
|
+
`${pad}CustomPaint(`,
|
|
402
|
+
`${pad} size: Size(${w}, ${h}),`,
|
|
403
|
+
`${pad} painter: _PathPainter('${escapeDartString(node.d)}', ${fillColor}),`,
|
|
404
|
+
`${pad})`,
|
|
405
|
+
].join('\n')
|
|
406
|
+
return applyWrappers(widget, node, depth)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function generatePolygonFlutter(node: PolygonNode, depth: number): string {
|
|
410
|
+
const pad = indent(depth)
|
|
411
|
+
const fillColor = fillColorOnly(node.fill) ?? 'Colors.black'
|
|
412
|
+
const w = typeof node.width === 'number' ? node.width : 24
|
|
413
|
+
const h = typeof node.height === 'number' ? node.height : 24
|
|
414
|
+
const widget = [
|
|
415
|
+
`${pad}// Polygon (${node.polygonCount}-sided)`,
|
|
416
|
+
`${pad}CustomPaint(`,
|
|
417
|
+
`${pad} size: Size(${w}, ${h}),`,
|
|
418
|
+
`${pad} painter: _PolygonPainter(${node.polygonCount}, ${fillColor}),`,
|
|
419
|
+
`${pad})`,
|
|
420
|
+
].join('\n')
|
|
421
|
+
return applyWrappers(widget, node, depth)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function generateImageFlutter(node: ImageNode, depth: number): string {
|
|
425
|
+
const pad = indent(depth)
|
|
426
|
+
const w = typeof node.width === 'number' ? node.width : undefined
|
|
427
|
+
const h = typeof node.height === 'number' ? node.height : undefined
|
|
428
|
+
const fit = node.objectFit === 'fit' ? 'BoxFit.contain' : 'BoxFit.cover'
|
|
429
|
+
const src = node.src
|
|
430
|
+
|
|
431
|
+
let ctor: string, firstArg: string
|
|
432
|
+
if (src.startsWith('data:image/')) {
|
|
433
|
+
const base64Data = src.replace(/^data:image\/[^;]+;base64,/, '')
|
|
434
|
+
ctor = 'Image.memory'
|
|
435
|
+
firstArg = `base64Decode('${escapeDartString(base64Data)}')`
|
|
436
|
+
} else if (src.startsWith('http://') || src.startsWith('https://')) {
|
|
437
|
+
ctor = 'Image.network'
|
|
438
|
+
firstArg = `'${escapeDartString(src)}'`
|
|
439
|
+
} else {
|
|
440
|
+
ctor = 'Image.asset'
|
|
441
|
+
firstArg = `'${escapeDartString(src)}'`
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const parts: string[] = [`${pad}${ctor}(`]
|
|
445
|
+
parts.push(`${pad} ${firstArg},`)
|
|
446
|
+
if (w !== undefined) parts.push(`${pad} width: ${w},`)
|
|
447
|
+
if (h !== undefined) parts.push(`${pad} height: ${h},`)
|
|
448
|
+
parts.push(`${pad} fit: ${fit},`)
|
|
449
|
+
parts.push(`${pad})`)
|
|
450
|
+
let widget = parts.join('\n')
|
|
451
|
+
|
|
452
|
+
if (node.cornerRadius) {
|
|
453
|
+
const br = cornerRadiusToFlutter(node.cornerRadius)
|
|
454
|
+
if (br) widget = `${pad}ClipRRect(\n${pad} borderRadius: ${br},\n${pad} child: ${widget.trimStart()},\n${pad})`
|
|
455
|
+
}
|
|
456
|
+
return applyWrappers(widget, node, depth)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function getHelperClasses(nodes: PenNode[]): string {
|
|
460
|
+
let needsPath = false, needsPolygon = false
|
|
461
|
+
function walk(list: PenNode[]) {
|
|
462
|
+
for (const n of list) {
|
|
463
|
+
if (n.type === 'path') needsPath = true
|
|
464
|
+
if (n.type === 'polygon') needsPolygon = true
|
|
465
|
+
if ('children' in n && (n as any).children) walk((n as any).children)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
walk(nodes)
|
|
469
|
+
const helpers: string[] = []
|
|
470
|
+
if (needsPath) {
|
|
471
|
+
helpers.push(
|
|
472
|
+
`class _PathPainter extends CustomPainter {
|
|
473
|
+
final String pathData;
|
|
474
|
+
final Color color;
|
|
475
|
+
_PathPainter(this.pathData, this.color);
|
|
476
|
+
|
|
477
|
+
@override
|
|
478
|
+
void paint(Canvas canvas, Size size) {
|
|
479
|
+
final paint = Paint()..color = color;
|
|
480
|
+
final path = parseSvgPathData(pathData);
|
|
481
|
+
canvas.drawPath(path, paint);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
@override
|
|
485
|
+
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
486
|
+
}`)
|
|
487
|
+
}
|
|
488
|
+
if (needsPolygon) {
|
|
489
|
+
helpers.push(
|
|
490
|
+
`class _PolygonPainter extends CustomPainter {
|
|
491
|
+
final int sides;
|
|
492
|
+
final Color color;
|
|
493
|
+
_PolygonPainter(this.sides, this.color);
|
|
494
|
+
|
|
495
|
+
@override
|
|
496
|
+
void paint(Canvas canvas, Size size) {
|
|
497
|
+
final paint = Paint()..color = color;
|
|
498
|
+
final path = Path();
|
|
499
|
+
final cx = size.width / 2, cy = size.height / 2, r = size.width / 2;
|
|
500
|
+
for (var i = 0; i < sides; i++) {
|
|
501
|
+
final angle = (i * 2 * pi / sides) - (pi / 2);
|
|
502
|
+
final x = cx + r * cos(angle);
|
|
503
|
+
final y = cy + r * sin(angle);
|
|
504
|
+
i == 0 ? path.moveTo(x, y) : path.lineTo(x, y);
|
|
505
|
+
}
|
|
506
|
+
path.close();
|
|
507
|
+
canvas.drawPath(path, paint);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
@override
|
|
511
|
+
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
512
|
+
}`)
|
|
513
|
+
}
|
|
514
|
+
return helpers.join('\n\n')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function generateFlutterCode(
|
|
518
|
+
nodes: PenNode[],
|
|
519
|
+
widgetName = 'GeneratedDesign',
|
|
520
|
+
): string {
|
|
521
|
+
if (nodes.length === 0) {
|
|
522
|
+
return `import 'package:flutter/material.dart';\n\nclass ${widgetName} extends StatelessWidget {\n const ${widgetName}({super.key});\n\n @override\n Widget build(BuildContext context) {\n return const SizedBox.shrink();\n }\n}\n`
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let maxW = 0, maxH = 0
|
|
526
|
+
for (const node of nodes) {
|
|
527
|
+
const x = node.x ?? 0, y = node.y ?? 0
|
|
528
|
+
const w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
|
529
|
+
const h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
|
530
|
+
maxW = Math.max(maxW, x + w)
|
|
531
|
+
maxH = Math.max(maxH, y + h)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const childWidgets = nodes.map((n) => {
|
|
535
|
+
const childStr = generateNodeFlutter(n, 5)
|
|
536
|
+
const cx = n.x ?? 0, cy = n.y ?? 0
|
|
537
|
+
if (cx !== 0 || cy !== 0) {
|
|
538
|
+
const cPad = indent(4)
|
|
539
|
+
return `${cPad}Positioned(\n${cPad} left: ${cx},\n${cPad} top: ${cy},\n${cPad} child: ${childStr.trimStart()},\n${cPad})`
|
|
540
|
+
}
|
|
541
|
+
return childStr
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
const sizeArgs: string[] = []
|
|
545
|
+
if (maxW > 0) sizeArgs.push(`\n width: ${maxW},`)
|
|
546
|
+
if (maxH > 0) sizeArgs.push(`\n height: ${maxH},`)
|
|
547
|
+
const sizedBoxParams = sizeArgs.length > 0 ? sizeArgs.join('') : '\n width: double.infinity,\n height: double.infinity,'
|
|
548
|
+
|
|
549
|
+
const helpers = getHelperClasses(nodes)
|
|
550
|
+
const helperSection = helpers ? `\n\n${helpers}` : ''
|
|
551
|
+
|
|
552
|
+
return `import 'dart:convert';
|
|
553
|
+
import 'dart:math';
|
|
554
|
+
import 'package:flutter/material.dart';
|
|
555
|
+
|
|
556
|
+
class ${widgetName} extends StatelessWidget {
|
|
557
|
+
const ${widgetName}({super.key});
|
|
558
|
+
|
|
559
|
+
@override
|
|
560
|
+
Widget build(BuildContext context) {
|
|
561
|
+
return SizedBox(${sizedBoxParams}
|
|
562
|
+
child: Stack(
|
|
563
|
+
children: [
|
|
564
|
+
${childWidgets.map((c) => c + ',').join('\n')}
|
|
565
|
+
],
|
|
566
|
+
),
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}${helperSection}
|
|
570
|
+
`
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export function generateFlutterFromDocument(
|
|
574
|
+
doc: PenDocument,
|
|
575
|
+
activePageId?: string | null,
|
|
576
|
+
): string {
|
|
577
|
+
const children = activePageId !== undefined
|
|
578
|
+
? getActivePageChildren(doc, activePageId)
|
|
579
|
+
: doc.children
|
|
580
|
+
return generateFlutterCode(children, 'GeneratedDesign')
|
|
581
|
+
}
|