@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,1979 @@
1
+ /* eslint-disable */
2
+ // Arrow shape class - extracted from drawArrow.js
3
+ // Depends on globals: svg, shapes, rough, currentShape, currentZoom
4
+
5
+ let isDragging = false;
6
+ let hoveredFrameArrow = null;
7
+ let dragOldPosArrow = null;
8
+
9
+ function getSVGCoordsFromMouse(e) {
10
+ const viewBox = svg.viewBox.baseVal;
11
+ const rect = svg.getBoundingClientRect();
12
+ const mouseX = e.clientX - rect.left;
13
+ const mouseY = e.clientY - rect.top;
14
+ const svgX = viewBox.x + (mouseX / rect.width) * viewBox.width;
15
+ const svgY = viewBox.y + (mouseY / rect.height) * viewBox.height;
16
+ return { x: svgX, y: svgY };
17
+ }
18
+
19
+ class Arrow {
20
+ constructor(startPoint, endPoint, options = {}) {
21
+ this.startPoint = startPoint;
22
+ this.endPoint = endPoint;
23
+ this.options = {
24
+ stroke: options.stroke || "#fff",
25
+ strokeWidth: options.strokeWidth || 2,
26
+ strokeDasharray: options.arrowOutlineStyle === "dashed" ? "10,10" : (options.arrowOutlineStyle === "dotted" ? "2,8" : ""),
27
+ fill: 'none',
28
+ ...options
29
+ };
30
+ this.arrowOutlineStyle = options.arrowOutlineStyle || "solid";
31
+ this.arrowHeadStyle = options.arrowHeadStyle || "default";
32
+ this.arrowHeadLength = parseFloat(options.arrowHeadLength || 15);
33
+ this.arrowHeadAngleDeg = parseFloat(options.arrowHeadAngleDeg || 30);
34
+ this.arrowCurved = options.arrowCurved !== undefined ? options.arrowCurved : "straight";
35
+ this.arrowCurveAmount = options.arrowCurveAmount || 50;
36
+
37
+ // Control points for curved arrows
38
+ this.controlPoint1 = options.controlPoint1 || null;
39
+ this.controlPoint2 = options.controlPoint2 || null;
40
+
41
+
42
+ this.attachedToStart = null;
43
+ this.attachedToEnd = null;
44
+ this.parentFrame = null;
45
+ this.element = null;
46
+ this.elbowX = options.elbowX !== undefined ? options.elbowX : null;
47
+ this.group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
48
+ this.isSelected = false;
49
+ this.anchors = [];
50
+ this.shapeName = "arrow";
51
+ this.shapeID = `arrow-${String(Date.now()).slice(0, 8)}-${Math.floor(Math.random() * 10000)}`;
52
+ this.group.setAttribute('id', this.shapeID);
53
+
54
+ // Embedded label support
55
+ this.label = options.label || '';
56
+ this.labelElement = null;
57
+ this.labelColor = options.labelColor || '#e0e0e0';
58
+ this.labelFontSize = options.labelFontSize || 12;
59
+ this._isEditingLabel = false;
60
+ this._hitArea = null;
61
+ this._labelBg = null;
62
+
63
+ // Initialize control points if curved
64
+ if (this.arrowCurved === "curved" && !this.controlPoint1 && !this.controlPoint2) {
65
+ this.initializeCurveControlPoints();
66
+ }
67
+
68
+ svg.appendChild(this.group);
69
+ this._setupLabelDblClick();
70
+ this.draw();
71
+ }
72
+
73
+ get x() {
74
+ return Math.min(this.startPoint.x, this.endPoint.x);
75
+ }
76
+
77
+ set x(value) {
78
+ const currentX = this.x;
79
+ const dx = value - currentX;
80
+ this.startPoint.x += dx;
81
+ this.endPoint.x += dx;
82
+ if (this.controlPoint1) this.controlPoint1.x += dx;
83
+ if (this.controlPoint2) this.controlPoint2.x += dx;
84
+ }
85
+
86
+ get y() {
87
+ return Math.min(this.startPoint.y, this.endPoint.y);
88
+ }
89
+
90
+ set y(value) {
91
+ const currentY = this.y;
92
+ const dy = value - currentY;
93
+ this.startPoint.y += dy;
94
+ this.endPoint.y += dy;
95
+ if (this.controlPoint1) this.controlPoint1.y += dy;
96
+ if (this.controlPoint2) this.controlPoint2.y += dy;
97
+ }
98
+
99
+ get width() {
100
+ return Math.abs(this.endPoint.x - this.startPoint.x);
101
+ }
102
+
103
+ set width(value) {
104
+ const centerX = (this.startPoint.x + this.endPoint.x) / 2;
105
+ const halfWidth = value / 2;
106
+ this.startPoint.x = centerX - halfWidth;
107
+ this.endPoint.x = centerX + halfWidth;
108
+ }
109
+
110
+ get height() {
111
+ return Math.abs(this.endPoint.y - this.startPoint.y);
112
+ }
113
+
114
+ set height(value) {
115
+ const centerY = (this.startPoint.y + this.endPoint.y) / 2;
116
+ const halfHeight = value / 2;
117
+ this.startPoint.y = centerY - halfHeight;
118
+ this.endPoint.y = centerY + halfHeight;
119
+ }
120
+
121
+ initializeCurveControlPoints() {
122
+ const dx = this.endPoint.x - this.startPoint.x;
123
+ const dy = this.endPoint.y - this.startPoint.y;
124
+ const distance = Math.sqrt(dx * dx + dy * dy);
125
+
126
+ if (distance === 0 || isNaN(distance)) {
127
+ this.controlPoint1 = { x: this.startPoint.x + 20, y: this.startPoint.y };
128
+ this.controlPoint2 = { x: this.endPoint.x - 20, y: this.endPoint.y };
129
+ return;
130
+ }
131
+
132
+ const perpX = -dy / distance;
133
+ const perpY = dx / distance;
134
+ const curveOffset = this.arrowCurveAmount;
135
+
136
+ const t1 = 0.33;
137
+ const point1X = this.startPoint.x + t1 * dx;
138
+ const point1Y = this.startPoint.y + t1 * dy;
139
+ this.controlPoint1 = {
140
+ x: point1X + perpX * curveOffset,
141
+ y: point1Y + perpY * curveOffset
142
+ };
143
+
144
+ const t2 = 0.67;
145
+ const point2X = this.startPoint.x + t2 * dx;
146
+ const point2Y = this.startPoint.y + t2 * dy;
147
+ this.controlPoint2 = {
148
+ x: point2X - perpX * curveOffset,
149
+ y: point2Y - perpY * curveOffset
150
+ };
151
+ }
152
+
153
+ _buildElbowPath(elbowX, shortenEnd) {
154
+ const x1 = this.startPoint.x, y1 = this.startPoint.y;
155
+ const x2 = this.endPoint.x, y2 = this.endPoint.y;
156
+ const r = Math.min(
157
+ Math.abs(this.arrowCurveAmount),
158
+ Math.abs(elbowX - x1) / 2,
159
+ Math.abs(x2 - elbowX) / 2,
160
+ Math.abs(y2 - y1) / 2
161
+ );
162
+ const dx = elbowX >= x1 ? 1 : -1;
163
+ const ex = x2 >= elbowX ? 1 : -1;
164
+ const dy = y2 >= y1 ? 1 : -1;
165
+ let endX = x2;
166
+ if (shortenEnd) {
167
+ endX = x2 - ex * (this.arrowHeadLength * 0.3);
168
+ }
169
+ if (r > 2 && Math.abs(elbowX - x1) > r * 2 && Math.abs(x2 - elbowX) > r * 2 && Math.abs(y2 - y1) > r * 2) {
170
+ return `M ${x1} ${y1}` +
171
+ ` H ${elbowX - dx * r}` +
172
+ ` Q ${elbowX} ${y1} ${elbowX} ${y1 + dy * r}` +
173
+ ` V ${y2 - dy * r}` +
174
+ ` Q ${elbowX} ${y2} ${elbowX + ex * r} ${y2}` +
175
+ ` H ${endX}`;
176
+ }
177
+ return `M ${x1} ${y1} H ${elbowX} V ${y2} H ${endX}`;
178
+ }
179
+
180
+ selectArrow() {
181
+ this.isSelected = true;
182
+ disableAllSideBars();
183
+ arrowSideBar.classList.remove("hidden");
184
+ if (window.__showSidebarForShape) window.__showSidebarForShape('arrow');
185
+ this.updateSidebar();
186
+ this.draw();
187
+ }
188
+
189
+ removeSelection() {
190
+ this.anchors.forEach(anchor => {
191
+ if (anchor.parentNode === this.group) {
192
+ this.group.removeChild(anchor);
193
+ }
194
+ });
195
+ this.anchors = [];
196
+ this.isSelected = false;
197
+ }
198
+
199
+ attachToShape(isStartPoint, shape, attachment) {
200
+ if (isStartPoint) {
201
+ this.attachedToStart = {
202
+ shape: shape,
203
+ side: attachment.side,
204
+ offset: attachment.offset
205
+ };
206
+ this.startPoint = attachment.point;
207
+ } else {
208
+ this.attachedToEnd = {
209
+ shape: shape,
210
+ side: attachment.side,
211
+ offset: attachment.offset
212
+ };
213
+ this.endPoint = attachment.point;
214
+ }
215
+
216
+ // Update control points if curved
217
+ if (this.arrowCurved === "curved") {
218
+ this.initializeCurveControlPoints();
219
+ }
220
+
221
+ this.draw();
222
+ }
223
+
224
+ draw() {
225
+ // Clean up existing arrowhead element before redraw
226
+ if (this._arrowHeadEl) {
227
+ this._arrowHeadEl.remove();
228
+ this._arrowHeadEl = null;
229
+ }
230
+
231
+ const childrenToRemove = [];
232
+ const anchorSet = this._skipAnchors ? new Set(this.anchors) : null;
233
+ for (let i = 0; i < this.group.children.length; i++) {
234
+ const child = this.group.children[i];
235
+ if (child !== this.labelElement && child !== this._hitArea && child !== this._labelBg) {
236
+ if (anchorSet && anchorSet.has(child)) continue;
237
+ childrenToRemove.push(child);
238
+ }
239
+ }
240
+ childrenToRemove.forEach(child => this.group.removeChild(child));
241
+ if (!this._skipAnchors) this.anchors = [];
242
+
243
+ let pathData;
244
+ let arrowEndPoint = this.endPoint;
245
+
246
+ const elbowX = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
247
+
248
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
249
+ if (isNaN(this.controlPoint1.x) || isNaN(this.controlPoint1.y) ||
250
+ isNaN(this.controlPoint2.x) || isNaN(this.controlPoint2.y)) {
251
+ this.initializeCurveControlPoints();
252
+ }
253
+
254
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} ` +
255
+ `C ${this.controlPoint1.x} ${this.controlPoint1.y}, ` +
256
+ `${this.controlPoint2.x} ${this.controlPoint2.y}, ` +
257
+ `${this.endPoint.x} ${this.endPoint.y}`;
258
+
259
+ // Shorten curve endpoint so arrowhead sits cleanly at the tip
260
+ const t = 0.95;
261
+ const tangent = this.getCubicBezierTangent(t);
262
+ const curveAngle = Math.atan2(tangent.y, tangent.x);
263
+
264
+ if (this.arrowHeadStyle && this.arrowHeadStyle !== 'none') {
265
+ arrowEndPoint = {
266
+ x: this.endPoint.x - (this.arrowHeadLength * 0.3) * Math.cos(curveAngle),
267
+ y: this.endPoint.y - (this.arrowHeadLength * 0.3) * Math.sin(curveAngle)
268
+ };
269
+
270
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} ` +
271
+ `C ${this.controlPoint1.x} ${this.controlPoint1.y}, ` +
272
+ `${this.controlPoint2.x} ${this.controlPoint2.y}, ` +
273
+ `${arrowEndPoint.x} ${arrowEndPoint.y}`;
274
+ }
275
+ } else if (this.arrowCurved === "elbow") {
276
+ pathData = this._buildElbowPath(elbowX, false);
277
+ } else {
278
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
279
+ }
280
+
281
+ // Render arrowhead
282
+ const headAngle = this._getArrowAngle(elbowX);
283
+ if (this.arrowHeadStyle === "default") {
284
+ const pts = this._getArrowHeadPoints(headAngle);
285
+ pathData += ` M ${pts.x3} ${pts.y3} L ${this.endPoint.x} ${this.endPoint.y} L ${pts.x4} ${pts.y4}`;
286
+ } else {
287
+ this._renderArrowHead(headAngle);
288
+ }
289
+
290
+ const arrowPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
291
+ arrowPath.setAttribute("d", pathData);
292
+ arrowPath.setAttribute("stroke", this.options.stroke);
293
+ arrowPath.setAttribute("stroke-width", this.options.strokeWidth);
294
+ arrowPath.setAttribute("fill", this.options.fill);
295
+
296
+ if (this.options.strokeDasharray) {
297
+ arrowPath.setAttribute("stroke-dasharray", this.options.strokeDasharray);
298
+ } else {
299
+ arrowPath.removeAttribute("stroke-dasharray");
300
+ }
301
+
302
+ arrowPath.setAttribute("stroke-linecap", "round");
303
+ arrowPath.setAttribute("stroke-linejoin", "round");
304
+ arrowPath.classList.add("arrow-path");
305
+
306
+ this.element = arrowPath;
307
+ this.group.appendChild(this.element);
308
+
309
+ // Hit area - thicker invisible path for dblclick detection
310
+ {
311
+ let hitPathData;
312
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
313
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} C ${this.controlPoint1.x} ${this.controlPoint1.y}, ${this.controlPoint2.x} ${this.controlPoint2.y}, ${this.endPoint.x} ${this.endPoint.y}`;
314
+ } else if (this.arrowCurved === "elbow") {
315
+ const ex = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
316
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${ex} ${this.startPoint.y} L ${ex} ${this.endPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
317
+ } else {
318
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
319
+ }
320
+ if (!this._hitArea) {
321
+ this._hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'path');
322
+ this._hitArea.setAttribute('fill', 'none');
323
+ this._hitArea.setAttribute('stroke', 'transparent');
324
+ this._hitArea.setAttribute('stroke-width', '20');
325
+ this._hitArea.setAttribute('style', 'pointer-events: stroke;');
326
+ this.group.appendChild(this._hitArea);
327
+ }
328
+ this._hitArea.setAttribute('d', hitPathData);
329
+ }
330
+
331
+ // Update embedded label at midpoint
332
+ this._updateLabelElement();
333
+
334
+ if (this.isSelected) {
335
+ if (this._skipAnchors) {
336
+ this.updateSelectionControls();
337
+ } else {
338
+ this.addAnchors();
339
+ this.addAttachmentIndicators();
340
+ }
341
+ }
342
+ }
343
+
344
+ _buildFullPathData() {
345
+ let pathData;
346
+ const elbowX = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
347
+
348
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
349
+ if (isNaN(this.controlPoint1.x) || isNaN(this.controlPoint1.y) ||
350
+ isNaN(this.controlPoint2.x) || isNaN(this.controlPoint2.y)) {
351
+ this.initializeCurveControlPoints();
352
+ }
353
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} ` +
354
+ `C ${this.controlPoint1.x} ${this.controlPoint1.y}, ` +
355
+ `${this.controlPoint2.x} ${this.controlPoint2.y}, ` +
356
+ `${this.endPoint.x} ${this.endPoint.y}`;
357
+
358
+ if (this.arrowHeadStyle && this.arrowHeadStyle !== 'none') {
359
+ const t = 0.95;
360
+ const tangent = this.getCubicBezierTangent(t);
361
+ const angle = Math.atan2(tangent.y, tangent.x);
362
+ const arrowEndPoint = {
363
+ x: this.endPoint.x - (this.arrowHeadLength * 0.3) * Math.cos(angle),
364
+ y: this.endPoint.y - (this.arrowHeadLength * 0.3) * Math.sin(angle)
365
+ };
366
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} ` +
367
+ `C ${this.controlPoint1.x} ${this.controlPoint1.y}, ` +
368
+ `${this.controlPoint2.x} ${this.controlPoint2.y}, ` +
369
+ `${arrowEndPoint.x} ${arrowEndPoint.y}`;
370
+ }
371
+ } else if (this.arrowCurved === "elbow") {
372
+ pathData = this._buildElbowPath(elbowX, false);
373
+ } else {
374
+ pathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
375
+ }
376
+
377
+ // Only include arrowhead in path string for default style
378
+ if (this.arrowHeadStyle === "default") {
379
+ const angle = this._getArrowAngle(elbowX);
380
+ const pts = this._getArrowHeadPoints(angle);
381
+ pathData += ` M ${pts.x3} ${pts.y3} L ${this.endPoint.x} ${this.endPoint.y} L ${pts.x4} ${pts.y4}`;
382
+ }
383
+ return pathData;
384
+ }
385
+
386
+ _updatePathElement() {
387
+ if (!this.element) return;
388
+ this.element.setAttribute("d", this._buildFullPathData());
389
+ this._updateArrowHead();
390
+ }
391
+
392
+ _updateHitArea() {
393
+ if (!this._hitArea) return;
394
+ let hitPathData;
395
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
396
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} C ${this.controlPoint1.x} ${this.controlPoint1.y}, ${this.controlPoint2.x} ${this.controlPoint2.y}, ${this.endPoint.x} ${this.endPoint.y}`;
397
+ } else if (this.arrowCurved === "elbow") {
398
+ const ex = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
399
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${ex} ${this.startPoint.y} L ${ex} ${this.endPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
400
+ } else {
401
+ hitPathData = `M ${this.startPoint.x} ${this.startPoint.y} L ${this.endPoint.x} ${this.endPoint.y}`;
402
+ }
403
+ this._hitArea.setAttribute('d', hitPathData);
404
+ }
405
+
406
+ _getMidpoint() {
407
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
408
+ const p = this.getCubicBezierPoint(0.5);
409
+ return { x: p.x, y: p.y };
410
+ }
411
+ if (this.arrowCurved === "elbow") {
412
+ const ex = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
413
+ return { x: ex, y: (this.startPoint.y + this.endPoint.y) / 2 };
414
+ }
415
+ return {
416
+ x: (this.startPoint.x + this.endPoint.x) / 2,
417
+ y: (this.startPoint.y + this.endPoint.y) / 2
418
+ };
419
+ }
420
+
421
+ _updateLabelElement() {
422
+ if (!this.label) {
423
+ if (this.labelElement && this.labelElement.parentNode === this.group) {
424
+ this.group.removeChild(this.labelElement);
425
+ this.labelElement = null;
426
+ }
427
+ if (this._labelBg && this._labelBg.parentNode === this.group) {
428
+ this.group.removeChild(this._labelBg);
429
+ this._labelBg = null;
430
+ }
431
+ return;
432
+ }
433
+
434
+ if (!this.labelElement) {
435
+ this.labelElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
436
+ this.labelElement.setAttribute('class', 'shape-label');
437
+ this.labelElement.setAttribute('pointer-events', 'none');
438
+ }
439
+
440
+ const mid = this._getMidpoint();
441
+ this.labelElement.setAttribute('x', mid.x);
442
+ this.labelElement.setAttribute('y', mid.y);
443
+ this.labelElement.setAttribute('text-anchor', 'middle');
444
+ this.labelElement.setAttribute('dominant-baseline', 'central');
445
+ this.labelElement.setAttribute('fill', this.labelColor);
446
+ this.labelElement.setAttribute('font-size', this.labelFontSize);
447
+ this.labelElement.setAttribute('font-family', 'lixFont, sans-serif');
448
+ this.labelElement.textContent = this.label;
449
+
450
+ // Background knockout rect - hides the arrow behind the text
451
+ const canvasBg = window.getComputedStyle(svg).backgroundColor || '#000';
452
+ if (!this._labelBg) {
453
+ this._labelBg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
454
+ this._labelBg.setAttribute('pointer-events', 'none');
455
+ }
456
+ this._labelBg.setAttribute('fill', canvasBg);
457
+ const hPadding = 4;
458
+ const vPadding = 1;
459
+ const charWidth = this.labelFontSize * 0.6;
460
+ const bgW = this.label.length * charWidth + hPadding * 2;
461
+ const bgH = this.labelFontSize + vPadding * 2;
462
+ this._labelBg.setAttribute('x', mid.x - bgW / 2);
463
+ this._labelBg.setAttribute('y', mid.y - bgH / 2);
464
+ this._labelBg.setAttribute('width', bgW);
465
+ this._labelBg.setAttribute('height', bgH);
466
+ this._labelBg.setAttribute('rx', 2);
467
+
468
+ // Re-append bg then text at end so they render ON TOP of the arrow path
469
+ if (this._labelBg.parentNode === this.group) this.group.removeChild(this._labelBg);
470
+ if (this.labelElement.parentNode === this.group) this.group.removeChild(this.labelElement);
471
+ this.group.appendChild(this._labelBg);
472
+ this.group.appendChild(this.labelElement);
473
+ }
474
+
475
+ _setupLabelDblClick() {
476
+ this.group.addEventListener('dblclick', (e) => {
477
+ e.stopPropagation();
478
+ e.preventDefault();
479
+ this.startLabelEdit();
480
+ });
481
+ }
482
+
483
+ startLabelEdit() {
484
+ if (this._isEditingLabel) return;
485
+ this._isEditingLabel = true;
486
+
487
+ if (this.labelElement) {
488
+ this.labelElement.setAttribute('visibility', 'hidden');
489
+ }
490
+ if (this._labelBg) {
491
+ this._labelBg.setAttribute('visibility', 'hidden');
492
+ }
493
+
494
+ // Get midpoint in screen coords via CTM
495
+ const mid = this._getMidpoint();
496
+ const ctm = this.group.getScreenCTM();
497
+ if (!ctm) { this._isEditingLabel = false; return; }
498
+
499
+ const pt = svg.createSVGPoint();
500
+ pt.x = mid.x; pt.y = mid.y;
501
+ const screenMid = pt.matrixTransform(ctm);
502
+
503
+ const editW = 160;
504
+ const editH = 28;
505
+
506
+ // Create HTML overlay centered on the midpoint
507
+ const overlay = document.createElement('div');
508
+ overlay.className = 'shape-label-editor';
509
+ overlay.style.cssText = `
510
+ position: fixed; z-index: 10000;
511
+ left: ${screenMid.x - editW / 2}px; top: ${screenMid.y - editH / 2}px;
512
+ width: ${editW}px; height: ${editH}px;
513
+ display: flex; align-items: center; justify-content: center;
514
+ pointer-events: auto;
515
+ `;
516
+
517
+ const canvasBg = window.getComputedStyle(svg).backgroundColor || '#000';
518
+ const input = document.createElement('div');
519
+ input.setAttribute('contenteditable', 'true');
520
+ input.style.cssText = `
521
+ width: 100%; height: 100%;
522
+ background: ${canvasBg}; border: none;
523
+ outline: none; padding: 2px 6px;
524
+ color: ${this.labelColor}; font-size: ${this.labelFontSize}px;
525
+ font-family: lixFont, sans-serif; text-align: center;
526
+ display: flex; align-items: center; justify-content: center;
527
+ white-space: pre-wrap; word-break: break-word;
528
+ cursor: text;
529
+ `;
530
+ if (this.label) {
531
+ input.textContent = this.label;
532
+ } else {
533
+ input.innerHTML = '&nbsp;';
534
+ }
535
+
536
+ overlay.appendChild(input);
537
+ document.body.appendChild(overlay);
538
+
539
+ setTimeout(() => {
540
+ input.focus();
541
+ const sel = window.getSelection();
542
+ const range = document.createRange();
543
+ range.selectNodeContents(input);
544
+ sel.removeAllRanges();
545
+ sel.addRange(range);
546
+ }, 10);
547
+
548
+ const finishEdit = () => {
549
+ const newText = input.textContent.trim().replace(/\u00A0/g, '');
550
+ this.label = newText;
551
+ this._isEditingLabel = false;
552
+ if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
553
+ if (this.labelElement) this.labelElement.removeAttribute('visibility');
554
+ if (this._labelBg) this._labelBg.removeAttribute('visibility');
555
+ this.draw();
556
+ };
557
+
558
+ input.addEventListener('blur', finishEdit);
559
+ input.addEventListener('keydown', (e) => {
560
+ e.stopPropagation();
561
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); input.blur(); }
562
+ if (e.key === 'Escape') { input.textContent = this.label; input.blur(); }
563
+ });
564
+ input.addEventListener('mousedown', (e) => e.stopPropagation());
565
+ input.addEventListener('mousemove', (e) => e.stopPropagation());
566
+ input.addEventListener('mouseup', (e) => e.stopPropagation());
567
+ }
568
+
569
+ setLabel(text, color, fontSize) {
570
+ this.label = text || '';
571
+ if (color) this.labelColor = color;
572
+ if (fontSize) this.labelFontSize = fontSize;
573
+ this.draw();
574
+ }
575
+
576
+ getCubicBezierPoint(t) {
577
+ if (!this.controlPoint1 || !this.controlPoint2) return this.startPoint;
578
+
579
+ const mt = 1 - t;
580
+ const mt2 = mt * mt;
581
+ const mt3 = mt2 * mt;
582
+ const t2 = t * t;
583
+ const t3 = t2 * t;
584
+
585
+ return {
586
+ x: mt3 * this.startPoint.x + 3 * mt2 * t * this.controlPoint1.x +
587
+ 3 * mt * t2 * this.controlPoint2.x + t3 * this.endPoint.x,
588
+ y: mt3 * this.startPoint.y + 3 * mt2 * t * this.controlPoint1.y +
589
+ 3 * mt * t2 * this.controlPoint2.y + t3 * this.endPoint.y
590
+ };
591
+ }
592
+
593
+ getCubicBezierTangent(t) {
594
+ if (!this.controlPoint1 || !this.controlPoint2) {
595
+ return { x: this.endPoint.x - this.startPoint.x, y: this.endPoint.y - this.startPoint.y };
596
+ }
597
+
598
+ const mt = 1 - t;
599
+ const mt2 = mt * mt;
600
+ const t2 = t * t;
601
+
602
+ return {
603
+ x: 3 * mt2 * (this.controlPoint1.x - this.startPoint.x) +
604
+ 6 * mt * t * (this.controlPoint2.x - this.controlPoint1.x) +
605
+ 3 * t2 * (this.endPoint.x - this.controlPoint2.x),
606
+ y: 3 * mt2 * (this.controlPoint1.y - this.startPoint.y) +
607
+ 6 * mt * t * (this.controlPoint2.y - this.controlPoint1.y) +
608
+ 3 * t2 * (this.endPoint.y - this.controlPoint2.y)
609
+ };
610
+ }
611
+
612
+ _getArrowAngle(elbowX) {
613
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
614
+ const tangent = this.getCubicBezierTangent(1.0);
615
+ return Math.atan2(tangent.y, tangent.x);
616
+ } else if (this.arrowCurved === "elbow") {
617
+ const ex = elbowX !== undefined ? elbowX : (this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2);
618
+ return Math.atan2(0, this.endPoint.x - ex);
619
+ } else {
620
+ const dx = this.endPoint.x - this.startPoint.x;
621
+ const dy = this.endPoint.y - this.startPoint.y;
622
+ return Math.atan2(dy, dx);
623
+ }
624
+ }
625
+
626
+ _getArrowHeadPoints(angle) {
627
+ const rad = (this.arrowHeadAngleDeg * Math.PI) / 180;
628
+ return {
629
+ x3: this.endPoint.x - this.arrowHeadLength * Math.cos(angle - rad),
630
+ y3: this.endPoint.y - this.arrowHeadLength * Math.sin(angle - rad),
631
+ x4: this.endPoint.x - this.arrowHeadLength * Math.cos(angle + rad),
632
+ y4: this.endPoint.y - this.arrowHeadLength * Math.sin(angle + rad)
633
+ };
634
+ }
635
+
636
+ _renderArrowHead(angle) {
637
+ if (this._arrowHeadEl) {
638
+ this._arrowHeadEl.remove();
639
+ this._arrowHeadEl = null;
640
+ }
641
+
642
+ const style = this.arrowHeadStyle;
643
+ if (!style || style === 'default') return; // default is handled inline in pathData
644
+
645
+ const pts = this._getArrowHeadPoints(angle);
646
+ const tip = this.endPoint;
647
+ let el;
648
+
649
+ if (style === 'square') {
650
+ const size = this.arrowHeadLength * 0.7;
651
+ const perpX = -Math.sin(angle), perpY = Math.cos(angle);
652
+ const backX = -Math.cos(angle), backY = -Math.sin(angle);
653
+ const p1x = tip.x + perpX * size / 2, p1y = tip.y + perpY * size / 2;
654
+ const p2x = tip.x - perpX * size / 2, p2y = tip.y - perpY * size / 2;
655
+ const p3x = p2x + backX * size, p3y = p2y + backY * size;
656
+ const p4x = p1x + backX * size, p4y = p1y + backY * size;
657
+ el = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
658
+ el.setAttribute("points", `${p1x},${p1y} ${p2x},${p2y} ${p3x},${p3y} ${p4x},${p4y}`);
659
+ el.setAttribute("fill", "none");
660
+ } else {
661
+ // outline or solid - triangle
662
+ el = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
663
+ el.setAttribute("points", `${pts.x3},${pts.y3} ${tip.x},${tip.y} ${pts.x4},${pts.y4}`);
664
+ el.setAttribute("fill", style === 'solid' ? this.options.stroke : "none");
665
+ }
666
+
667
+ el.setAttribute("stroke", this.options.stroke);
668
+ el.setAttribute("stroke-width", this.options.strokeWidth);
669
+ el.setAttribute("stroke-linejoin", "round");
670
+ el.classList.add("arrow-head");
671
+ this._arrowHeadEl = el;
672
+ this.group.appendChild(el);
673
+ }
674
+
675
+ _updateArrowHead() {
676
+ if (!this._arrowHeadEl) return;
677
+ const style = this.arrowHeadStyle;
678
+ if (style === 'default' || !style) return;
679
+
680
+ const elbowX = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
681
+ const angle = this._getArrowAngle(elbowX);
682
+ const pts = this._getArrowHeadPoints(angle);
683
+ const tip = this.endPoint;
684
+
685
+ if (style === 'square') {
686
+ const size = this.arrowHeadLength * 0.7;
687
+ const perpX = -Math.sin(angle), perpY = Math.cos(angle);
688
+ const backX = -Math.cos(angle), backY = -Math.sin(angle);
689
+ const p1x = tip.x + perpX * size / 2, p1y = tip.y + perpY * size / 2;
690
+ const p2x = tip.x - perpX * size / 2, p2y = tip.y - perpY * size / 2;
691
+ const p3x = p2x + backX * size, p3y = p2y + backY * size;
692
+ const p4x = p1x + backX * size, p4y = p1y + backY * size;
693
+ this._arrowHeadEl.setAttribute("points", `${p1x},${p1y} ${p2x},${p2y} ${p3x},${p3y} ${p4x},${p4y}`);
694
+ } else {
695
+ this._arrowHeadEl.setAttribute("points", `${pts.x3},${pts.y3} ${tip.x},${tip.y} ${pts.x4},${pts.y4}`);
696
+ }
697
+ }
698
+
699
+ updateSelectionControls() {
700
+ if (!this.anchors || this.anchors.length === 0) return;
701
+
702
+ const anchorSize = 5 / currentZoom;
703
+
704
+ let anchorPositions = [this.startPoint, this.endPoint];
705
+
706
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
707
+ const midOnCurve = this.getCubicBezierPoint(0.5);
708
+ anchorPositions.push(midOnCurve);
709
+ } else if (this.arrowCurved === "elbow") {
710
+ const elbowXVal = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
711
+ const midY = (this.startPoint.y + this.endPoint.y) / 2;
712
+ anchorPositions.push({ x: elbowXVal, y: midY });
713
+ } else {
714
+ // straight — offset end anchor past arrowhead
715
+ const arrowAngle = Math.atan2(this.endPoint.y - this.startPoint.y, this.endPoint.x - this.startPoint.x);
716
+ const arrowHeadClearance = this.arrowHeadLength + anchorSize - 10;
717
+ anchorPositions[1] = {
718
+ x: this.endPoint.x + arrowHeadClearance * Math.cos(arrowAngle),
719
+ y: this.endPoint.y + arrowHeadClearance * Math.sin(arrowAngle)
720
+ };
721
+ }
722
+
723
+ anchorPositions.forEach((point, index) => {
724
+ if (this.anchors[index]) {
725
+ this.anchors[index].setAttribute("cx", point.x);
726
+ this.anchors[index].setAttribute("cy", point.y);
727
+ this.anchors[index].setAttribute("r", anchorSize);
728
+ }
729
+ });
730
+ }
731
+
732
+ addAnchors() {
733
+ const anchorSize = 5 / currentZoom;
734
+ const anchorStrokeWidth = 2 / currentZoom;
735
+
736
+ let anchorPositions = [this.startPoint, this.endPoint];
737
+
738
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
739
+ // Show a single on-curve anchor at t=0.5 for intuitive dragging
740
+ const midOnCurve = this.getCubicBezierPoint(0.5);
741
+ anchorPositions.push(midOnCurve);
742
+ } else if (this.arrowCurved === "elbow") {
743
+ const elbowXVal = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
744
+ const midY = (this.startPoint.y + this.endPoint.y) / 2;
745
+ anchorPositions.push({ x: elbowXVal, y: midY });
746
+ } else {
747
+ // straight — offset end anchor past arrowhead
748
+ const arrowAngle = Math.atan2(this.endPoint.y - this.startPoint.y, this.endPoint.x - this.startPoint.x);
749
+ const arrowHeadClearance = this.arrowHeadLength + anchorSize - 10;
750
+ anchorPositions[1] = {
751
+ x: this.endPoint.x + arrowHeadClearance * Math.cos(arrowAngle),
752
+ y: this.endPoint.y + arrowHeadClearance * Math.sin(arrowAngle)
753
+ };
754
+ }
755
+
756
+ // Show sidebar
757
+ disableAllSideBars();
758
+ arrowSideBar.classList.remove("hidden");
759
+ if (window.__showSidebarForShape) window.__showSidebarForShape('arrow');
760
+ this.updateSidebar();
761
+
762
+ anchorPositions.forEach((point, index) => {
763
+ const anchor = document.createElementNS("http://www.w3.org/2000/svg", "circle");
764
+ anchor.setAttribute("cx", point.x);
765
+ anchor.setAttribute("cy", point.y);
766
+ anchor.setAttribute("r", anchorSize);
767
+
768
+ if (this.arrowCurved && index >= 2) {
769
+ anchor.setAttribute("fill", "#121212");
770
+ anchor.setAttribute("stroke", "#5B57D1");
771
+ } else {
772
+ anchor.setAttribute("fill", "#121212");
773
+ anchor.setAttribute("stroke", "#5B57D1");
774
+ }
775
+
776
+ anchor.setAttribute("stroke-width", anchorStrokeWidth);
777
+ anchor.setAttribute("vector-effect", "non-scaling-stroke");
778
+ anchor.setAttribute("class", "anchor arrow-anchor");
779
+ anchor.setAttribute("data-index", index);
780
+ anchor.style.cursor = "grab";
781
+ anchor.style.pointerEvents = "all";
782
+ anchor.addEventListener('pointerdown', (e) => this.startAnchorDrag(e, index));
783
+
784
+ this.group.appendChild(anchor);
785
+ this.anchors[index] = anchor;
786
+ });
787
+ }
788
+
789
+ addAttachmentIndicators() {
790
+ if (this.attachedToStart) {
791
+ const attachPoint = this.calculateAttachedPoint(this.attachedToStart);
792
+ const indicator = document.createElementNS("http://www.w3.org/2000/svg", "circle");
793
+ indicator.setAttribute("cx", attachPoint.x);
794
+ indicator.setAttribute("cy", attachPoint.y);
795
+ indicator.setAttribute("r", 4);
796
+ indicator.setAttribute("fill", "#5B57D1");
797
+ indicator.setAttribute("stroke", "#121212");
798
+ indicator.setAttribute("stroke-width", 1);
799
+ indicator.setAttribute("class", "attachment-indicator");
800
+ this.group.appendChild(indicator);
801
+ }
802
+
803
+ if (this.attachedToEnd) {
804
+ const attachPoint = this.calculateAttachedPoint(this.attachedToEnd);
805
+ const indicator = document.createElementNS("http://www.w3.org/2000/svg", "circle");
806
+ indicator.setAttribute("cx", attachPoint.x);
807
+ indicator.setAttribute("cy", attachPoint.y);
808
+ indicator.setAttribute("r", 4);
809
+ indicator.setAttribute("fill", "#5B57D1");
810
+ indicator.setAttribute("stroke", "#121212");
811
+ indicator.setAttribute("stroke-width", 1);
812
+ indicator.setAttribute("class", "attachment-indicator");
813
+ this.group.appendChild(indicator);
814
+ }
815
+ }
816
+
817
+ getAttachmentState() {
818
+ return {
819
+ attachedToStart: this.attachedToStart ? {
820
+ shapeId: this.attachedToStart.shape.shapeID,
821
+ side: this.attachedToStart.side,
822
+ offset: { ...this.attachedToStart.offset }
823
+ } : null,
824
+ attachedToEnd: this.attachedToEnd ? {
825
+ shapeId: this.attachedToEnd.shape.shapeID,
826
+ side: this.attachedToEnd.side,
827
+ offset: { ...this.attachedToEnd.offset }
828
+ } : null
829
+ };
830
+ }
831
+
832
+ restoreAttachmentState(attachmentState) {
833
+ this.attachedToStart = null;
834
+ this.attachedToEnd = null;
835
+
836
+ if (attachmentState.attachedToStart) {
837
+ const shape = shapes.find(s => s.shapeID === attachmentState.attachedToStart.shapeId);
838
+ if (shape) {
839
+ this.attachedToStart = {
840
+ shape: shape,
841
+ side: attachmentState.attachedToStart.side,
842
+ offset: { ...attachmentState.attachedToStart.offset }
843
+ };
844
+ this.startPoint = this.calculateAttachedPoint(this.attachedToStart);
845
+ }
846
+ }
847
+
848
+ if (attachmentState.attachedToEnd) {
849
+ const shape = shapes.find(s => s.shapeID === attachmentState.attachedToEnd.shapeId);
850
+ if (shape) {
851
+ this.attachedToEnd = {
852
+ shape: shape,
853
+ side: attachmentState.attachedToEnd.side,
854
+ offset: { ...attachmentState.attachedToEnd.offset }
855
+ };
856
+ this.endPoint = this.calculateAttachedPoint(this.attachedToEnd);
857
+ }
858
+ }
859
+
860
+ if (this.arrowCurved === "curved") {
861
+ this.initializeCurveControlPoints();
862
+ }
863
+
864
+ this.draw();
865
+ }
866
+
867
+ static getEllipsePerimeterPoint(circle, angle) {
868
+ // Calculate point on ellipse perimeter at given angle
869
+ const cosAngle = Math.cos(angle);
870
+ const sinAngle = Math.sin(angle);
871
+
872
+ const a = circle.rx;
873
+ const b = circle.ry;
874
+
875
+ const t = Math.atan2(a * sinAngle, b * cosAngle);
876
+
877
+ return {
878
+ x: circle.x + a * Math.cos(t),
879
+ y: circle.y + b * Math.sin(t)
880
+ };
881
+ }
882
+
883
+
884
+ static findNearbyShape(point, tolerance = 20) {
885
+ for (let shape of shapes) {
886
+ // Can't attach to other arrows or lines
887
+ if (shape.shapeName === 'arrow' || shape.shapeName === 'line') continue;
888
+
889
+ let attachment = null;
890
+
891
+ switch (shape.shapeName) {
892
+ case 'rectangle':
893
+ attachment = Arrow.getRectangleAttachmentPoint(point, shape, tolerance);
894
+ break;
895
+ case 'circle':
896
+ attachment = Arrow.getCircleAttachmentPoint(point, shape, tolerance);
897
+ break;
898
+ case 'frame':
899
+ attachment = Arrow.getFrameAttachmentPoint(point, shape, tolerance);
900
+ break;
901
+ case 'text':
902
+ case 'code':
903
+ // Text and code shapes wrap a group element
904
+ if (shape.group) {
905
+ attachment = Arrow.getTextAttachmentPoint(point, shape.group, tolerance);
906
+ }
907
+ break;
908
+ case 'image':
909
+ // Image shapes wrap an element
910
+ if (shape.element) {
911
+ attachment = Arrow.getImageAttachmentPoint(point, shape.element, tolerance);
912
+ }
913
+ break;
914
+ case 'icon':
915
+ if (shape.element) {
916
+ attachment = Arrow.getIconAttachmentPoint(point, shape.element, tolerance);
917
+ }
918
+ break;
919
+ case 'freehandStroke':
920
+ attachment = Arrow.getBoundingBoxAttachmentPoint(point, shape, tolerance);
921
+ break;
922
+ case 'line':
923
+ attachment = Arrow.getLineAttachmentPoint(point, shape, tolerance);
924
+ break;
925
+ }
926
+
927
+ if (attachment) {
928
+ return { shape, attachment };
929
+ }
930
+ }
931
+ return null;
932
+ }
933
+
934
+ static getBoundingBoxAttachmentPoint(point, shape, tolerance = 20) {
935
+ const bounds = shape.boundingBox || { x: shape.x, y: shape.y, width: shape.width, height: shape.height };
936
+ if (!bounds || bounds.width === 0 || bounds.height === 0) return null;
937
+
938
+ const sides = [
939
+ { name: 'top', start: { x: bounds.x, y: bounds.y }, end: { x: bounds.x + bounds.width, y: bounds.y } },
940
+ { name: 'right', start: { x: bounds.x + bounds.width, y: bounds.y }, end: { x: bounds.x + bounds.width, y: bounds.y + bounds.height } },
941
+ { name: 'bottom', start: { x: bounds.x + bounds.width, y: bounds.y + bounds.height }, end: { x: bounds.x, y: bounds.y + bounds.height } },
942
+ { name: 'left', start: { x: bounds.x, y: bounds.y + bounds.height }, end: { x: bounds.x, y: bounds.y } }
943
+ ];
944
+
945
+ let closestSide = null;
946
+ let minDistance = tolerance;
947
+ let attachPoint = null;
948
+
949
+ sides.forEach(side => {
950
+ const distance = Arrow.pointToLineSegmentDistance(point, side.start, side.end);
951
+ if (distance < minDistance) {
952
+ minDistance = distance;
953
+ closestSide = side.name;
954
+ attachPoint = Arrow.closestPointOnLineSegment(point, side.start, side.end);
955
+ }
956
+ });
957
+
958
+ if (closestSide && attachPoint) {
959
+ const offset = {
960
+ x: attachPoint.x - bounds.x,
961
+ y: attachPoint.y - bounds.y,
962
+ side: closestSide
963
+ };
964
+ return { side: closestSide, point: attachPoint, offset };
965
+ }
966
+ return null;
967
+ }
968
+
969
+ static getLineAttachmentPoint(point, line, tolerance = 20) {
970
+ const start = line.startPoint;
971
+ const end = line.endPoint;
972
+ if (!start || !end) return null;
973
+
974
+ const distance = Arrow.pointToLineSegmentDistance(point, start, end);
975
+ if (distance >= tolerance) return null;
976
+
977
+ const attachPoint = Arrow.closestPointOnLineSegment(point, start, end);
978
+ // Store as a parametric t value along the line
979
+ const dx = end.x - start.x;
980
+ const dy = end.y - start.y;
981
+ const lenSq = dx * dx + dy * dy;
982
+ const t = lenSq > 0 ? ((attachPoint.x - start.x) * dx + (attachPoint.y - start.y) * dy) / lenSq : 0;
983
+
984
+ return {
985
+ side: 'line',
986
+ point: attachPoint,
987
+ offset: { t: Math.max(0, Math.min(1, t)), side: 'line' }
988
+ };
989
+ }
990
+
991
+ static getIconAttachmentPoint(point, iconElement, tolerance = 20) {
992
+ // Check if it's an SVG icon element (group)
993
+ if (!iconElement || (iconElement.tagName !== 'g' && (!iconElement.getAttribute || iconElement.getAttribute('type') !== 'icon'))) {
994
+ console.warn('Invalid icon element for attachment:', iconElement);
995
+ return null;
996
+ }
997
+
998
+ // Get icon position and dimensions from data attributes
999
+ const iconX = parseFloat(iconElement.getAttribute('data-shape-x') || iconElement.getAttribute('x'));
1000
+ const iconY = parseFloat(iconElement.getAttribute('data-shape-y') || iconElement.getAttribute('y'));
1001
+ const iconWidth = parseFloat(iconElement.getAttribute('data-shape-width') || iconElement.getAttribute('width'));
1002
+ const iconHeight = parseFloat(iconElement.getAttribute('data-shape-height') || iconElement.getAttribute('height'));
1003
+
1004
+ // Get rotation from data attribute or transform attribute
1005
+ let rotation = 0;
1006
+ const dataRotation = iconElement.getAttribute('data-shape-rotation');
1007
+ if (dataRotation) {
1008
+ rotation = parseFloat(dataRotation) * Math.PI / 180; // Convert to radians
1009
+ } else {
1010
+ const transform = iconElement.getAttribute('transform');
1011
+ if (transform) {
1012
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
1013
+ if (rotateMatch) {
1014
+ rotation = parseFloat(rotateMatch[1]) * Math.PI / 180; // Convert to radians
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ const centerX = iconX + iconWidth / 2;
1020
+ const centerY = iconY + iconHeight / 2;
1021
+
1022
+ // Transform the bounding box corners to world coordinates
1023
+ const corners = [
1024
+ { x: iconX, y: iconY }, // top-left
1025
+ { x: iconX + iconWidth, y: iconY }, // top-right
1026
+ { x: iconX + iconWidth, y: iconY + iconHeight }, // bottom-right
1027
+ { x: iconX, y: iconY + iconHeight } // bottom-left
1028
+ ];
1029
+
1030
+ // Apply rotation to corners
1031
+ const transformedCorners = corners.map(corner => {
1032
+ if (rotation === 0) return corner;
1033
+
1034
+ const dx = corner.x - centerX;
1035
+ const dy = corner.y - centerY;
1036
+
1037
+ return {
1038
+ x: centerX + dx * Math.cos(rotation) - dy * Math.sin(rotation),
1039
+ y: centerY + dx * Math.sin(rotation) + dy * Math.cos(rotation)
1040
+ };
1041
+ });
1042
+
1043
+ // Calculate sides of the transformed rectangle
1044
+ const sides = [
1045
+ { name: 'top', start: transformedCorners[0], end: transformedCorners[1] },
1046
+ { name: 'right', start: transformedCorners[1], end: transformedCorners[2] },
1047
+ { name: 'bottom', start: transformedCorners[2], end: transformedCorners[3] },
1048
+ { name: 'left', start: transformedCorners[3], end: transformedCorners[0] }
1049
+ ];
1050
+
1051
+ let closestSide = null;
1052
+ let minDistance = tolerance;
1053
+ let attachPoint = null;
1054
+
1055
+ sides.forEach(side => {
1056
+ const distance = Arrow.pointToLineSegmentDistance(point, side.start, side.end);
1057
+ if (distance < minDistance) {
1058
+ minDistance = distance;
1059
+ closestSide = side.name;
1060
+
1061
+ // Calculate the closest point on the line segment
1062
+ attachPoint = Arrow.closestPointOnLineSegment(point, side.start, side.end);
1063
+ }
1064
+ });
1065
+
1066
+ if (closestSide && attachPoint) {
1067
+ // Calculate offset relative to the original icon rectangle
1068
+ // Transform the attach point back to local coordinates
1069
+ let localPoint = attachPoint;
1070
+ if (rotation !== 0) {
1071
+ const dx = attachPoint.x - centerX;
1072
+ const dy = attachPoint.y - centerY;
1073
+
1074
+ localPoint = {
1075
+ x: centerX + dx * Math.cos(-rotation) - dy * Math.sin(-rotation),
1076
+ y: centerY + dx * Math.sin(-rotation) + dy * Math.cos(-rotation)
1077
+ };
1078
+ }
1079
+
1080
+ // Calculate offset relative to the icon rectangle
1081
+ const offset = {
1082
+ x: localPoint.x - iconX,
1083
+ y: localPoint.y - iconY,
1084
+ side: closestSide
1085
+ };
1086
+
1087
+ return { side: closestSide, point: attachPoint, offset };
1088
+ }
1089
+
1090
+ return null;
1091
+ }
1092
+
1093
+
1094
+ static getImageAttachmentPoint(point, imageElement, tolerance = 20) {
1095
+ // Check if it's an SVG image element
1096
+ if (!imageElement || (imageElement.tagName !== 'image' && (!imageElement.getAttribute || imageElement.getAttribute('type') !== 'image'))) {
1097
+ console.warn('Invalid image element for attachment:', imageElement);
1098
+ return null;
1099
+ }
1100
+
1101
+ // Get image position and dimensions from data attributes
1102
+ const imgX = parseFloat(imageElement.getAttribute('data-shape-x') || imageElement.getAttribute('x'));
1103
+ const imgY = parseFloat(imageElement.getAttribute('data-shape-y') || imageElement.getAttribute('y'));
1104
+ const imgWidth = parseFloat(imageElement.getAttribute('data-shape-width') || imageElement.getAttribute('width'));
1105
+ const imgHeight = parseFloat(imageElement.getAttribute('data-shape-height') || imageElement.getAttribute('height'));
1106
+
1107
+ // Get rotation from data attribute or transform attribute
1108
+ let rotation = 0;
1109
+ const dataRotation = imageElement.getAttribute('data-shape-rotation');
1110
+ if (dataRotation) {
1111
+ rotation = parseFloat(dataRotation) * Math.PI / 180; // Convert to radians
1112
+ } else {
1113
+ const transform = imageElement.getAttribute('transform');
1114
+ if (transform) {
1115
+ const rotateMatch = transform.match(/rotate\(([^,]+)/);
1116
+ if (rotateMatch) {
1117
+ rotation = parseFloat(rotateMatch[1]) * Math.PI / 180; // Convert to radians
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ const centerX = imgX + imgWidth / 2;
1123
+ const centerY = imgY + imgHeight / 2;
1124
+
1125
+ // Transform the bounding box corners to world coordinates
1126
+ const corners = [
1127
+ { x: imgX, y: imgY }, // top-left
1128
+ { x: imgX + imgWidth, y: imgY }, // top-right
1129
+ { x: imgX + imgWidth, y: imgY + imgHeight }, // bottom-right
1130
+ { x: imgX, y: imgY + imgHeight } // bottom-left
1131
+ ];
1132
+
1133
+ // Apply rotation to corners
1134
+ const transformedCorners = corners.map(corner => {
1135
+ if (rotation === 0) return corner;
1136
+
1137
+ const dx = corner.x - centerX;
1138
+ const dy = corner.y - centerY;
1139
+
1140
+ return {
1141
+ x: centerX + dx * Math.cos(rotation) - dy * Math.sin(rotation),
1142
+ y: centerY + dx * Math.sin(rotation) + dy * Math.cos(rotation)
1143
+ };
1144
+ });
1145
+
1146
+ // Calculate sides of the transformed rectangle
1147
+ const sides = [
1148
+ { name: 'top', start: transformedCorners[0], end: transformedCorners[1] },
1149
+ { name: 'right', start: transformedCorners[1], end: transformedCorners[2] },
1150
+ { name: 'bottom', start: transformedCorners[2], end: transformedCorners[3] },
1151
+ { name: 'left', start: transformedCorners[3], end: transformedCorners[0] }
1152
+ ];
1153
+
1154
+ let closestSide = null;
1155
+ let minDistance = tolerance;
1156
+ let attachPoint = null;
1157
+
1158
+ sides.forEach(side => {
1159
+ const distance = Arrow.pointToLineSegmentDistance(point, side.start, side.end);
1160
+ if (distance < minDistance) {
1161
+ minDistance = distance;
1162
+ closestSide = side.name;
1163
+
1164
+ // Calculate the closest point on the line segment
1165
+ attachPoint = Arrow.closestPointOnLineSegment(point, side.start, side.end);
1166
+ }
1167
+ });
1168
+
1169
+ if (closestSide && attachPoint) {
1170
+ // Calculate offset relative to the original image rectangle
1171
+ // Transform the attach point back to local coordinates
1172
+ let localPoint = attachPoint;
1173
+ if (rotation !== 0) {
1174
+ const dx = attachPoint.x - centerX;
1175
+ const dy = attachPoint.y - centerY;
1176
+
1177
+ localPoint = {
1178
+ x: centerX + dx * Math.cos(-rotation) - dy * Math.sin(-rotation),
1179
+ y: centerY + dx * Math.sin(-rotation) + dy * Math.cos(-rotation)
1180
+ };
1181
+ }
1182
+
1183
+ // Calculate offset relative to the image rectangle
1184
+ const offset = {
1185
+ x: localPoint.x - imgX,
1186
+ y: localPoint.y - imgY,
1187
+ side: closestSide
1188
+ };
1189
+
1190
+ return { side: closestSide, point: attachPoint, offset };
1191
+ }
1192
+
1193
+ return null;
1194
+ }
1195
+
1196
+ static getFrameAttachmentPoint(point, frame, tolerance = 20) {
1197
+ const rect = {
1198
+ left: frame.x,
1199
+ right: frame.x + frame.width,
1200
+ top: frame.y,
1201
+ bottom: frame.y + frame.height
1202
+ };
1203
+
1204
+ const distances = {
1205
+ top: Math.abs(point.y - rect.top),
1206
+ bottom: Math.abs(point.y - rect.bottom),
1207
+ left: Math.abs(point.x - rect.left),
1208
+ right: Math.abs(point.x - rect.right)
1209
+ };
1210
+
1211
+ let closestSide = null;
1212
+ let minDistance = tolerance;
1213
+
1214
+ for (let side in distances) {
1215
+ if (distances[side] < minDistance) {
1216
+ if ((side === 'top' || side === 'bottom') &&
1217
+ point.x >= rect.left - tolerance && point.x <= rect.right + tolerance) {
1218
+ closestSide = side;
1219
+ minDistance = distances[side];
1220
+ } else if ((side === 'left' || side === 'right') &&
1221
+ point.y >= rect.top - tolerance && point.y <= rect.bottom + tolerance) {
1222
+ closestSide = side;
1223
+ minDistance = distances[side];
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ if (closestSide) {
1229
+ let attachPoint, offset;
1230
+
1231
+ switch (closestSide) {
1232
+ case 'top':
1233
+ attachPoint = { x: Math.max(rect.left, Math.min(rect.right, point.x)), y: rect.top };
1234
+ offset = { x: attachPoint.x - frame.x, y: 0 };
1235
+ break;
1236
+ case 'bottom':
1237
+ attachPoint = { x: Math.max(rect.left, Math.min(rect.right, point.x)), y: rect.bottom };
1238
+ offset = { x: attachPoint.x - frame.x, y: frame.height };
1239
+ break;
1240
+ case 'left':
1241
+ attachPoint = { x: rect.left, y: Math.max(rect.top, Math.min(rect.bottom, point.y)) };
1242
+ offset = { x: 0, y: attachPoint.y - frame.y };
1243
+ break;
1244
+ case 'right':
1245
+ attachPoint = { x: rect.right, y: Math.max(rect.top, Math.min(rect.bottom, point.y)) };
1246
+ offset = { x: frame.width, y: attachPoint.y - frame.y };
1247
+ break;
1248
+ }
1249
+
1250
+ return { side: closestSide, point: attachPoint, offset };
1251
+ }
1252
+
1253
+ return null;
1254
+ }
1255
+
1256
+ static getRectangleAttachmentPoint(point, rectangle, tolerance = 20) {
1257
+ const rect = {
1258
+ left: rectangle.x,
1259
+ right: rectangle.x + rectangle.width,
1260
+ top: rectangle.y,
1261
+ bottom: rectangle.y + rectangle.height
1262
+ };
1263
+
1264
+ const distances = {
1265
+ top: Math.abs(point.y - rect.top),
1266
+ bottom: Math.abs(point.y - rect.bottom),
1267
+ left: Math.abs(point.x - rect.left),
1268
+ right: Math.abs(point.x - rect.right)
1269
+ };
1270
+
1271
+ let closestSide = null;
1272
+ let minDistance = tolerance;
1273
+
1274
+ for (let side in distances) {
1275
+ if (distances[side] < minDistance) {
1276
+ if ((side === 'top' || side === 'bottom') &&
1277
+ point.x >= rect.left - tolerance && point.x <= rect.right + tolerance) {
1278
+ closestSide = side;
1279
+ minDistance = distances[side];
1280
+ } else if ((side === 'left' || side === 'right') &&
1281
+ point.y >= rect.top - tolerance && point.y <= rect.bottom + tolerance) {
1282
+ closestSide = side;
1283
+ minDistance = distances[side];
1284
+ }
1285
+ }
1286
+ }
1287
+
1288
+ if (closestSide) {
1289
+ let attachPoint, offset;
1290
+
1291
+ switch (closestSide) {
1292
+ case 'top':
1293
+ attachPoint = { x: Math.max(rect.left, Math.min(rect.right, point.x)), y: rect.top };
1294
+ offset = { x: attachPoint.x - rectangle.x, y: 0 };
1295
+ break;
1296
+ case 'bottom':
1297
+ attachPoint = { x: Math.max(rect.left, Math.min(rect.right, point.x)), y: rect.bottom };
1298
+ offset = { x: attachPoint.x - rectangle.x, y: rectangle.height };
1299
+ break;
1300
+ case 'left':
1301
+ attachPoint = { x: rect.left, y: Math.max(rect.top, Math.min(rect.bottom, point.y)) };
1302
+ offset = { x: 0, y: attachPoint.y - rectangle.y };
1303
+ break;
1304
+ case 'right':
1305
+ attachPoint = { x: rect.right, y: Math.max(rect.top, Math.min(rect.bottom, point.y)) };
1306
+ offset = { x: rectangle.width, y: attachPoint.y - rectangle.y };
1307
+ break;
1308
+ }
1309
+
1310
+ return { side: closestSide, point: attachPoint, offset };
1311
+ }
1312
+
1313
+ return null;
1314
+ }
1315
+
1316
+ static getCircleAttachmentPoint(point, circle, tolerance = 20) {
1317
+ // Calculate distance from point to circle center
1318
+ const dx = point.x - circle.x;
1319
+ const dy = point.y - circle.y;
1320
+ const distanceToCenter = Math.sqrt(dx * dx + dy * dy);
1321
+
1322
+ const averageRadius = (circle.rx + circle.ry) / 2;
1323
+ const distanceToPerimeter = Math.abs(distanceToCenter - averageRadius);
1324
+
1325
+ if (distanceToPerimeter <= tolerance) {
1326
+
1327
+ const angle = Math.atan2(dy, dx);
1328
+
1329
+
1330
+ const attachPoint = this.getEllipsePerimeterPoint(circle, angle);
1331
+
1332
+
1333
+ const offset = {
1334
+ angle: angle,
1335
+ radiusRatioX: (attachPoint.x - circle.x) / circle.rx,
1336
+ radiusRatioY: (attachPoint.y - circle.y) / circle.ry
1337
+ };
1338
+
1339
+ return {
1340
+ side: 'perimeter',
1341
+ point: attachPoint,
1342
+ offset: offset
1343
+ };
1344
+ }
1345
+
1346
+ return null;
1347
+ }
1348
+
1349
+ static getTextAttachmentPoint(point, textGroup, tolerance = 20) {
1350
+ if (!textGroup) return null;
1351
+ // Accept groups with type='text' or type='code'
1352
+ const groupType = textGroup.getAttribute ? textGroup.getAttribute('type') : textGroup.type;
1353
+ if (groupType !== 'text' && groupType !== 'code') return null;
1354
+
1355
+ const textElement = textGroup.querySelector('text');
1356
+ if (!textElement) return null;
1357
+
1358
+ // Get the text bounding box
1359
+ const bbox = textElement.getBBox();
1360
+
1361
+ // Get the group's transform
1362
+ const groupTransform = textGroup.transform.baseVal.consolidate();
1363
+ const matrix = groupTransform ? groupTransform.matrix : { e: 0, f: 0, a: 1, b: 0, c: 0, d: 1 };
1364
+
1365
+ // Transform the bounding box corners to world coordinates
1366
+ const corners = [
1367
+ { x: bbox.x, y: bbox.y }, // top-left
1368
+ { x: bbox.x + bbox.width, y: bbox.y }, // top-right
1369
+ { x: bbox.x + bbox.width, y: bbox.y + bbox.height }, // bottom-right
1370
+ { x: bbox.x, y: bbox.y + bbox.height } // bottom-left
1371
+ ];
1372
+
1373
+ // Transform corners using the group's transform matrix
1374
+ const transformedCorners = corners.map(corner => ({
1375
+ x: corner.x * matrix.a + corner.y * matrix.c + matrix.e,
1376
+ y: corner.x * matrix.b + corner.y * matrix.d + matrix.f
1377
+ }));
1378
+
1379
+ // Calculate sides of the transformed rectangle
1380
+ const sides = [
1381
+ { name: 'top', start: transformedCorners[0], end: transformedCorners[1] },
1382
+ { name: 'right', start: transformedCorners[1], end: transformedCorners[2] },
1383
+ { name: 'bottom', start: transformedCorners[2], end: transformedCorners[3] },
1384
+ { name: 'left', start: transformedCorners[3], end: transformedCorners[0] }
1385
+ ];
1386
+
1387
+ let closestSide = null;
1388
+ let minDistance = tolerance;
1389
+ let attachPoint = null;
1390
+
1391
+ sides.forEach(side => {
1392
+ const distance = Arrow.pointToLineSegmentDistance(point, side.start, side.end);
1393
+ if (distance < minDistance) {
1394
+ minDistance = distance;
1395
+ closestSide = side.name;
1396
+
1397
+ // Calculate the closest point on the line segment
1398
+ attachPoint = Arrow.closestPointOnLineSegment(point, side.start, side.end);
1399
+ }
1400
+ });
1401
+
1402
+ if (closestSide && attachPoint) {
1403
+ // Calculate offset relative to the original bounding box
1404
+ // Transform the attach point back to local coordinates
1405
+ const det = matrix.a * matrix.d - matrix.b * matrix.c;
1406
+ if (det === 0) return null;
1407
+
1408
+ const invMatrix = {
1409
+ a: matrix.d / det,
1410
+ b: -matrix.b / det,
1411
+ c: -matrix.c / det,
1412
+ d: matrix.a / det,
1413
+ e: (matrix.c * matrix.f - matrix.d * matrix.e) / det,
1414
+ f: (matrix.b * matrix.e - matrix.a * matrix.f) / det
1415
+ };
1416
+
1417
+ const localPoint = {
1418
+ x: attachPoint.x * invMatrix.a + attachPoint.y * invMatrix.c + invMatrix.e,
1419
+ y: attachPoint.x * invMatrix.b + attachPoint.y * invMatrix.d + invMatrix.f
1420
+ };
1421
+
1422
+ // Calculate offset relative to the bounding box
1423
+ const offset = {
1424
+ x: localPoint.x - bbox.x,
1425
+ y: localPoint.y - bbox.y,
1426
+ side: closestSide
1427
+ };
1428
+
1429
+ return { side: closestSide, point: attachPoint, offset };
1430
+ }
1431
+
1432
+ return null;
1433
+ }
1434
+
1435
+ static pointToLineSegmentDistance(point, lineStart, lineEnd) {
1436
+ const A = point.x - lineStart.x;
1437
+ const B = point.y - lineStart.y;
1438
+ const C = lineEnd.x - lineStart.x;
1439
+ const D = lineEnd.y - lineStart.y;
1440
+
1441
+ const dot = A * C + B * D;
1442
+ const lenSq = C * C + D * D;
1443
+
1444
+ if (lenSq === 0) {
1445
+ // Line segment is a point
1446
+ return Math.sqrt(A * A + B * B);
1447
+ }
1448
+
1449
+ let param = dot / lenSq;
1450
+ param = Math.max(0, Math.min(1, param)); // Clamp to [0,1]
1451
+
1452
+ const xx = lineStart.x + param * C;
1453
+ const yy = lineStart.y + param * D;
1454
+
1455
+ const dx = point.x - xx;
1456
+ const dy = point.y - yy;
1457
+ return Math.sqrt(dx * dx + dy * dy);
1458
+ }
1459
+
1460
+ static closestPointOnLineSegment(point, lineStart, lineEnd) {
1461
+ const A = point.x - lineStart.x;
1462
+ const B = point.y - lineStart.y;
1463
+ const C = lineEnd.x - lineStart.x;
1464
+ const D = lineEnd.y - lineStart.y;
1465
+
1466
+ const dot = A * C + B * D;
1467
+ const lenSq = C * C + D * D;
1468
+
1469
+ if (lenSq === 0) {
1470
+ return { x: lineStart.x, y: lineStart.y };
1471
+ }
1472
+
1473
+ let param = dot / lenSq;
1474
+ param = Math.max(0, Math.min(1, param));
1475
+
1476
+ return {
1477
+ x: lineStart.x + param * C,
1478
+ y: lineStart.y + param * D
1479
+ };
1480
+ }
1481
+
1482
+ calculateAttachedPoint(attachment) {
1483
+ const shape = attachment.shape;
1484
+ const side = attachment.side;
1485
+ const offset = attachment.offset;
1486
+
1487
+ if (shape.shapeName === 'rectangle') {
1488
+ return Arrow._calcRectAttachedPoint(shape.x, shape.y, shape.width, shape.height, shape.rotation, side, offset);
1489
+ }
1490
+
1491
+ if (shape.shapeName === 'circle') {
1492
+ if (side === 'perimeter') {
1493
+ return Arrow.getEllipsePerimeterPoint(shape, offset.angle);
1494
+ }
1495
+ }
1496
+
1497
+ if (shape.shapeName === 'text' || shape.shapeName === 'code') {
1498
+ // Text/code shapes use their group's transform
1499
+ const groupEl = shape.group;
1500
+ if (!groupEl) return { x: shape.x || 0, y: shape.y || 0 };
1501
+ const textElement = groupEl.querySelector('text') || groupEl.querySelector('foreignObject');
1502
+ if (!textElement) return { x: shape.x || 0, y: shape.y || 0 };
1503
+
1504
+ const bbox = textElement.getBBox();
1505
+ const groupTransform = groupEl.transform.baseVal.consolidate();
1506
+ const matrix = groupTransform ? groupTransform.matrix : { e: 0, f: 0, a: 1, b: 0, c: 0, d: 1 };
1507
+
1508
+ let localPoint = { x: bbox.x + offset.x, y: bbox.y + offset.y };
1509
+ return {
1510
+ x: localPoint.x * matrix.a + localPoint.y * matrix.c + matrix.e,
1511
+ y: localPoint.x * matrix.b + localPoint.y * matrix.d + matrix.f
1512
+ };
1513
+ }
1514
+
1515
+ if (shape.shapeName === 'image') {
1516
+ return Arrow._calcRectAttachedPoint(shape.x, shape.y, shape.width, shape.height, shape.rotation, side, offset);
1517
+ }
1518
+
1519
+ if (shape.shapeName === 'icon') {
1520
+ return Arrow._calcRectAttachedPoint(shape.x, shape.y, shape.width, shape.height, shape.rotation, side, offset);
1521
+ }
1522
+
1523
+ if (shape.shapeName === 'frame') {
1524
+ return Arrow._calcRectAttachedPoint(shape.x, shape.y, shape.width, shape.height, 0, side, offset);
1525
+ }
1526
+
1527
+ if (shape.shapeName === 'freehandStroke') {
1528
+ const bounds = shape.boundingBox || { x: shape.x, y: shape.y, width: shape.width, height: shape.height };
1529
+ return Arrow._calcRectAttachedPoint(bounds.x, bounds.y, bounds.width, bounds.height, 0, side, offset);
1530
+ }
1531
+
1532
+ if (shape.shapeName === 'line') {
1533
+ if (side === 'line' && offset.t !== undefined) {
1534
+ const t = offset.t;
1535
+ return {
1536
+ x: shape.startPoint.x + t * (shape.endPoint.x - shape.startPoint.x),
1537
+ y: shape.startPoint.y + t * (shape.endPoint.y - shape.startPoint.y)
1538
+ };
1539
+ }
1540
+ }
1541
+
1542
+ return { x: shape.x || 0, y: shape.y || 0 };
1543
+ }
1544
+
1545
+ static _calcRectAttachedPoint(rx, ry, rw, rh, rotation, side, offset) {
1546
+ let localPoint;
1547
+ switch (side) {
1548
+ case 'top':
1549
+ localPoint = { x: rx + offset.x, y: ry };
1550
+ break;
1551
+ case 'bottom':
1552
+ localPoint = { x: rx + offset.x, y: ry + rh };
1553
+ break;
1554
+ case 'left':
1555
+ localPoint = { x: rx, y: ry + offset.y };
1556
+ break;
1557
+ case 'right':
1558
+ localPoint = { x: rx + rw, y: ry + offset.y };
1559
+ break;
1560
+ default:
1561
+ localPoint = { x: rx + offset.x, y: ry + offset.y };
1562
+ }
1563
+
1564
+ if (rotation) {
1565
+ const rad = rotation * Math.PI / 180;
1566
+ const cx = rx + rw / 2;
1567
+ const cy = ry + rh / 2;
1568
+ const dx = localPoint.x - cx;
1569
+ const dy = localPoint.y - cy;
1570
+ return {
1571
+ x: cx + dx * Math.cos(rad) - dy * Math.sin(rad),
1572
+ y: cy + dx * Math.sin(rad) + dy * Math.cos(rad)
1573
+ };
1574
+ }
1575
+ return localPoint;
1576
+ }
1577
+
1578
+ detachFromShape(isStartPoint) {
1579
+ if (isStartPoint) {
1580
+ this.attachedToStart = null;
1581
+ } else {
1582
+ this.attachedToEnd = null;
1583
+ }
1584
+ }
1585
+
1586
+ updateAttachments() {
1587
+ let needsRedraw = false;
1588
+
1589
+ if (this.attachedToStart && this.attachedToStart.shape) {
1590
+ const newPoint = this.calculateAttachedPoint(this.attachedToStart);
1591
+ if (newPoint.x !== this.startPoint.x || newPoint.y !== this.startPoint.y) {
1592
+ this.startPoint = newPoint;
1593
+ needsRedraw = true;
1594
+ }
1595
+ }
1596
+
1597
+ if (this.attachedToEnd && this.attachedToEnd.shape) {
1598
+ const newPoint = this.calculateAttachedPoint(this.attachedToEnd);
1599
+ if (newPoint.x !== this.endPoint.x || newPoint.y !== this.endPoint.y) {
1600
+ this.endPoint = newPoint;
1601
+ needsRedraw = true;
1602
+ }
1603
+ }
1604
+
1605
+ if (needsRedraw) {
1606
+ if (this.arrowCurved) {
1607
+ this.initializeCurveControlPoints();
1608
+ }
1609
+ this.draw();
1610
+ }
1611
+ }
1612
+
1613
+ move(dx, dy) {
1614
+ if (!this.attachedToStart) {
1615
+ this.startPoint.x += dx;
1616
+ this.startPoint.y += dy;
1617
+ }
1618
+ if (!this.attachedToEnd) {
1619
+ this.endPoint.x += dx;
1620
+ this.endPoint.y += dy;
1621
+ }
1622
+
1623
+ if (this.controlPoint1 && (!this.attachedToStart && !this.attachedToEnd)) {
1624
+ this.controlPoint1.x += dx;
1625
+ this.controlPoint1.y += dy;
1626
+ }
1627
+ if (this.controlPoint2 && (!this.attachedToStart && !this.attachedToEnd)) {
1628
+ this.controlPoint2.x += dx;
1629
+ this.controlPoint2.y += dy;
1630
+ }
1631
+
1632
+ // Lightweight update — rebuild the path element without full draw/anchor rebuild
1633
+ this._updatePathElement();
1634
+ this._updateHitArea();
1635
+ this._updateLabelElement();
1636
+
1637
+ // Only update frame containment if we're actively dragging the shape itself
1638
+ // and not being moved by a parent frame
1639
+ if (isDragging && !this.isBeingMovedByFrame) {
1640
+ this.updateFrameContainment();
1641
+ }
1642
+ }
1643
+
1644
+ updateFrameContainment() {
1645
+ // Don't update if we're being moved by a frame
1646
+ if (this.isBeingMovedByFrame) return;
1647
+
1648
+ let targetFrame = null;
1649
+
1650
+ // Find which frame this shape is over
1651
+ shapes.forEach(shape => {
1652
+ if (shape.shapeName === 'frame' && shape.isShapeInFrame(this)) {
1653
+ targetFrame = shape;
1654
+ }
1655
+ });
1656
+
1657
+ // If we have a parent frame and we're being dragged, temporarily remove clipping
1658
+ if (this.parentFrame && isDragging) {
1659
+ this.parentFrame.temporarilyRemoveFromFrame(this);
1660
+ }
1661
+
1662
+ // Update frame highlighting
1663
+ if (hoveredFrameArrow && hoveredFrameArrow !== targetFrame) {
1664
+ hoveredFrameArrow.removeHighlight();
1665
+ }
1666
+
1667
+ if (targetFrame && targetFrame !== hoveredFrameArrow) {
1668
+ targetFrame.highlightFrame();
1669
+ }
1670
+
1671
+ hoveredFrameArrow = targetFrame;
1672
+ }
1673
+
1674
+ isNearAnchor(x, y) {
1675
+ const anchorSize = 10 / currentZoom;
1676
+
1677
+ for (let i = 0; i < this.anchors.length; i++) {
1678
+ const anchor = this.anchors[i];
1679
+ if (anchor) {
1680
+ const anchorX = parseFloat(anchor.getAttribute('cx'));
1681
+ const anchorY = parseFloat(anchor.getAttribute('cy'));
1682
+ const distance = Math.sqrt(Math.pow(x - anchorX, 2) + Math.pow(y - anchorY, 2));
1683
+ if (distance <= anchorSize) {
1684
+ return { type: 'anchor', index: i };
1685
+ }
1686
+ }
1687
+ }
1688
+
1689
+ return null;
1690
+ }
1691
+
1692
+ startAnchorDrag(e, index) {
1693
+ e.stopPropagation();
1694
+ e.preventDefault();
1695
+
1696
+ // Store initial state including attachments
1697
+ dragOldPosArrow = {
1698
+ startPoint: { x: this.startPoint.x, y: this.startPoint.y },
1699
+ endPoint: { x: this.endPoint.x, y: this.endPoint.y },
1700
+ controlPoint1: this.controlPoint1 ? { x: this.controlPoint1.x, y: this.controlPoint1.y } : null,
1701
+ controlPoint2: this.controlPoint2 ? { x: this.controlPoint2.x, y: this.controlPoint2.y } : null,
1702
+ attachments: this.getAttachmentState()
1703
+ };
1704
+
1705
+ const onPointerMove = (event) => {
1706
+ const { x, y } = getSVGCoordsFromMouse(event);
1707
+
1708
+ // Check for potential attachment when dragging start or end anchors
1709
+ if (index === 0 || index === 1) {
1710
+ const nearbyShape = Arrow.findNearbyShape({ x, y });
1711
+ if (nearbyShape) {
1712
+ // Show preview while dragging
1713
+ const existingPreview = svg.querySelector('.attachment-preview');
1714
+ if (existingPreview) existingPreview.remove();
1715
+
1716
+ const preview = document.createElementNS("http://www.w3.org/2000/svg", "circle");
1717
+ preview.setAttribute("cx", nearbyShape.attachment.point.x);
1718
+ preview.setAttribute("cy", nearbyShape.attachment.point.y);
1719
+ preview.setAttribute("r", 6);
1720
+ preview.setAttribute("fill", "none");
1721
+ preview.setAttribute("stroke", "#5B57D1");
1722
+ preview.setAttribute("stroke-width", 2);
1723
+ preview.setAttribute("class", "attachment-preview");
1724
+ preview.setAttribute("opacity", "0.7");
1725
+ svg.appendChild(preview);
1726
+
1727
+ // Snap to attachment point
1728
+ this.updatePosition(index, nearbyShape.attachment.point.x, nearbyShape.attachment.point.y);
1729
+ } else {
1730
+
1731
+ const existingPreview = svg.querySelector('.attachment-preview');
1732
+ if (existingPreview) existingPreview.remove();
1733
+
1734
+ this.updatePosition(index, x, y);
1735
+ }
1736
+ } else {
1737
+ this.updatePosition(index, x, y);
1738
+ }
1739
+ };
1740
+
1741
+ const onPointerUp = () => {
1742
+
1743
+ const existingPreview = svg.querySelector('.attachment-preview');
1744
+ if (existingPreview) existingPreview.remove();
1745
+
1746
+ if (index === 0) {
1747
+ // Check for start point attachment
1748
+ const startAttachment = Arrow.findNearbyShape(this.startPoint);
1749
+ if (startAttachment) {
1750
+
1751
+ if (this.attachedToStart && this.attachedToStart.shape !== startAttachment.shape) {
1752
+ this.detachFromShape(true);
1753
+ }
1754
+ this.attachToShape(true, startAttachment.shape, startAttachment.attachment);
1755
+ } else {
1756
+ // Detach if moved away from shape
1757
+ if (this.attachedToStart) {
1758
+ this.detachFromShape(true);
1759
+ }
1760
+ }
1761
+ } else if (index === 1) {
1762
+ // Check for end point attachment
1763
+ const endAttachment = Arrow.findNearbyShape(this.endPoint);
1764
+ if (endAttachment) {
1765
+ // Detach if previously attached to different shape
1766
+ if (this.attachedToEnd && this.attachedToEnd.shape !== endAttachment.shape) {
1767
+ this.detachFromShape(false);
1768
+ }
1769
+ this.attachToShape(false, endAttachment.shape, endAttachment.attachment);
1770
+ } else {
1771
+ // Detach if moved away from shape
1772
+ if (this.attachedToEnd) {
1773
+ this.detachFromShape(false);
1774
+ }
1775
+ }
1776
+ }
1777
+
1778
+ if (dragOldPosArrow) {
1779
+ const newPos = {
1780
+ startPoint: { x: this.startPoint.x, y: this.startPoint.y },
1781
+ endPoint: { x: this.endPoint.x, y: this.endPoint.y },
1782
+ controlPoint1: this.controlPoint1 ? { x: this.controlPoint1.x, y: this.controlPoint1.y } : null,
1783
+ controlPoint2: this.controlPoint2 ? { x: this.controlPoint2.x, y: this.controlPoint2.y } : null,
1784
+ attachments: this.getAttachmentState()
1785
+ };
1786
+
1787
+ // Check if anything actually changed (position or attachments)
1788
+ const stateChanged =
1789
+ dragOldPosArrow.startPoint.x !== newPos.startPoint.x ||
1790
+ dragOldPosArrow.startPoint.y !== newPos.startPoint.y ||
1791
+ dragOldPosArrow.endPoint.x !== newPos.endPoint.x ||
1792
+ dragOldPosArrow.endPoint.y !== newPos.endPoint.y ||
1793
+ JSON.stringify(dragOldPosArrow.attachments) !== JSON.stringify(newPos.attachments);
1794
+
1795
+ if (stateChanged) {
1796
+ pushTransformAction(this, dragOldPosArrow, newPos);
1797
+ }
1798
+ dragOldPosArrow = null;
1799
+ }
1800
+
1801
+ svg.removeEventListener('pointermove', onPointerMove);
1802
+ svg.removeEventListener('pointerup', onPointerUp);
1803
+ };
1804
+
1805
+ svg.addEventListener('pointermove', onPointerMove);
1806
+ svg.addEventListener('pointerup', onPointerUp);
1807
+ }
1808
+
1809
+ updatePosition(anchorIndex, newViewBoxX, newViewBoxY) {
1810
+ if (anchorIndex === 0) {
1811
+ this.startPoint.x = newViewBoxX;
1812
+ this.startPoint.y = newViewBoxY;
1813
+ } else if (anchorIndex === 1) {
1814
+ this.endPoint.x = newViewBoxX;
1815
+ this.endPoint.y = newViewBoxY;
1816
+ } else if (anchorIndex === 2 && this.arrowCurved === "elbow") {
1817
+ this.elbowX = newViewBoxX;
1818
+ } else if (anchorIndex === 2 && this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
1819
+ // On-curve midpoint anchor dragged — inversely compute control points
1820
+ // B(0.5) = 0.125*P0 + 0.375*CP1 + 0.375*CP2 + 0.125*P3
1821
+ // Keep curve symmetric: offset both control points equally from the line
1822
+ const dx = this.endPoint.x - this.startPoint.x;
1823
+ const dy = this.endPoint.y - this.startPoint.y;
1824
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
1825
+ const perpX = -dy / dist;
1826
+ const perpY = dx / dist;
1827
+
1828
+ // Desired midpoint offset from line midpoint
1829
+ const lineMidX = (this.startPoint.x + this.endPoint.x) / 2;
1830
+ const lineMidY = (this.startPoint.y + this.endPoint.y) / 2;
1831
+ const offsetX = newViewBoxX - lineMidX;
1832
+ const offsetY = newViewBoxY - lineMidY;
1833
+ // Project offset onto perpendicular to get curve amount
1834
+ const curveAmount = offsetX * perpX + offsetY * perpY;
1835
+
1836
+ // Recompute control points with this curve amount
1837
+ const t1 = 0.33, t2 = 0.67;
1838
+ this.controlPoint1 = {
1839
+ x: this.startPoint.x + t1 * dx + perpX * curveAmount * (4 / 3),
1840
+ y: this.startPoint.y + t1 * dy + perpY * curveAmount * (4 / 3)
1841
+ };
1842
+ this.controlPoint2 = {
1843
+ x: this.startPoint.x + t2 * dx + perpX * curveAmount * (4 / 3),
1844
+ y: this.startPoint.y + t2 * dy + perpY * curveAmount * (4 / 3)
1845
+ };
1846
+ }
1847
+ this.draw();
1848
+ }
1849
+
1850
+ contains(viewBoxX, viewBoxY) {
1851
+ const tolerance = Math.max(5, this.options.strokeWidth * 2) / currentZoom;
1852
+
1853
+ if (this.arrowCurved === "curved" && this.controlPoint1 && this.controlPoint2) {
1854
+ return this.pointToCubicBezierDistance(viewBoxX, viewBoxY) <= tolerance;
1855
+ } else if (this.arrowCurved === "elbow") {
1856
+ const ex = this.elbowX !== null ? this.elbowX : (this.startPoint.x + this.endPoint.x) / 2;
1857
+ const d1 = this.pointToLineDistance(viewBoxX, viewBoxY, this.startPoint.x, this.startPoint.y, ex, this.startPoint.y);
1858
+ const d2 = this.pointToLineDistance(viewBoxX, viewBoxY, ex, this.startPoint.y, ex, this.endPoint.y);
1859
+ const d3 = this.pointToLineDistance(viewBoxX, viewBoxY, ex, this.endPoint.y, this.endPoint.x, this.endPoint.y);
1860
+ return Math.min(d1, d2, d3) <= tolerance;
1861
+ } else {
1862
+ return this.pointToLineDistance(viewBoxX, viewBoxY, this.startPoint.x, this.startPoint.y, this.endPoint.x, this.endPoint.y) <= tolerance;
1863
+ }
1864
+ }
1865
+
1866
+ pointToCubicBezierDistance(x, y) {
1867
+ let minDistance = Infinity;
1868
+ const steps = 100;
1869
+
1870
+ for (let i = 0; i <= steps; i++) {
1871
+ const t = i / steps;
1872
+ const point = this.getCubicBezierPoint(t);
1873
+ const distance = Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2));
1874
+ minDistance = Math.min(minDistance, distance);
1875
+ }
1876
+
1877
+ return minDistance;
1878
+ }
1879
+
1880
+ pointToLineDistance(x, y, x1, y1, x2, y2) {
1881
+ const A = x - x1;
1882
+ const B = y - y1;
1883
+ const C = x2 - x1;
1884
+ const D = y2 - y1;
1885
+
1886
+ const dot = A * C + B * D;
1887
+ const lenSq = C * C + D * D;
1888
+ let param = -1;
1889
+
1890
+ if (lenSq !== 0) {
1891
+ param = dot / lenSq;
1892
+ }
1893
+
1894
+ let xx, yy;
1895
+
1896
+ if (param < 0) {
1897
+ xx = x1;
1898
+ yy = y1;
1899
+ } else if (param > 1) {
1900
+ xx = x2;
1901
+ yy = y2;
1902
+ } else {
1903
+ xx = x1 + param * C;
1904
+ yy = y1 + param * D;
1905
+ }
1906
+
1907
+ const dx = x - xx;
1908
+ const dy = y - yy;
1909
+ return Math.sqrt(dx * dx + dy * dy);
1910
+ }
1911
+
1912
+ updateStyle(newOptions) {
1913
+ if (newOptions.arrowOutlineStyle !== undefined) {
1914
+ this.arrowOutlineStyle = newOptions.arrowOutlineStyle;
1915
+ const style = this.arrowOutlineStyle;
1916
+ this.options.strokeDasharray = style === "dashed" ? "10,10" : (style === "dotted" ? "2,8" : "");
1917
+ }
1918
+ if (newOptions.arrowHeadStyle !== undefined) {
1919
+ this.arrowHeadStyle = newOptions.arrowHeadStyle;
1920
+ }
1921
+ if (newOptions.arrowCurved !== undefined) {
1922
+ const wasCurved = this.arrowCurved;
1923
+ this.arrowCurved = newOptions.arrowCurved;
1924
+
1925
+ if (this.arrowCurved === "curved" && wasCurved !== "curved") {
1926
+ this.initializeCurveControlPoints();
1927
+ } else if (this.arrowCurved !== "curved") {
1928
+ this.controlPoint1 = null;
1929
+ this.controlPoint2 = null;
1930
+ }
1931
+ if (this.arrowCurved !== "elbow") {
1932
+ this.elbowX = null;
1933
+ }
1934
+ }
1935
+ if (newOptions.elbowX !== undefined) {
1936
+ this.elbowX = newOptions.elbowX;
1937
+ }
1938
+ if (newOptions.stroke !== undefined) {
1939
+ this.options.stroke = newOptions.stroke;
1940
+ }
1941
+ if (newOptions.strokeWidth !== undefined) {
1942
+ this.options.strokeWidth = parseFloat(newOptions.strokeWidth);
1943
+ }
1944
+ if (newOptions.arrowCurveAmount !== undefined) {
1945
+ this.arrowCurveAmount = newOptions.arrowCurveAmount;
1946
+ if (this.arrowCurved) {
1947
+ this.initializeCurveControlPoints();
1948
+ }
1949
+ }
1950
+
1951
+ Object.keys(newOptions).forEach(key => newOptions[key] === undefined && delete newOptions[key]);
1952
+ this.options = { ...this.options, ...newOptions };
1953
+
1954
+ if (this.arrowOutlineStyle === 'solid' && this.options.strokeDasharray) {
1955
+ delete this.options.strokeDasharray;
1956
+ }
1957
+
1958
+ this.draw();
1959
+ }
1960
+
1961
+ updateSidebar() {
1962
+ // No-op: React sidebar handles UI updates via Zustand store
1963
+ }
1964
+
1965
+ destroy() {
1966
+ if (this.group && this.group.parentNode) {
1967
+ this.group.parentNode.removeChild(this.group);
1968
+ }
1969
+ const index = shapes.indexOf(this);
1970
+ if (index > -1) {
1971
+ shapes.splice(index, 1);
1972
+ }
1973
+ if (currentShape === this) {
1974
+ currentShape = null;
1975
+ }
1976
+ }
1977
+ }
1978
+
1979
+ export { Arrow };