@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,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 = ' ';
|
|
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 };
|