@elixpo/lixsketch 4.5.8

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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/fonts/fonts.css +29 -0
  4. package/fonts/lixCode.ttf +0 -0
  5. package/fonts/lixDefault.ttf +0 -0
  6. package/fonts/lixDocs.ttf +0 -0
  7. package/fonts/lixFancy.ttf +0 -0
  8. package/fonts/lixFont.woff2 +0 -0
  9. package/package.json +49 -0
  10. package/src/SketchEngine.js +473 -0
  11. package/src/core/AIRenderer.js +1390 -0
  12. package/src/core/CopyPaste.js +655 -0
  13. package/src/core/EraserTrail.js +234 -0
  14. package/src/core/EventDispatcher.js +371 -0
  15. package/src/core/GraphEngine.js +150 -0
  16. package/src/core/GraphMathParser.js +231 -0
  17. package/src/core/GraphRenderer.js +255 -0
  18. package/src/core/LayerOrder.js +91 -0
  19. package/src/core/LixScriptParser.js +1299 -0
  20. package/src/core/MermaidFlowchartRenderer.js +475 -0
  21. package/src/core/MermaidSequenceParser.js +197 -0
  22. package/src/core/MermaidSequenceRenderer.js +479 -0
  23. package/src/core/ResizeCode.js +175 -0
  24. package/src/core/ResizeShapes.js +318 -0
  25. package/src/core/SceneSerializer.js +778 -0
  26. package/src/core/Selection.js +1861 -0
  27. package/src/core/SnapGuides.js +273 -0
  28. package/src/core/UndoRedo.js +1358 -0
  29. package/src/core/ZoomPan.js +258 -0
  30. package/src/core/ai-system-prompt.js +663 -0
  31. package/src/index.js +69 -0
  32. package/src/shapes/Arrow.js +1979 -0
  33. package/src/shapes/Circle.js +751 -0
  34. package/src/shapes/CodeShape.js +244 -0
  35. package/src/shapes/Frame.js +1460 -0
  36. package/src/shapes/FreehandStroke.js +724 -0
  37. package/src/shapes/IconShape.js +265 -0
  38. package/src/shapes/ImageShape.js +270 -0
  39. package/src/shapes/Line.js +738 -0
  40. package/src/shapes/Rectangle.js +794 -0
  41. package/src/shapes/TextShape.js +225 -0
  42. package/src/tools/arrowTool.js +581 -0
  43. package/src/tools/circleTool.js +619 -0
  44. package/src/tools/codeTool.js +2103 -0
  45. package/src/tools/eraserTool.js +131 -0
  46. package/src/tools/frameTool.js +241 -0
  47. package/src/tools/freehandTool.js +620 -0
  48. package/src/tools/iconTool.js +1344 -0
  49. package/src/tools/imageTool.js +1323 -0
  50. package/src/tools/laserTool.js +317 -0
  51. package/src/tools/lineTool.js +502 -0
  52. package/src/tools/rectangleTool.js +544 -0
  53. package/src/tools/textTool.js +1823 -0
  54. package/src/utils/imageCompressor.js +107 -0
