@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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/fonts/fonts.css +29 -0
- package/fonts/lixCode.ttf +0 -0
- package/fonts/lixDefault.ttf +0 -0
- package/fonts/lixDocs.ttf +0 -0
- package/fonts/lixFancy.ttf +0 -0
- package/fonts/lixFont.woff2 +0 -0
- package/package.json +49 -0
- package/src/SketchEngine.js +473 -0
- package/src/core/AIRenderer.js +1390 -0
- package/src/core/CopyPaste.js +655 -0
- package/src/core/EraserTrail.js +234 -0
- package/src/core/EventDispatcher.js +371 -0
- package/src/core/GraphEngine.js +150 -0
- package/src/core/GraphMathParser.js +231 -0
- package/src/core/GraphRenderer.js +255 -0
- package/src/core/LayerOrder.js +91 -0
- package/src/core/LixScriptParser.js +1299 -0
- package/src/core/MermaidFlowchartRenderer.js +475 -0
- package/src/core/MermaidSequenceParser.js +197 -0
- package/src/core/MermaidSequenceRenderer.js +479 -0
- package/src/core/ResizeCode.js +175 -0
- package/src/core/ResizeShapes.js +318 -0
- package/src/core/SceneSerializer.js +778 -0
- package/src/core/Selection.js +1861 -0
- package/src/core/SnapGuides.js +273 -0
- package/src/core/UndoRedo.js +1358 -0
- package/src/core/ZoomPan.js +258 -0
- package/src/core/ai-system-prompt.js +663 -0
- package/src/index.js +69 -0
- package/src/shapes/Arrow.js +1979 -0
- package/src/shapes/Circle.js +751 -0
- package/src/shapes/CodeShape.js +244 -0
- package/src/shapes/Frame.js +1460 -0
- package/src/shapes/FreehandStroke.js +724 -0
- package/src/shapes/IconShape.js +265 -0
- package/src/shapes/ImageShape.js +270 -0
- package/src/shapes/Line.js +738 -0
- package/src/shapes/Rectangle.js +794 -0
- package/src/shapes/TextShape.js +225 -0
- package/src/tools/arrowTool.js +581 -0
- package/src/tools/circleTool.js +619 -0
- package/src/tools/codeTool.js +2103 -0
- package/src/tools/eraserTool.js +131 -0
- package/src/tools/frameTool.js +241 -0
- package/src/tools/freehandTool.js +620 -0
- package/src/tools/iconTool.js +1344 -0
- package/src/tools/imageTool.js +1323 -0
- package/src/tools/laserTool.js +317 -0
- package/src/tools/lineTool.js +502 -0
- package/src/tools/rectangleTool.js +544 -0
- package/src/tools/textTool.js +1823 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|