@@ -0,0 +1,1299 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * LixScript — Programmatic diagram DSL for LixSketch
4
+ *
5
+ * Parses a text-based language into canvas shapes with full control over
6
+ * properties, positions, connections, and grouping.
7
+ *
8
+ * Usage:
9
+ * const result = parseLixScript(source)
10
+ * if (result.errors.length) { ... }
11
+ * renderLixScript(result)
12
+ */
13
+
14
+ const NS = 'http://www.w3.org/2000/svg'
15
+
16
+ // ============================================================
17
+ // TOKENIZER
18
+ // ============================================================
19
+
20
+ /**
21
+ * Tokenize a LixScript source string into a flat list of tokens.
22
+ * Each token: { type, value, line }
23
+ */
24
+ function tokenize(source) {
25
+ const tokens = []
26
+ const lines = source.split('\n')
27
+
28
+ for (let i = 0; i < lines.length; i++) {
29
+ const raw = lines[i]
30
+ const lineNum = i + 1
31
+
32
+ // Strip comments
33
+ const commentIdx = raw.indexOf('//')
34
+ const line = commentIdx !== -1 ? raw.slice(0, commentIdx) : raw
35
+ const trimmed = line.trim()
36
+ if (!trimmed) continue
37
+
38
+ tokens.push({ type: 'LINE', value: trimmed, line: lineNum })
39
+ }
40
+
41
+ return tokens
42
+ }
43
+
44
+ // ============================================================
45
+ // PARSER
46
+ // ============================================================
47
+
48
+ /**
49
+ * Parse LixScript source into an AST.
50
+ * Returns { variables, shapes, errors }
51
+ */
52
+ export function parseLixScript(source) {
53
+ const tokens = tokenize(source)
54
+ const variables = {}
55
+ const shapes = []
56
+ const errors = []
57
+
58
+ let i = 0
59
+
60
+ while (i < tokens.length) {
61
+ const token = tokens[i]
62
+ const line = token.value
63
+ const lineNum = token.line
64
+
65
+ try {
66
+ // Variable assignment: $name = value
67
+ if (line.startsWith('$')) {
68
+ const match = line.match(/^\$(\w+)\s*=\s*(.+)$/)
69
+ if (match) {
70
+ variables[match[1]] = match[2].trim()
71
+ } else {
72
+ errors.push({ line: lineNum, message: `Invalid variable syntax: ${line}` })
73
+ }
74
+ i++
75
+ continue
76
+ }
77
+
78
+ // Shape declarations
79
+ const shapeMatch = line.match(/^(rect|circle|ellipse|arrow|line|text|frame|freehand|image|icon)\s+(\w+)\s+(.+)$/)
80
+ if (shapeMatch) {
81
+ const [, type, id, rest] = shapeMatch
82
+ const shape = parseShapeDeclaration(type, id, rest, lineNum, errors, variables)
83
+
84
+ // Check for property block { ... }
85
+ if (rest.includes('{') && !rest.includes('}')) {
86
+ // Multi-line property block — consume lines until closing }
87
+ i++
88
+ const props = []
89
+ while (i < tokens.length && !tokens[i].value.startsWith('}')) {
90
+ props.push(tokens[i].value)
91
+ i++
92
+ }
93
+ if (i < tokens.length) i++ // skip closing }
94
+ parseProperties(shape, props, variables, errors)
95
+ } else if (rest.includes('{') && rest.includes('}')) {
96
+ // Inline property block
97
+ const blockMatch = rest.match(/\{([^}]*)\}/)
98
+ if (blockMatch) {
99
+ const props = blockMatch[1].split(/[,;]/).map(s => s.trim()).filter(Boolean)
100
+ parseProperties(shape, props, variables, errors)
101
+ }
102
+ i++ // advance past this single line
103
+ } else {
104
+ // No property block
105
+ i++
106
+ }
107
+
108
+ shapes.push(shape)
109
+ continue
110
+ }
111
+
112
+ errors.push({ line: lineNum, message: `Unrecognized syntax: ${line}` })
113
+ i++
114
+ } catch (err) {
115
+ errors.push({ line: lineNum, message: err.message })
116
+ i++
117
+ }
118
+ }
119
+
120
+ return { variables, shapes, errors }
121
+ }
122
+
123
+ /**
124
+ * Parse the declaration part (after type and id) of a shape.
125
+ */
126
+ function parseShapeDeclaration(type, id, rest, lineNum, errors, variables) {
127
+ const shape = { type, id, line: lineNum, props: {} }
128
+
129
+ // Extract position: at X, Y or from ... to ...
130
+ if (type === 'arrow' || type === 'line') {
131
+ // from source[.side] to target[.side]
132
+ // from X, Y to X, Y
133
+ const connMatch = rest.match(/from\s+(.+?)\s+to\s+(.+?)(?:\s*\{|$)/)
134
+ if (connMatch) {
135
+ shape.from = parsePointOrRef(connMatch[1].trim(), variables)
136
+ shape.to = parsePointOrRef(connMatch[2].trim(), variables)
137
+ } else {
138
+ errors.push({ line: lineNum, message: `${type} requires 'from ... to ...' syntax` })
139
+ }
140
+ } else {
141
+ // at X, Y
142
+ const atMatch = rest.match(/at\s+([\w$.+\-*\s]+?),\s*([\w$.+\-*\s]+?)(?:\s+size|\s*\{|$)/)
143
+ if (atMatch) {
144
+ shape.x = parseExpr(atMatch[1].trim(), variables)
145
+ shape.y = parseExpr(atMatch[2].trim(), variables)
146
+ } else if (type !== 'frame') {
147
+ errors.push({ line: lineNum, message: `${type} requires 'at X, Y' syntax` })
148
+ }
149
+
150
+ // size WxH
151
+ const sizeMatch = rest.match(/size\s+([\w$.+\-*]+)\s*x\s*([\w$.+\-*]+)/)
152
+ if (sizeMatch) {
153
+ shape.width = parseExpr(sizeMatch[1].trim(), variables)
154
+ shape.height = parseExpr(sizeMatch[2].trim(), variables)
155
+ }
156
+ }
157
+
158
+ return shape
159
+ }
160
+
161
+ /**
162
+ * Parse a point reference — either a coordinate pair "X, Y" or
163
+ * a shape reference "shapeId.side" or "shapeId.side + offset"
164
+ */
165
+ function parsePointOrRef(str, variables) {
166
+ // shapeId.side [+/- offset]
167
+ const refMatch = str.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/)
168
+ if (refMatch) {
169
+ return {
170
+ ref: refMatch[1],
171
+ side: refMatch[2],
172
+ offset: refMatch[3] ? parseFloat((refMatch[3] === '-' ? '-' : '') + refMatch[4]) : 0,
173
+ }
174
+ }
175
+
176
+ // X, Y coordinate pair
177
+ const coordMatch = str.match(/^([\d.]+)\s*,?\s*([\d.]+)$/)
178
+ if (coordMatch) {
179
+ return { x: parseFloat(coordMatch[1]), y: parseFloat(coordMatch[2]) }
180
+ }
181
+
182
+ // Just a shape reference (center)
183
+ if (/^\w+$/.test(str)) {
184
+ return { ref: str, side: 'center', offset: 0 }
185
+ }
186
+
187
+ return { x: 0, y: 0 }
188
+ }
189
+
190
+ /**
191
+ * Parse a numeric expression, resolving variables.
192
+ * Supports: numbers, $var, shapeId.prop, simple +/- arithmetic
193
+ */
194
+ function parseExpr(str, variables) {
195
+ // Replace $variables
196
+ let resolved = str.replace(/\$(\w+)/g, (_, name) => {
197
+ return variables[name] !== undefined ? variables[name] : '0'
198
+ })
199
+
200
+ // If it's a simple number, return it
201
+ const num = parseFloat(resolved)
202
+ if (!isNaN(num) && String(num) === resolved.trim()) {
203
+ return num
204
+ }
205
+
206
+ // If it contains a shape reference like shapeId.prop, return as deferred
207
+ if (/\w+\.\w+/.test(resolved)) {
208
+ return { expr: resolved }
209
+ }
210
+
211
+ // Try simple arithmetic (A + B, A - B)
212
+ const arithMatch = resolved.match(/^([\d.]+)\s*([+-])\s*([\d.]+)$/)
213
+ if (arithMatch) {
214
+ const a = parseFloat(arithMatch[1])
215
+ const b = parseFloat(arithMatch[3])
216
+ return arithMatch[2] === '+' ? a + b : a - b
217
+ }
218
+
219
+ return isNaN(num) ? 0 : num
220
+ }
221
+
222
+ /**
223
+ * Parse property lines into shape.props
224
+ */
225
+ function parseProperties(shape, lines, variables, errors) {
226
+ for (const line of lines) {
227
+ const propMatch = line.match(/^(\w+)\s*:\s*(.+)$/)
228
+ if (!propMatch) continue
229
+
230
+ let [, key, value] = propMatch
231
+ value = value.trim()
232
+
233
+ // Strip quotes from string values
234
+ if ((value.startsWith('"') && value.endsWith('"')) ||
235
+ (value.startsWith("'") && value.endsWith("'"))) {
236
+ value = value.slice(1, -1)
237
+ }
238
+
239
+ // Resolve variables
240
+ value = value.replace(/\$(\w+)/g, (_, name) => {
241
+ return variables[name] !== undefined ? variables[name] : value
242
+ })
243
+
244
+ // Parse numeric values
245
+ const num = parseFloat(value)
246
+ if (!isNaN(num) && String(num) === value) {
247
+ shape.props[key] = num
248
+ } else if (value === 'true') {
249
+ shape.props[key] = true
250
+ } else if (value === 'false') {
251
+ shape.props[key] = false
252
+ } else {
253
+ shape.props[key] = value
254
+ }
255
+ }
256
+ }
257
+
258
+ // ============================================================
259
+ // RESOLVER — resolve deferred expressions & shape references
260
+ // ============================================================
261
+
262
+ export function resolveShapeRefs(shapes) {
263
+ const shapeMap = new Map()
264
+
265
+ // Iteratively resolve deferred expressions until no more progress
266
+ // This handles chained references like: A(absolute) → B(refs A) → C(refs B) → D(refs C)
267
+ const MAX_PASSES = 10
268
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
269
+ let progress = false
270
+
271
+ // Collect all shapes with known (numeric) positions
272
+ for (const s of shapes) {
273
+ if (typeof s.x === 'number' && typeof s.y === 'number' && !shapeMap.has(s.id)) {
274
+ shapeMap.set(s.id, s)
275
+ progress = true
276
+ }
277
+ }
278
+
279
+ // Try to resolve any remaining deferred expressions
280
+ let anyUnresolved = false
281
+ for (const s of shapes) {
282
+ if (s.x && typeof s.x === 'object' && s.x.expr) {
283
+ const resolved = resolveExpr(s.x.expr, shapeMap)
284
+ if (typeof resolved === 'number' && !isNaN(resolved)) {
285
+ s.x = resolved
286
+ progress = true
287
+ } else {
288
+ anyUnresolved = true
289
+ }
290
+ }
291
+ if (s.y && typeof s.y === 'object' && s.y.expr) {
292
+ const resolved = resolveExpr(s.y.expr, shapeMap)
293
+ if (typeof resolved === 'number' && !isNaN(resolved)) {
294
+ s.y = resolved
295
+ progress = true
296
+ } else {
297
+ anyUnresolved = true
298
+ }
299
+ }
300
+ }
301
+
302
+ if (!anyUnresolved || !progress) break
303
+ }
304
+
305
+ // Final fallback: force-resolve any remaining deferred expressions to 0
306
+ for (const s of shapes) {
307
+ if (s.x && typeof s.x === 'object' && s.x.expr) s.x = 0
308
+ if (s.y && typeof s.y === 'object' && s.y.expr) s.y = 0
309
+ }
310
+ }
311
+
312
+ function resolveExpr(expr, shapeMap) {
313
+ // Match: shapeId.prop [+/- offset]
314
+ const m = expr.match(/^(\w+)\.(\w+)(?:\s*([+-])\s*([\d.]+))?$/)
315
+ if (!m) return NaN
316
+
317
+ const [, ref, prop, op, offsetStr] = m
318
+ const shape = shapeMap.get(ref)
319
+ if (!shape) return NaN
320
+
321
+ let val = 0
322
+ switch (prop) {
323
+ case 'x': val = shape.x || 0; break
324
+ case 'y': val = shape.y || 0; break
325
+ case 'right': val = (shape.x || 0) + (shape.width || 0); break
326
+ case 'left': val = shape.x || 0; break
327
+ case 'top': val = shape.y || 0; break
328
+ case 'bottom': val = (shape.y || 0) + (shape.height || 0); break
329
+ case 'centerX': val = (shape.x || 0) + (shape.width || 0) / 2; break
330
+ case 'centerY': val = (shape.y || 0) + (shape.height || 0) / 2; break
331
+ case 'width': val = shape.width || 0; break
332
+ case 'height': val = shape.height || 0; break
333
+ default: val = 0
334
+ }
335
+
336
+ const offset = offsetStr ? parseFloat(offsetStr) : 0
337
+ return op === '-' ? val - offset : val + offset
338
+ }
339
+
340
+ // ============================================================
341
+ // RENDERER — create actual canvas shapes from parsed AST
342
+ // ============================================================
343
+
344
+ /**
345
+ * Render a parsed LixScript AST onto the canvas.
346
+ * Returns { success, shapesCreated, errors }
347
+ */
348
+ export function renderLixScript(parsed) {
349
+ const { shapes: shapeDefs, errors } = parsed
350
+ if (errors.length > 0) {
351
+ return { success: false, shapesCreated: 0, errors }
352
+ }
353
+
354
+ // Resolve relative references
355
+ resolveShapeRefs(shapeDefs)
356
+
357
+ const svg = window.svg
358
+ if (!svg) {
359
+ return { success: false, shapesCreated: 0, errors: [{ line: 0, message: 'Canvas not initialized' }] }
360
+ }
361
+
362
+ const createdShapes = new Map() // id -> { instance, def }
363
+ const renderErrors = []
364
+
365
+ // Check if user defined a frame — if not, we auto-create one
366
+ const userFrameDef = shapeDefs.find(s => s.type === 'frame')
367
+ let frame = null
368
+ let frameDef = userFrameDef
369
+
370
+ if (userFrameDef) {
371
+ // User-defined frame
372
+ frame = createFrame(userFrameDef, renderErrors)
373
+ if (frame) {
374
+ createdShapes.set(userFrameDef.id, { instance: frame, def: userFrameDef })
375
+ }
376
+ }
377
+
378
+ // Create non-connection shapes first (rect, circle, text)
379
+ for (const def of shapeDefs) {
380
+ if (def.type === 'frame') continue // already created
381
+ if (def.type === 'arrow' || def.type === 'line') continue // deferred
382
+
383
+ const instance = createShape(def, renderErrors)
384
+ if (instance) {
385
+ createdShapes.set(def.id, { instance, def })
386
+ }
387
+ }
388
+
389
+ // Create connections (arrows, lines) — now that source/target shapes exist
390
+ for (const def of shapeDefs) {
391
+ if (def.type !== 'arrow' && def.type !== 'line') continue
392
+
393
+ const instance = createConnection(def, createdShapes, renderErrors)
394
+ if (instance) {
395
+ createdShapes.set(def.id, { instance, def })
396
+ }
397
+ }
398
+
399
+ // Auto-create wrapping frame if user didn't define one
400
+ if (!frame && createdShapes.size > 0) {
401
+ // Calculate bounds from all created shapes
402
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
403
+
404
+ for (const [, { instance, def }] of createdShapes) {
405
+ if (def.type === 'arrow' || def.type === 'line') {
406
+ // Use the actual instance start/end coordinates
407
+ const from = resolveConnectionPoint(def.from, createdShapes)
408
+ const to = resolveConnectionPoint(def.to, createdShapes)
409
+ if (from) { minX = Math.min(minX, from.x); minY = Math.min(minY, from.y); maxX = Math.max(maxX, from.x); maxY = Math.max(maxY, from.y) }
410
+ if (to) { minX = Math.min(minX, to.x); minY = Math.min(minY, to.y); maxX = Math.max(maxX, to.x); maxY = Math.max(maxY, to.y) }
411
+ } else if (def.type !== 'frame') {
412
+ const x = def.x || 0
413
+ const y = def.y || 0
414
+ const w = def.width || 160
415
+ const h = def.height || 60
416
+ minX = Math.min(minX, x)
417
+ minY = Math.min(minY, y)
418
+ maxX = Math.max(maxX, x + w)
419
+ maxY = Math.max(maxY, y + h)
420
+ }
421
+ }
422
+
423
+ if (isFinite(minX)) {
424
+ const pad = 40
425
+ const autoFrameDef = {
426
+ type: 'frame',
427
+ id: '_lixscript_auto_frame',
428
+ line: 0,
429
+ x: minX - pad,
430
+ y: minY - pad,
431
+ width: (maxX - minX) + pad * 2,
432
+ height: (maxY - minY) + pad * 2,
433
+ props: { name: 'LixScript' }
434
+ }
435
+ frameDef = autoFrameDef
436
+ frame = createFrame(autoFrameDef, renderErrors)
437
+ if (frame) {
438
+ createdShapes.set(autoFrameDef.id, { instance: frame, def: autoFrameDef })
439
+ }
440
+ }
441
+ }
442
+
443
+ // Add all shapes to frame
444
+ if (frame) {
445
+ for (const [id, { instance, def }] of createdShapes) {
446
+ if (def.type === 'frame') continue // don't add frame to itself
447
+ if (shouldAddToFrame(def, frameDef)) {
448
+ frame.addShapeToFrame(instance)
449
+ }
450
+ }
451
+ }
452
+
453
+ return {
454
+ success: renderErrors.length === 0,
455
+ shapesCreated: createdShapes.size,
456
+ errors: renderErrors,
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Check if a shape should be added to the frame based on 'contains' prop.
462
+ */
463
+ function shouldAddToFrame(shapeDef, frameDef) {
464
+ if (!frameDef || !frameDef.props.contains) return true // add all by default
465
+ const ids = frameDef.props.contains.split(',').map(s => s.trim())
466
+ return ids.includes(shapeDef.id)
467
+ }
468
+
469
+ /**
470
+ * Create a Frame instance.
471
+ */
472
+ function createFrame(def, errors) {
473
+ const Frame = window.Frame
474
+ if (!Frame) {
475
+ errors.push({ line: def.line, message: 'Frame class not available' })
476
+ return null
477
+ }
478
+
479
+ try {
480
+ const x = def.x || 0
481
+ const y = def.y || 0
482
+ const w = def.width || 600
483
+ const h = def.height || 400
484
+
485
+ const frame = new Frame(x, y, w, h, {
486
+ frameName: def.props.frameName || def.props.name || def.id,
487
+ stroke: def.props.stroke || '#555',
488
+ strokeWidth: def.props.strokeWidth || 1,
489
+ fill: def.props.fill || 'transparent',
490
+ fillStyle: def.props.fillStyle || 'transparent',
491
+ fillColor: def.props.fillColor || '#1e1e28',
492
+ opacity: def.props.opacity || 1,
493
+ rotation: def.props.rotation || 0,
494
+ })
495
+
496
+ window.shapes.push(frame)
497
+ if (window.pushCreateAction) window.pushCreateAction(frame)
498
+
499
+ // Tag as scripted
500
+ frame._frameType = 'lixscript'
501
+ frame._lixscriptSource = true
502
+
503
+ // Set image from URL if provided
504
+ if (def.props.imageURL && typeof frame.setImageFromURL === 'function') {
505
+ frame.setImageFromURL(def.props.imageURL, def.props.imageFit || 'cover')
506
+ }
507
+
508
+ return frame
509
+ } catch (err) {
510
+ errors.push({ line: def.line, message: `Frame creation failed: ${err.message}` })
511
+ return null
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Create a shape instance from a definition.
517
+ */
518
+ function createShape(def, errors) {
519
+ try {
520
+ switch (def.type) {
521
+ case 'rect': return createRect(def, errors)
522
+ case 'circle':
523
+ case 'ellipse': return createCircle(def, errors)
524
+ case 'text': return createText(def, errors)
525
+ case 'freehand': return createFreehand(def, errors)
526
+ case 'image': return createImage(def, errors)
527
+ case 'icon': return createIcon(def, errors)
528
+ default:
529
+ errors.push({ line: def.line, message: `Unknown shape type: ${def.type}` })
530
+ return null
531
+ }
532
+ } catch (err) {
533
+ errors.push({ line: def.line, message: `Shape '${def.id}' failed: ${err.message}` })
534
+ return null
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Create an image shape from a LixScript definition.
540
+ * Usage: image myImg at 100, 200 size 300x200 { src: "https://..." }
541
+ */
542
+ function createImage(def, errors) {
543
+ const ImageShape = window.ImageShape
544
+ if (!ImageShape) {
545
+ errors.push({ line: def.line, message: 'ImageShape class not available' })
546
+ return null
547
+ }
548
+
549
+ const src = def.props.src || def.props.href || def.props.url || ''
550
+ if (!src) {
551
+ errors.push({ line: def.line, message: `Image '${def.id}' requires a src property` })
552
+ return null
553
+ }
554
+
555
+ const svgEl = document.getElementById('freehand-canvas')
556
+ if (!svgEl) return null
557
+
558
+ const x = def.x || 0
559
+ const y = def.y || 0
560
+ const w = def.width || 200
561
+ const h = def.height || 200
562
+
563
+ const imgEl = document.createElementNS('http://www.w3.org/2000/svg', 'image')
564
+ imgEl.setAttribute('href', src)
565
+ imgEl.setAttribute('x', x)
566
+ imgEl.setAttribute('y', y)
567
+ imgEl.setAttribute('width', w)
568
+ imgEl.setAttribute('height', h)
569
+ imgEl.setAttribute('data-shape-x', x)
570
+ imgEl.setAttribute('data-shape-y', y)
571
+ imgEl.setAttribute('data-shape-width', w)
572
+ imgEl.setAttribute('data-shape-height', h)
573
+ imgEl.setAttribute('type', 'image')
574
+ imgEl.setAttribute('preserveAspectRatio', def.props.fit === 'contain' ? 'xMidYMid meet' : def.props.fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet')
575
+
576
+ svgEl.appendChild(imgEl)
577
+ const shape = new ImageShape(imgEl)
578
+ if (def.props.rotation) shape.rotation = parseFloat(def.props.rotation)
579
+ shape.shapeID = def.id || shape.shapeID
580
+
581
+ window.shapes.push(shape)
582
+ if (window.pushCreateAction) window.pushCreateAction(shape)
583
+
584
+ return shape
585
+ }
586
+
587
+ /**
588
+ * Create an icon shape from a LixScript definition.
589
+ * Usage: icon myIcon at 100, 200 size 48x48 { name: "AWS_Lambda_64" }
590
+ * or: icon myIcon at 100, 200 size 48x48 { svg: "<path d='...' />" }
591
+ */
592
+ function createIcon(def, errors) {
593
+ const IconShape = window.IconShape
594
+ if (!IconShape) {
595
+ errors.push({ line: def.line, message: 'IconShape class not available' })
596
+ return null
597
+ }
598
+
599
+ const svgEl = document.getElementById('freehand-canvas')
600
+ if (!svgEl) return null
601
+
602
+ const x = def.x || 0
603
+ const y = def.y || 0
604
+ const size = def.width || 48
605
+ const color = def.props.color || def.props.fill || '#ffffff'
606
+
607
+ // Build the inner SVG content
608
+ let innerSVG = ''
609
+ if (def.props.svg) {
610
+ // Inline SVG path(s)
611
+ innerSVG = def.props.svg
612
+ } else if (def.props.name) {
613
+ // Named icon — use a simple placeholder circle+text until loaded
614
+ // The AI should provide inline SVG paths for reliability
615
+ innerSVG = `<circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="1.5"/><text x="12" y="16" text-anchor="middle" fill="${color}" font-size="10" font-family="sans-serif">${(def.props.name || '?').charAt(0)}</text>`
616
+ } else {
617
+ errors.push({ line: def.line, message: `Icon '${def.id}' requires a name or svg property` })
618
+ return null
619
+ }
620
+
621
+ const vbWidth = parseFloat(def.props.viewBoxWidth) || 24
622
+ const vbHeight = parseFloat(def.props.viewBoxHeight) || 24
623
+ const scale = size / Math.max(vbWidth, vbHeight)
624
+ const localCenterX = size / 2 / scale
625
+ const localCenterY = size / 2 / scale
626
+ const rotation = def.props.rotation || 0
627
+
628
+ const iconGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g')
629
+ iconGroup.setAttribute('transform', `translate(${x}, ${y}) scale(${scale}) rotate(${rotation}, ${localCenterX}, ${localCenterY})`)
630
+ iconGroup.setAttribute('data-viewbox-width', vbWidth)
631
+ iconGroup.setAttribute('data-viewbox-height', vbHeight)
632
+ iconGroup.setAttribute('x', x)
633
+ iconGroup.setAttribute('y', y)
634
+ iconGroup.setAttribute('width', size)
635
+ iconGroup.setAttribute('height', size)
636
+ iconGroup.setAttribute('type', 'icon')
637
+ iconGroup.setAttribute('data-shape-x', x)
638
+ iconGroup.setAttribute('data-shape-y', y)
639
+ iconGroup.setAttribute('data-shape-width', size)
640
+ iconGroup.setAttribute('data-shape-height', size)
641
+ iconGroup.setAttribute('data-shape-rotation', rotation)
642
+ iconGroup.setAttribute('style', 'cursor: pointer; pointer-events: all;')
643
+
644
+ // Transparent hit area
645
+ const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
646
+ bgRect.setAttribute('x', 0)
647
+ bgRect.setAttribute('y', 0)
648
+ bgRect.setAttribute('width', vbWidth)
649
+ bgRect.setAttribute('height', vbHeight)
650
+ bgRect.setAttribute('fill', 'transparent')
651
+ bgRect.setAttribute('stroke', 'none')
652
+ bgRect.setAttribute('style', 'pointer-events: all;')
653
+ iconGroup.appendChild(bgRect)
654
+
655
+ // Parse and insert SVG content
656
+ const tempSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
657
+ tempSvg.innerHTML = innerSVG
658
+ while (tempSvg.firstChild) {
659
+ const child = tempSvg.firstChild
660
+ // Apply color to paths/circles/etc
661
+ if (child.nodeType === 1) {
662
+ const fill = child.getAttribute('fill')
663
+ const stroke = child.getAttribute('stroke')
664
+ if (!fill || fill === 'currentColor' || fill === '#000' || fill === '#000000' || fill === 'black') {
665
+ child.setAttribute('fill', color)
666
+ }
667
+ if (stroke === 'currentColor' || stroke === '#000' || stroke === '#000000' || stroke === 'black') {
668
+ child.setAttribute('stroke', color)
669
+ }
670
+ }
671
+ iconGroup.appendChild(child)
672
+ }
673
+
674
+ svgEl.appendChild(iconGroup)
675
+ const shape = new IconShape(iconGroup)
676
+ shape.shapeID = def.id || shape.shapeID
677
+
678
+ window.shapes.push(shape)
679
+ if (window.pushCreateAction) window.pushCreateAction(shape)
680
+
681
+ return shape
682
+ }
683
+
684
+ function createRect(def, errors) {
685
+ const Rectangle = window.Rectangle
686
+ if (!Rectangle) {
687
+ errors.push({ line: def.line, message: 'Rectangle class not available' })
688
+ return null
689
+ }
690
+
691
+ const x = def.x || 0
692
+ const y = def.y || 0
693
+ const w = def.width || 160
694
+ const h = def.height || 60
695
+
696
+ const rect = new Rectangle(x, y, w, h, {
697
+ stroke: def.props.stroke || '#fff',
698
+ strokeWidth: def.props.strokeWidth || 2,
699
+ fill: def.props.fill || 'transparent',
700
+ fillStyle: def.props.fillStyle || 'none',
701
+ roughness: def.props.roughness !== undefined ? def.props.roughness : 1.5,
702
+ strokeDasharray: resolveStrokeStyle(def.props.style),
703
+ shadeColor: def.props.shadeColor || null,
704
+ shadeOpacity: def.props.shadeOpacity !== undefined ? parseFloat(def.props.shadeOpacity) : 0.15,
705
+ shadeDirection: def.props.shadeDirection || 'bottom',
706
+ })
707
+
708
+ if (def.props.rotation) rect.rotation = def.props.rotation
709
+ if (def.props.label) {
710
+ rect.setLabel(
711
+ def.props.label,
712
+ def.props.labelColor || '#e0e0e0',
713
+ def.props.labelFontSize || 14
714
+ )
715
+ }
716
+
717
+ window.shapes.push(rect)
718
+ if (window.pushCreateAction) window.pushCreateAction(rect)
719
+ return rect
720
+ }
721
+
722
+ function createCircle(def, errors) {
723
+ const Circle = window.Circle
724
+ if (!Circle) {
725
+ errors.push({ line: def.line, message: 'Circle class not available' })
726
+ return null
727
+ }
728
+
729
+ const x = def.x || 0
730
+ const y = def.y || 0
731
+ const w = def.width || 80
732
+ const h = def.height || 80
733
+
734
+ // Circle constructor uses (x, y, rx, ry) — center and radii
735
+ const rx = w / 2
736
+ const ry = h / 2
737
+
738
+ const circle = new Circle(x + rx, y + ry, rx, ry, {
739
+ stroke: def.props.stroke || '#fff',
740
+ strokeWidth: def.props.strokeWidth || 2,
741
+ fill: def.props.fill || 'transparent',
742
+ fillStyle: def.props.fillStyle || 'none',
743
+ roughness: def.props.roughness !== undefined ? def.props.roughness : 1.5,
744
+ strokeDasharray: resolveStrokeStyle(def.props.style),
745
+ shadeColor: def.props.shadeColor || null,
746
+ shadeOpacity: def.props.shadeOpacity !== undefined ? parseFloat(def.props.shadeOpacity) : 0.15,
747
+ shadeDirection: def.props.shadeDirection || 'bottom',
748
+ })
749
+
750
+ if (def.props.rotation) circle.rotation = def.props.rotation
751
+ if (def.props.label) {
752
+ circle.setLabel(
753
+ def.props.label,
754
+ def.props.labelColor || '#e0e0e0',
755
+ def.props.labelFontSize || 14
756
+ )
757
+ }
758
+
759
+ window.shapes.push(circle)
760
+ if (window.pushCreateAction) window.pushCreateAction(circle)
761
+ return circle
762
+ }
763
+
764
+ function createText(def, errors) {
765
+ const TextShape = window.TextShape
766
+ const svgEl = window.svg
767
+ if (!TextShape || !svgEl) {
768
+ errors.push({ line: def.line, message: 'TextShape class not available' })
769
+ return null
770
+ }
771
+
772
+ const x = def.x || 0
773
+ const y = def.y || 0
774
+ const content = def.props.content || def.props.text || 'Text'
775
+ const color = def.props.color || def.props.fill || '#fff'
776
+ const fontSize = def.props.fontSize || 16
777
+
778
+ const g = document.createElementNS(NS, 'g')
779
+ g.setAttribute('data-type', 'text-group')
780
+ g.setAttribute('transform', `translate(${x}, ${y})`)
781
+ g.setAttribute('data-x', x)
782
+ g.setAttribute('data-y', y)
783
+
784
+ const t = document.createElementNS(NS, 'text')
785
+ t.setAttribute('x', 0)
786
+ t.setAttribute('y', 0)
787
+ t.setAttribute('text-anchor', def.props.anchor || 'middle')
788
+ t.setAttribute('dominant-baseline', 'central')
789
+ t.setAttribute('fill', color)
790
+ t.setAttribute('font-size', fontSize)
791
+ t.setAttribute('font-family', def.props.fontFamily || 'lixFont, sans-serif')
792
+ t.setAttribute('data-initial-font', def.props.fontFamily || 'lixFont')
793
+ t.setAttribute('data-initial-color', color)
794
+ t.setAttribute('data-initial-size', fontSize + 'px')
795
+ t.textContent = content
796
+
797
+ g.appendChild(t)
798
+ svgEl.appendChild(g)
799
+
800
+ const shape = new TextShape(g)
801
+ window.shapes.push(shape)
802
+ if (window.pushCreateAction) window.pushCreateAction(shape)
803
+ return shape
804
+ }
805
+
806
+ function createFreehand(def, errors) {
807
+ const FreehandStroke = window.FreehandStroke
808
+ if (!FreehandStroke) {
809
+ errors.push({ line: def.line, message: 'FreehandStroke class not available' })
810
+ return null
811
+ }
812
+
813
+ // Points should be provided as a property: points: "x1,y1;x2,y2;..."
814
+ const pointsStr = def.props.points || ''
815
+ const points = pointsStr.split(';').map(p => {
816
+ const [x, y, pressure] = p.split(',').map(Number)
817
+ return [x || 0, y || 0, pressure || 0.5]
818
+ }).filter(p => !isNaN(p[0]))
819
+
820
+ if (points.length < 2) {
821
+ errors.push({ line: def.line, message: 'Freehand requires at least 2 points' })
822
+ return null
823
+ }
824
+
825
+ const stroke = new FreehandStroke(points, {
826
+ stroke: def.props.stroke || def.props.color || '#fff',
827
+ strokeWidth: def.props.strokeWidth || 3,
828
+ thinning: def.props.thinning || 0.5,
829
+ roughness: def.props.roughness || 'smooth',
830
+ strokeStyle: def.props.style || 'solid',
831
+ })
832
+
833
+ window.shapes.push(stroke)
834
+ if (window.pushCreateAction) window.pushCreateAction(stroke)
835
+ return stroke
836
+ }
837
+
838
+ /**
839
+ * Create a connection (arrow or line) between two points or shape references.
840
+ */
841
+ function createConnection(def, createdShapes, errors) {
842
+ const from = resolveConnectionPoint(def.from, createdShapes)
843
+ const to = resolveConnectionPoint(def.to, createdShapes)
844
+
845
+ if (!from || !to) {
846
+ errors.push({ line: def.line, message: `Cannot resolve connection endpoints for '${def.id}'` })
847
+ return null
848
+ }
849
+
850
+ if (def.type === 'arrow') {
851
+ return createArrow(def, from, to, createdShapes, errors)
852
+ } else {
853
+ return createLine(def, from, to, errors)
854
+ }
855
+ }
856
+
857
+ function createArrow(def, from, to, createdShapes, errors) {
858
+ const Arrow = window.Arrow
859
+ if (!Arrow) {
860
+ errors.push({ line: def.line, message: 'Arrow class not available' })
861
+ return null
862
+ }
863
+
864
+ const curveMode = def.props.curve || 'straight'
865
+
866
+ const arrow = new Arrow(
867
+ { x: from.x, y: from.y },
868
+ { x: to.x, y: to.y },
869
+ {
870
+ stroke: def.props.stroke || '#fff',
871
+ strokeWidth: def.props.strokeWidth || 2,
872
+ arrowOutlineStyle: def.props.style || 'solid',
873
+ arrowHeadStyle: def.props.head || 'default',
874
+ arrowHeadLength: def.props.headLength || 15,
875
+ arrowCurved: curveMode,
876
+ arrowCurveAmount: def.props.curveAmount || 50,
877
+ }
878
+ )
879
+
880
+ if (def.props.label) {
881
+ arrow.setLabel(
882
+ def.props.label,
883
+ def.props.labelColor || '#e0e0e0',
884
+ def.props.labelFontSize || 12
885
+ )
886
+ }
887
+
888
+ // Auto-attach to source/target shapes
889
+ if (def.from && def.from.ref) {
890
+ const sourceEntry = createdShapes.get(def.from.ref)
891
+ if (sourceEntry) {
892
+ autoAttach(arrow, sourceEntry.instance, true, from)
893
+ }
894
+ }
895
+ if (def.to && def.to.ref) {
896
+ const targetEntry = createdShapes.get(def.to.ref)
897
+ if (targetEntry) {
898
+ autoAttach(arrow, targetEntry.instance, false, to)
899
+ }
900
+ }
901
+
902
+ window.shapes.push(arrow)
903
+ if (window.pushCreateAction) window.pushCreateAction(arrow)
904
+ return arrow
905
+ }
906
+
907
+ function createLine(def, from, to, errors) {
908
+ const Line = window.Line
909
+ if (!Line) {
910
+ errors.push({ line: def.line, message: 'Line class not available' })
911
+ return null
912
+ }
913
+
914
+ const line = new Line(
915
+ { x: from.x, y: from.y },
916
+ { x: to.x, y: to.y },
917
+ {
918
+ stroke: def.props.stroke || '#fff',
919
+ strokeWidth: def.props.strokeWidth || 2,
920
+ strokeDasharray: resolveStrokeStyle(def.props.style),
921
+ }
922
+ )
923
+
924
+ if (def.props.curve === 'true' || def.props.curve === true) {
925
+ line.isCurved = true
926
+ line.initializeCurveControlPoint?.()
927
+ line.draw()
928
+ }
929
+
930
+ if (def.props.label) {
931
+ line.setLabel(
932
+ def.props.label,
933
+ def.props.labelColor || '#e0e0e0',
934
+ def.props.labelFontSize || 12
935
+ )
936
+ }
937
+
938
+ window.shapes.push(line)
939
+ if (window.pushCreateAction) window.pushCreateAction(line)
940
+ return line
941
+ }
942
+
943
+ // ============================================================
944
+ // CONNECTION POINT RESOLUTION
945
+ // ============================================================
946
+
947
+ /**
948
+ * Resolve a connection endpoint — either absolute coords or a shape reference.
949
+ */
950
+ function resolveConnectionPoint(pointDef, createdShapes) {
951
+ if (!pointDef) return null
952
+
953
+ // Absolute coordinates
954
+ if (pointDef.x !== undefined && pointDef.y !== undefined) {
955
+ return { x: pointDef.x, y: pointDef.y }
956
+ }
957
+
958
+ // Shape reference
959
+ if (pointDef.ref) {
960
+ const entry = createdShapes.get(pointDef.ref)
961
+ if (!entry) return null
962
+
963
+ const shape = entry.instance
964
+ const def = entry.def
965
+ const side = pointDef.side || 'center'
966
+ const offset = pointDef.offset || 0
967
+
968
+ // Get shape bounds
969
+ const sx = shape.x !== undefined ? shape.x : (def.x || 0)
970
+ const sy = shape.y !== undefined ? shape.y : (def.y || 0)
971
+ const sw = shape.width || def.width || 0
972
+ const sh = shape.height || def.height || 0
973
+
974
+ // For circles, x/y is the center
975
+ let cx, cy
976
+ if (shape.shapeName === 'circle') {
977
+ cx = sx
978
+ cy = sy
979
+ const rx = shape.rx || sw / 2
980
+ const ry = shape.ry || sh / 2
981
+
982
+ switch (side) {
983
+ case 'top': return { x: cx + offset, y: cy - ry }
984
+ case 'bottom': return { x: cx + offset, y: cy + ry }
985
+ case 'left': return { x: cx - rx, y: cy + offset }
986
+ case 'right': return { x: cx + rx, y: cy + offset }
987
+ case 'center': return { x: cx + offset, y: cy }
988
+ default: return { x: cx, y: cy }
989
+ }
990
+ }
991
+
992
+ // For rectangles and other box shapes
993
+ cx = sx + sw / 2
994
+ cy = sy + sh / 2
995
+
996
+ switch (side) {
997
+ case 'top': return { x: cx + offset, y: sy }
998
+ case 'bottom': return { x: cx + offset, y: sy + sh }
999
+ case 'left': return { x: sx, y: cy + offset }
1000
+ case 'right': return { x: sx + sw, y: cy + offset }
1001
+ case 'center': return { x: cx + offset, y: cy }
1002
+ default: return { x: cx, y: cy }
1003
+ }
1004
+ }
1005
+
1006
+ return null
1007
+ }
1008
+
1009
+ /**
1010
+ * Auto-attach an arrow to a shape (sets attachedToStart or attachedToEnd).
1011
+ */
1012
+ function autoAttach(arrow, shape, isStart, connectionPoint) {
1013
+ if (!shape || !connectionPoint) return
1014
+
1015
+ const sx = shape.x || 0
1016
+ const sy = shape.y || 0
1017
+ const sw = shape.width || 0
1018
+ const sh = shape.height || 0
1019
+
1020
+ let cx, cy
1021
+ if (shape.shapeName === 'circle') {
1022
+ cx = sx
1023
+ cy = sy
1024
+ } else {
1025
+ cx = sx + sw / 2
1026
+ cy = sy + sh / 2
1027
+ }
1028
+
1029
+ // Determine which side the connection point is on
1030
+ const dx = connectionPoint.x - cx
1031
+ const dy = connectionPoint.y - cy
1032
+ let side = 'bottom'
1033
+
1034
+ if (Math.abs(dx) > Math.abs(dy)) {
1035
+ side = dx > 0 ? 'right' : 'left'
1036
+ } else {
1037
+ side = dy > 0 ? 'bottom' : 'top'
1038
+ }
1039
+
1040
+ const attachment = { shape, side, offset: { x: 0, y: 0 } }
1041
+
1042
+ if (isStart) {
1043
+ arrow.attachedToStart = attachment
1044
+ } else {
1045
+ arrow.attachedToEnd = attachment
1046
+ }
1047
+ }
1048
+
1049
+ // ============================================================
1050
+ // PREVIEW — generate SVG preview without touching the canvas
1051
+ // ============================================================
1052
+
1053
+ /**
1054
+ * Generate a preview SVG string from parsed LixScript.
1055
+ */
1056
+ export function previewLixScript(parsed) {
1057
+ const { shapes: defs, errors } = parsed
1058
+ if (errors.length > 0) return ''
1059
+
1060
+ resolveShapeRefs(defs)
1061
+
1062
+ // Build a shape map for resolving arrow references in preview
1063
+ const shapeMap = new Map()
1064
+ for (const def of defs) {
1065
+ if (def.type !== 'arrow' && def.type !== 'line') {
1066
+ shapeMap.set(def.id, def)
1067
+ }
1068
+ }
1069
+
1070
+ // Resolve arrow/line endpoints to coordinates for preview
1071
+ function resolvePreviewPoint(pointDef) {
1072
+ if (!pointDef) return { x: 0, y: 0 }
1073
+ if (pointDef.x !== undefined && pointDef.y !== undefined) return pointDef
1074
+
1075
+ if (pointDef.ref) {
1076
+ const target = shapeMap.get(pointDef.ref)
1077
+ if (!target) return { x: 0, y: 0 }
1078
+
1079
+ const side = pointDef.side || 'center'
1080
+ const offset = pointDef.offset || 0
1081
+ const tx = target.x || 0
1082
+ const ty = target.y || 0
1083
+ const tw = target.width || 160
1084
+ const th = target.height || 60
1085
+
1086
+ // For circle, x/y is top-left of bounding box in our DSL
1087
+ const cx = tx + tw / 2
1088
+ const cy = ty + th / 2
1089
+
1090
+ switch (side) {
1091
+ case 'top': return { x: cx + offset, y: ty }
1092
+ case 'bottom': return { x: cx + offset, y: ty + th }
1093
+ case 'left': return { x: tx, y: cy + offset }
1094
+ case 'right': return { x: tx + tw, y: cy + offset }
1095
+ case 'center': return { x: cx + offset, y: cy }
1096
+ default: return { x: cx, y: cy }
1097
+ }
1098
+ }
1099
+ return { x: 0, y: 0 }
1100
+ }
1101
+
1102
+ // Calculate bounds
1103
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
1104
+
1105
+ for (const def of defs) {
1106
+ if (def.type === 'arrow' || def.type === 'line') {
1107
+ const from = resolvePreviewPoint(def.from)
1108
+ const to = resolvePreviewPoint(def.to)
1109
+ minX = Math.min(minX, from.x, to.x)
1110
+ minY = Math.min(minY, from.y, to.y)
1111
+ maxX = Math.max(maxX, from.x, to.x)
1112
+ maxY = Math.max(maxY, from.y, to.y)
1113
+ } else if (def.type !== 'frame') {
1114
+ const x = def.x || 0
1115
+ const y = def.y || 0
1116
+ const w = def.width || 160
1117
+ const h = def.height || 60
1118
+ minX = Math.min(minX, x)
1119
+ minY = Math.min(minY, y)
1120
+ maxX = Math.max(maxX, x + w)
1121
+ maxY = Math.max(maxY, y + h)
1122
+ }
1123
+ }
1124
+
1125
+ if (!isFinite(minX)) { minX = 0; minY = 0; maxX = 400; maxY = 300 }
1126
+
1127
+ const pad = 40
1128
+ const vw = maxX - minX + pad * 2
1129
+ const vh = maxY - minY + pad * 2
1130
+
1131
+ let svgContent = ''
1132
+
1133
+ // Render each shape as simple SVG
1134
+ for (const def of defs) {
1135
+ const props = def.props || {}
1136
+
1137
+ switch (def.type) {
1138
+ case 'rect':
1139
+ svgContent += `<rect x="${def.x || 0}" y="${def.y || 0}" width="${def.width || 160}" height="${def.height || 60}" stroke="${props.stroke || '#fff'}" stroke-width="${props.strokeWidth || 2}" fill="${props.fill || 'transparent'}" rx="4" />`
1140
+ if (props.label) {
1141
+ const cx = (def.x || 0) + (def.width || 160) / 2
1142
+ const cy = (def.y || 0) + (def.height || 60) / 2
1143
+ svgContent += `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" fill="${props.labelColor || '#e0e0e0'}" font-size="${props.labelFontSize || 14}" font-family="sans-serif">${escapeXml(props.label)}</text>`
1144
+ }
1145
+ break
1146
+
1147
+ case 'circle':
1148
+ case 'ellipse': {
1149
+ const rx = (def.width || 80) / 2
1150
+ const ry = (def.height || 80) / 2
1151
+ const cx = (def.x || 0) + rx
1152
+ const cy = (def.y || 0) + ry
1153
+ svgContent += `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" stroke="${props.stroke || '#fff'}" stroke-width="${props.strokeWidth || 2}" fill="${props.fill || 'transparent'}" />`
1154
+ if (props.label) {
1155
+ svgContent += `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" fill="${props.labelColor || '#e0e0e0'}" font-size="${props.labelFontSize || 14}" font-family="sans-serif">${escapeXml(props.label)}</text>`
1156
+ }
1157
+ break
1158
+ }
1159
+
1160
+ case 'text': {
1161
+ const content = props.content || props.text || 'Text'
1162
+ svgContent += `<text x="${def.x || 0}" y="${def.y || 0}" fill="${props.color || props.fill || '#fff'}" font-size="${props.fontSize || 16}" font-family="sans-serif">${escapeXml(content)}</text>`
1163
+ break
1164
+ }
1165
+
1166
+ case 'arrow': {
1167
+ const fromOrig = resolvePreviewPoint(def.from)
1168
+ const toOrig = resolvePreviewPoint(def.to)
1169
+ const stroke = props.stroke || '#fff'
1170
+ const sw = props.strokeWidth || 2
1171
+ const dash = props.style === 'dashed' ? ' stroke-dasharray="10,10"' : props.style === 'dotted' ? ' stroke-dasharray="2,8"' : ''
1172
+ const curve = props.curve || 'straight'
1173
+ const markerId = `ah-${def.id}`
1174
+ const headLen = 10 // arrowhead marker size
1175
+
1176
+ // Pull back arrow endpoint so the tip lands at the shape edge, not inside it
1177
+ const to = shortenEndpoint(fromOrig, toOrig, headLen)
1178
+ const from = fromOrig
1179
+
1180
+ // Per-arrow colored arrowhead marker — refX=0 so tip is at the shortened line end
1181
+ svgContent += `<defs><marker id="${markerId}" markerWidth="10" markerHeight="7" refX="0" refY="3.5" orient="auto"><polygon points="0 0, 10 3.5, 0 7" fill="${stroke}" /></marker></defs>`
1182
+
1183
+ if (curve === 'curved') {
1184
+ // Compute a quadratic bezier control point perpendicular to the line
1185
+ const mx = (from.x + toOrig.x) / 2
1186
+ const my = (from.y + toOrig.y) / 2
1187
+ const dx = toOrig.x - from.x
1188
+ const dy = toOrig.y - from.y
1189
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1
1190
+ const amt = props.curveAmount || Math.min(dist * 0.3, 80)
1191
+ // Perpendicular offset (always curve to the same side)
1192
+ const cpx = mx + (dy / dist) * amt
1193
+ const cpy = my - (dx / dist) * amt
1194
+ // Shorten the bezier endpoint
1195
+ const toShort = shortenEndpoint({ x: cpx, y: cpy }, toOrig, headLen)
1196
+ svgContent += `<path d="M${from.x},${from.y} Q${cpx},${cpy} ${toShort.x},${toShort.y}" stroke="${stroke}" stroke-width="${sw}" fill="none"${dash} marker-end="url(#${markerId})" />`
1197
+ if (props.label) {
1198
+ // Label at the curve midpoint (t=0.5 on quadratic bezier)
1199
+ const lx = 0.25 * from.x + 0.5 * cpx + 0.25 * toOrig.x
1200
+ const ly = 0.25 * from.y + 0.5 * cpy + 0.25 * toOrig.y - 8
1201
+ svgContent += `<text x="${lx}" y="${ly}" text-anchor="middle" fill="${props.labelColor || '#a0a0b0'}" font-size="11" font-family="sans-serif">${escapeXml(props.label)}</text>`
1202
+ }
1203
+ } else if (curve === 'elbow') {
1204
+ // Simple elbow: go vertical then horizontal (or vice versa)
1205
+ const midY = from.y + (toOrig.y - from.y) / 2
1206
+ // Shorten the last segment endpoint
1207
+ const lastFrom = { x: toOrig.x, y: midY }
1208
+ const toShort = shortenEndpoint(lastFrom, toOrig, headLen)
1209
+ svgContent += `<path d="M${from.x},${from.y} L${from.x},${midY} L${toOrig.x},${midY} L${toShort.x},${toShort.y}" stroke="${stroke}" stroke-width="${sw}" fill="none"${dash} marker-end="url(#${markerId})" />`
1210
+ if (props.label) {
1211
+ const lx = (from.x + toOrig.x) / 2
1212
+ const ly = midY - 8
1213
+ svgContent += `<text x="${lx}" y="${ly}" text-anchor="middle" fill="${props.labelColor || '#a0a0b0'}" font-size="11" font-family="sans-serif">${escapeXml(props.label)}</text>`
1214
+ }
1215
+ } else {
1216
+ svgContent += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${stroke}" stroke-width="${sw}"${dash} marker-end="url(#${markerId})" />`
1217
+ if (props.label) {
1218
+ const lx = (from.x + toOrig.x) / 2
1219
+ const ly = (from.y + toOrig.y) / 2 - 10
1220
+ svgContent += `<text x="${lx}" y="${ly}" text-anchor="middle" fill="${props.labelColor || '#a0a0b0'}" font-size="11" font-family="sans-serif">${escapeXml(props.label)}</text>`
1221
+ }
1222
+ }
1223
+ break
1224
+ }
1225
+
1226
+ case 'line': {
1227
+ const from = resolvePreviewPoint(def.from)
1228
+ const to = resolvePreviewPoint(def.to)
1229
+ const dash = props.style === 'dashed' ? ' stroke-dasharray="10,10"' : props.style === 'dotted' ? ' stroke-dasharray="2,8"' : ''
1230
+ svgContent += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${props.stroke || '#fff'}" stroke-width="${props.strokeWidth || 2}"${dash} />`
1231
+ break
1232
+ }
1233
+
1234
+ case 'frame': {
1235
+ const frameName = props.name || def.id
1236
+ svgContent += `<rect x="${def.x || 0}" y="${def.y || 0}" width="${def.width || 600}" height="${def.height || 400}" stroke="${props.stroke || '#555'}" stroke-width="1" fill="transparent" stroke-dasharray="8,4" rx="8" />`
1237
+ svgContent += `<text x="${(def.x || 0) + 10}" y="${(def.y || 0) - 8}" fill="#888" font-size="12" font-family="sans-serif">${escapeXml(frameName)}</text>`
1238
+ break
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${minX - pad} ${minY - pad} ${vw} ${vh}" width="100%" height="100%" style="background: transparent;">
1244
+ ${svgContent}
1245
+ </svg>`
1246
+ }
1247
+
1248
+ /**
1249
+ * Pull back an arrow endpoint so the arrowhead tip lands at the target edge,
1250
+ * rather than the line extending into the shape.
1251
+ */
1252
+ function shortenEndpoint(from, to, amount) {
1253
+ const dx = to.x - from.x
1254
+ const dy = to.y - from.y
1255
+ const dist = Math.sqrt(dx * dx + dy * dy)
1256
+ if (dist < amount * 2) return to // too short to shorten
1257
+ return {
1258
+ x: to.x - (dx / dist) * amount,
1259
+ y: to.y - (dy / dist) * amount,
1260
+ }
1261
+ }
1262
+
1263
+ function escapeXml(str) {
1264
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
1265
+ }
1266
+
1267
+ // ============================================================
1268
+ // STROKE STYLE HELPERS
1269
+ // ============================================================
1270
+
1271
+ function resolveStrokeStyle(style) {
1272
+ if (!style) return ''
1273
+ switch (style) {
1274
+ case 'dashed': return '10,10'
1275
+ case 'dotted': return '2,8'
1276
+ case 'solid':
1277
+ default: return ''
1278
+ }
1279
+ }
1280
+
1281
+ // ============================================================
1282
+ // WINDOW BRIDGE — expose to canvas engine
1283
+ // ============================================================
1284
+
1285
+ export function initLixScriptBridge() {
1286
+ window.__lixscriptParse = parseLixScript
1287
+ window.__lixscriptRender = renderLixScript
1288
+ window.__lixscriptPreview = (source) => {
1289
+ const parsed = parseLixScript(source)
1290
+ return previewLixScript(parsed)
1291
+ }
1292
+ window.__lixscriptExecute = (source) => {
1293
+ const parsed = parseLixScript(source)
1294
+ if (parsed.errors.length > 0) {
1295
+ return { success: false, errors: parsed.errors, shapesCreated: 0 }
1296
+ }
1297
+ return renderLixScript(parsed)
1298
+ }
1299
+ }