living-documentation 7.42.0 → 7.44.0
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/dist/src/frontend/diagram/custom-shapes.js +104 -0
- package/dist/src/frontend/diagram/main.js +10 -1
- package/dist/src/frontend/diagram/network.js +1042 -531
- package/dist/src/frontend/diagram/node-panel.js +47 -0
- package/dist/src/frontend/diagram/node-rendering.js +190 -0
- package/dist/src/frontend/diagram/persistence.js +2 -0
- package/dist/src/frontend/diagram/ports.js +49 -14
- package/dist/src/frontend/diagram/selection-overlay.js +64 -13
- package/dist/src/frontend/diagram/state.js +2 -0
- package/dist/src/frontend/diagram.html +99 -0
- package/dist/src/frontend/i18n/en.json +15 -1
- package/dist/src/frontend/i18n/fr.json +15 -1
- package/dist/src/frontend/shape-editor.html +685 -0
- package/dist/src/routes/shape-libraries.d.ts +3 -0
- package/dist/src/routes/shape-libraries.d.ts.map +1 -0
- package/dist/src/routes/shape-libraries.js +116 -0
- package/dist/src/routes/shape-libraries.js.map +1 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +3 -0
- package/dist/src/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,37 +2,62 @@
|
|
|
2
2
|
// Creates the vis.js Network, patches _drawNodes for canonical z-order,
|
|
3
3
|
// and wires all network-level events.
|
|
4
4
|
|
|
5
|
-
import { st, markDirty } from
|
|
6
|
-
import { pushSnapshot }
|
|
7
|
-
import { visNodeProps, SHAPE_DEFAULTS }
|
|
8
|
-
import { uploadImageFile } from
|
|
9
|
-
import { promptImageName } from
|
|
10
|
-
import { showToast }
|
|
11
|
-
import { t }
|
|
12
|
-
import { visEdgeProps }
|
|
13
|
-
import { showNodePanel, hideNodePanel } from
|
|
14
|
-
import { showEdgePanel, hideEdgePanel } from
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
5
|
+
import { st, markDirty } from "./state.js";
|
|
6
|
+
import { pushSnapshot } from "./history.js";
|
|
7
|
+
import { visNodeProps, SHAPE_DEFAULTS } from "./node-rendering.js";
|
|
8
|
+
import { uploadImageFile } from "./image-upload.js";
|
|
9
|
+
import { promptImageName } from "./image-name-modal.js";
|
|
10
|
+
import { showToast } from "./toast.js";
|
|
11
|
+
import { t } from "./t.js";
|
|
12
|
+
import { visEdgeProps } from "./edge-rendering.js";
|
|
13
|
+
import { showNodePanel, hideNodePanel } from "./node-panel.js";
|
|
14
|
+
import { showEdgePanel, hideEdgePanel } from "./edge-panel.js";
|
|
15
|
+
import {
|
|
16
|
+
startLabelEdit,
|
|
17
|
+
startEdgeLabelEdit,
|
|
18
|
+
commitLabelEdit,
|
|
19
|
+
hideLabelInput,
|
|
20
|
+
} from "./label-editor.js";
|
|
21
|
+
import {
|
|
22
|
+
updateSelectionOverlay,
|
|
23
|
+
hideSelectionOverlay,
|
|
24
|
+
} from "./selection-overlay.js";
|
|
25
|
+
import { drawGrid, onDragEnd, snapToGrid } from "./grid.js";
|
|
26
|
+
import {
|
|
27
|
+
onDragging,
|
|
28
|
+
drawAlignmentGuides,
|
|
29
|
+
clearAlignGuides,
|
|
30
|
+
} from "./alignment.js";
|
|
31
|
+
import { drawDebugOverlay } from "./debug.js";
|
|
32
|
+
import { updateZoomDisplay } from "./zoom.js";
|
|
33
|
+
import { expandSelectionToGroup, drawGroupOutlines } from "./groups.js";
|
|
34
|
+
import { navigateNodeLink, hideLinkPanel } from "./link-panel.js";
|
|
35
|
+
import {
|
|
36
|
+
getNearestPort,
|
|
37
|
+
getPortPosition,
|
|
38
|
+
drawPortDots,
|
|
39
|
+
drawPortEdge,
|
|
40
|
+
distanceToPortEdge,
|
|
41
|
+
wrapText,
|
|
42
|
+
} from "./ports.js";
|
|
43
|
+
import { getLastFreeArrowStyle } from "./edge-panel.js";
|
|
44
|
+
import { getLastNodeStyle } from "./node-panel.js";
|
|
45
|
+
import { installUnlockHold } from "./unlock-hold.js";
|
|
46
|
+
import {
|
|
47
|
+
CUSTOM_SHAPE_TYPE,
|
|
48
|
+
customShapeIdFromTool,
|
|
49
|
+
getCustomShapeDefaultSize,
|
|
50
|
+
getCustomShapeDefinition,
|
|
51
|
+
} from "./custom-shapes.js";
|
|
27
52
|
|
|
28
53
|
// Module-level port-hover state — shared between initNetwork event handlers and
|
|
29
54
|
// module-level helpers (_onAnchorSnapConnect).
|
|
30
55
|
let _hoveredPortNodeId = null;
|
|
31
|
-
let _hoveredPortKey
|
|
56
|
+
let _hoveredPortKey = null;
|
|
32
57
|
let _draggingAnchorIds = new Set();
|
|
33
58
|
// Rehook state: tracks which port is hovered while a port edge is selected.
|
|
34
|
-
let _rehookEdgeId
|
|
35
|
-
let _rehookHoveredNodeId
|
|
59
|
+
let _rehookEdgeId = null;
|
|
60
|
+
let _rehookHoveredNodeId = null;
|
|
36
61
|
let _rehookHoveredPortKey = null;
|
|
37
62
|
let _pointerDownSelection = { nodeIds: [], edgeIds: [] };
|
|
38
63
|
let _edgeLabelPointerAbort = null;
|
|
@@ -42,99 +67,171 @@ let _edgeLabelPointerAbort = null;
|
|
|
42
67
|
function isRehookable(edgeData) {
|
|
43
68
|
if (!edgeData) return false;
|
|
44
69
|
const fromNode = st.nodes.get(edgeData.from);
|
|
45
|
-
const toNode
|
|
46
|
-
return
|
|
70
|
+
const toNode = st.nodes.get(edgeData.to);
|
|
71
|
+
return (
|
|
72
|
+
!!(fromNode && fromNode.shapeType !== "anchor") ||
|
|
73
|
+
!!(toNode && toNode.shapeType !== "anchor")
|
|
74
|
+
);
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
50
|
-
const container = document.getElementById(
|
|
78
|
+
const container = document.getElementById("vis-canvas");
|
|
51
79
|
|
|
52
|
-
const edgeSmooth = edgesStraight
|
|
80
|
+
const edgeSmooth = edgesStraight
|
|
81
|
+
? { enabled: false }
|
|
82
|
+
: { type: "continuous" };
|
|
53
83
|
|
|
54
84
|
st.nodes = new vis.DataSet(
|
|
55
85
|
savedNodes.map((n) => {
|
|
56
|
-
const shapeType = n.shapeType || n.renderAs ||
|
|
86
|
+
const shapeType = n.shapeType || n.renderAs || "box";
|
|
57
87
|
return {
|
|
58
88
|
...n,
|
|
59
89
|
shapeType,
|
|
60
|
-
...visNodeProps(
|
|
90
|
+
...visNodeProps(
|
|
91
|
+
shapeType,
|
|
92
|
+
n.colorKey || "c-gray",
|
|
93
|
+
n.nodeWidth,
|
|
94
|
+
n.nodeHeight,
|
|
95
|
+
n.fontSize,
|
|
96
|
+
n.textAlign,
|
|
97
|
+
n.textValign,
|
|
98
|
+
),
|
|
61
99
|
...(n.locked ? { fixed: { x: true, y: true }, draggable: false } : {}),
|
|
62
100
|
};
|
|
63
|
-
})
|
|
101
|
+
}),
|
|
64
102
|
);
|
|
65
103
|
st.edges = new vis.DataSet(
|
|
66
104
|
savedEdges.map((e) => {
|
|
67
105
|
const toNode = savedNodes.find((n) => n.id === e.to);
|
|
68
|
-
const isAnchor = toNode && toNode.shapeType ===
|
|
106
|
+
const isAnchor = toNode && toNode.shapeType === "anchor";
|
|
69
107
|
const edgeObj = {
|
|
70
108
|
...e,
|
|
71
|
-
...visEdgeProps(e.arrowDir ??
|
|
109
|
+
...visEdgeProps(e.arrowDir ?? "to", e.dashes ?? false),
|
|
72
110
|
smooth: isAnchor ? { enabled: false } : edgeSmooth,
|
|
73
|
-
...(e.edgeColor
|
|
111
|
+
...(e.edgeColor
|
|
112
|
+
? {
|
|
113
|
+
color: {
|
|
114
|
+
color: e.edgeColor,
|
|
115
|
+
highlight: "#f97316",
|
|
116
|
+
hover: "#f97316",
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
74
120
|
...(e.edgeWidth ? { width: e.edgeWidth } : {}),
|
|
75
121
|
// Edge labels are always drawn by drawEdgeLabels() in afterDrawing
|
|
76
122
|
// (gives us positioning + rotation control). Hide vis-network's native
|
|
77
123
|
// label text entirely so it never appears alongside ours.
|
|
78
124
|
...(e.label
|
|
79
|
-
? {
|
|
125
|
+
? {
|
|
126
|
+
font: {
|
|
127
|
+
size: e.fontSize || 11,
|
|
128
|
+
align: "middle",
|
|
129
|
+
color: "rgba(0,0,0,0)",
|
|
130
|
+
},
|
|
131
|
+
}
|
|
80
132
|
: e.fontSize
|
|
81
|
-
? { font: { size: e.fontSize,
|
|
82
|
-
: {}
|
|
83
|
-
),
|
|
133
|
+
? { font: { size: e.fontSize, align: "middle", color: "#6b7280" } }
|
|
134
|
+
: {}),
|
|
84
135
|
};
|
|
85
136
|
// Port edges: hide vis-network's own rendering (line + arrowhead).
|
|
86
137
|
// drawPortEdge() handles all visual output; vis-network edge is a
|
|
87
138
|
// transparent ghost kept only for hit-detection (click selection).
|
|
88
139
|
if (e.fromPort || e.toPort) {
|
|
89
|
-
edgeObj.color
|
|
140
|
+
edgeObj.color = {
|
|
141
|
+
color: "rgba(0,0,0,0)",
|
|
142
|
+
highlight: "rgba(0,0,0,0)",
|
|
143
|
+
hover: "rgba(0,0,0,0)",
|
|
144
|
+
};
|
|
90
145
|
edgeObj.arrows = { to: { enabled: false }, from: { enabled: false } };
|
|
91
146
|
}
|
|
92
147
|
return edgeObj;
|
|
93
|
-
})
|
|
148
|
+
}),
|
|
94
149
|
);
|
|
95
150
|
|
|
96
151
|
const options = {
|
|
97
152
|
physics: { enabled: false },
|
|
98
|
-
interaction: {
|
|
99
|
-
|
|
100
|
-
|
|
153
|
+
interaction: {
|
|
154
|
+
hover: true,
|
|
155
|
+
navigationButtons: false,
|
|
156
|
+
keyboard: false,
|
|
157
|
+
multiselect: true,
|
|
158
|
+
},
|
|
159
|
+
nodes: {
|
|
160
|
+
font: { size: 13, face: "system-ui,-apple-system,sans-serif" },
|
|
161
|
+
borderWidth: 1.5,
|
|
162
|
+
borderWidthSelected: 2.5,
|
|
163
|
+
shadow: false,
|
|
164
|
+
widthConstraint: { minimum: 60 },
|
|
165
|
+
heightConstraint: { minimum: 28 },
|
|
166
|
+
},
|
|
167
|
+
edges: {
|
|
168
|
+
smooth: edgeSmooth,
|
|
169
|
+
color: { color: "#a8a29e", highlight: "#f97316", hover: "#f97316" },
|
|
170
|
+
width: 1.5,
|
|
171
|
+
selectionWidth: 2.5,
|
|
172
|
+
font: {
|
|
173
|
+
size: 11,
|
|
174
|
+
align: "middle",
|
|
175
|
+
color: "rgba(0,0,0,0)",
|
|
176
|
+
strokeColor: "rgba(0,0,0,0)",
|
|
177
|
+
background: "rgba(0,0,0,0)",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
101
180
|
manipulation: {
|
|
102
181
|
enabled: false,
|
|
103
182
|
addEdge(data, callback) {
|
|
104
183
|
// Block edges from/to locked nodes or anchors (free-arrow endpoints).
|
|
105
|
-
// Also block when the
|
|
106
|
-
//
|
|
184
|
+
// Also block when the source sits on top of a lower-z target that
|
|
185
|
+
// contains it: that target is acting as a background surface, and the
|
|
186
|
+
// mouseup handler turns the gesture into a free-arrow endpoint instead.
|
|
107
187
|
const fromNode = st.nodes.get(data.from) || {};
|
|
108
|
-
const toNode
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
188
|
+
const toNode = st.nodes.get(data.to) || {};
|
|
189
|
+
const targetContainsSource = lowerZTargetContainsSource(
|
|
190
|
+
data.from,
|
|
191
|
+
data.to,
|
|
192
|
+
);
|
|
193
|
+
if (
|
|
194
|
+
fromNode.locked ||
|
|
195
|
+
toNode.locked ||
|
|
196
|
+
fromNode.shapeType === "anchor" ||
|
|
197
|
+
toNode.shapeType === "anchor" ||
|
|
198
|
+
targetContainsSource
|
|
199
|
+
) {
|
|
113
200
|
_addEdgeFromPort = null;
|
|
114
|
-
setTimeout(() => {
|
|
201
|
+
setTimeout(() => {
|
|
202
|
+
if (st.currentTool === "addEdge") st.network.addEdgeMode();
|
|
203
|
+
}, 0);
|
|
115
204
|
return;
|
|
116
205
|
}
|
|
117
206
|
pushSnapshot();
|
|
118
|
-
data.id
|
|
119
|
-
data.arrowDir =
|
|
120
|
-
data.dashes
|
|
207
|
+
data.id = "e" + Date.now();
|
|
208
|
+
data.arrowDir = "to";
|
|
209
|
+
data.dashes = false;
|
|
121
210
|
// Attach captured port selections — skip for anchor nodes.
|
|
122
|
-
const fromIsAnchor = fromNode.shapeType ===
|
|
123
|
-
const toIsAnchor
|
|
211
|
+
const fromIsAnchor = fromNode.shapeType === "anchor";
|
|
212
|
+
const toIsAnchor = toNode.shapeType === "anchor";
|
|
124
213
|
if (!fromIsAnchor) data.fromPort = _addEdgeFromPort || null;
|
|
125
|
-
if (!toIsAnchor)
|
|
126
|
-
Object.assign(data, visEdgeProps(
|
|
214
|
+
if (!toIsAnchor) data.toPort = _hoveredPortKey || null;
|
|
215
|
+
Object.assign(data, visEdgeProps("to", false));
|
|
127
216
|
// Port edges: make vis-network's ghost transparent so only drawPortEdge is visible.
|
|
128
217
|
if (data.fromPort || data.toPort) {
|
|
129
|
-
data.color
|
|
218
|
+
data.color = {
|
|
219
|
+
color: "rgba(0,0,0,0)",
|
|
220
|
+
highlight: "rgba(0,0,0,0)",
|
|
221
|
+
hover: "rgba(0,0,0,0)",
|
|
222
|
+
};
|
|
130
223
|
data.arrows = { to: { enabled: false }, from: { enabled: false } };
|
|
131
224
|
}
|
|
132
225
|
callback(data);
|
|
133
226
|
markDirty();
|
|
134
227
|
_addEdgeFromPort = null;
|
|
135
228
|
setTimeout(() => {
|
|
136
|
-
if (st.currentTool ===
|
|
137
|
-
window.dispatchEvent(
|
|
229
|
+
if (st.currentTool === "addEdge") {
|
|
230
|
+
window.dispatchEvent(
|
|
231
|
+
new CustomEvent("diagram:setTool", {
|
|
232
|
+
detail: { tool: "select" },
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
138
235
|
}
|
|
139
236
|
}, 0);
|
|
140
237
|
},
|
|
@@ -142,19 +239,27 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
142
239
|
};
|
|
143
240
|
|
|
144
241
|
if (st.network) st.network.destroy();
|
|
145
|
-
st.network = new vis.Network(
|
|
242
|
+
st.network = new vis.Network(
|
|
243
|
+
container,
|
|
244
|
+
{ nodes: st.nodes, edges: st.edges },
|
|
245
|
+
options,
|
|
246
|
+
);
|
|
146
247
|
|
|
147
248
|
// ── Lock interception ───────────────────────────────────────────────────────
|
|
148
249
|
// Must be registered BEFORE any other capture-phase mousedown listeners on
|
|
149
250
|
// the container so it can stopImmediatePropagation for locked targets.
|
|
150
251
|
installUnlockHold(container);
|
|
151
|
-
container.addEventListener(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
252
|
+
container.addEventListener(
|
|
253
|
+
"mousedown",
|
|
254
|
+
(e) => {
|
|
255
|
+
if (e.button !== 0) return;
|
|
256
|
+
_pointerDownSelection = {
|
|
257
|
+
nodeIds: [...(st.selectedNodeIds || [])],
|
|
258
|
+
edgeIds: [...(st.selectedEdgeIds || [])],
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
{ capture: true },
|
|
262
|
+
);
|
|
158
263
|
|
|
159
264
|
// ── Z-order patch ──────────────────────────────────────────────────────────
|
|
160
265
|
// vis.js renders in 3 passes (normal → selected → hovered), which breaks
|
|
@@ -164,10 +269,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
164
269
|
st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
|
|
165
270
|
const bodyNodes = this.body.nodes;
|
|
166
271
|
const bodyEdges = this.body.edges;
|
|
167
|
-
const margin
|
|
168
|
-
const topLeft
|
|
169
|
-
const bottomRight = this.canvas.DOMtoCanvas({
|
|
170
|
-
|
|
272
|
+
const margin = 20;
|
|
273
|
+
const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
|
|
274
|
+
const bottomRight = this.canvas.DOMtoCanvas({
|
|
275
|
+
x: this.canvas.frame.canvas.clientWidth + margin,
|
|
276
|
+
y: this.canvas.frame.canvas.clientHeight + margin,
|
|
277
|
+
});
|
|
278
|
+
const viewableArea = {
|
|
279
|
+
top: topLeft.y,
|
|
280
|
+
left: topLeft.x,
|
|
281
|
+
bottom: bottomRight.y,
|
|
282
|
+
right: bottomRight.x,
|
|
283
|
+
};
|
|
171
284
|
|
|
172
285
|
// Build a map: canonical index → list of edges whose topmost endpoint is at that index.
|
|
173
286
|
const orderMap = new Map();
|
|
@@ -189,11 +302,11 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
189
302
|
// centre origin — otherwise the ghost line is visible from the node's
|
|
190
303
|
// centre instead of appearing to start at its boundary/port.
|
|
191
304
|
const fromData = st.nodes.get(edge.fromId);
|
|
192
|
-
const toData
|
|
193
|
-
const fromIsAnchor = fromData && fromData.shapeType ===
|
|
194
|
-
const toIsAnchor
|
|
305
|
+
const toData = st.nodes.get(edge.toId);
|
|
306
|
+
const fromIsAnchor = fromData && fromData.shapeType === "anchor";
|
|
307
|
+
const toIsAnchor = toData && toData.shapeType === "anchor";
|
|
195
308
|
const fromLevel = orderMap.get(edge.fromId);
|
|
196
|
-
const toLevel
|
|
309
|
+
const toLevel = orderMap.get(edge.toId);
|
|
197
310
|
let level;
|
|
198
311
|
if (fromLevel === undefined && toLevel === undefined) {
|
|
199
312
|
level = 0;
|
|
@@ -218,22 +331,24 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
218
331
|
for (const [level, edges] of edgesByLevel) {
|
|
219
332
|
for (const edge of edges) {
|
|
220
333
|
const fromData = st.nodes.get(edge.fromId);
|
|
221
|
-
const toData
|
|
222
|
-
if (toData
|
|
223
|
-
|
|
334
|
+
const toData = st.nodes.get(edge.toId);
|
|
335
|
+
if (toData && toData.shapeType === "anchor")
|
|
336
|
+
anchorEdgeLevel.set(edge.toId, level);
|
|
337
|
+
if (fromData && fromData.shapeType === "anchor")
|
|
338
|
+
anchorEdgeLevel.set(edge.fromId, level);
|
|
224
339
|
}
|
|
225
340
|
}
|
|
226
341
|
|
|
227
342
|
// Anchors with no connected edge are drawn at level 0 (bottom).
|
|
228
343
|
for (const id of st.canonicalOrder) {
|
|
229
344
|
const n = st.nodes.get(id);
|
|
230
|
-
if (!n || n.shapeType !==
|
|
345
|
+
if (!n || n.shapeType !== "anchor") continue;
|
|
231
346
|
if (!anchorEdgeLevel.has(id)) anchorEdgeLevel.set(id, 0);
|
|
232
347
|
}
|
|
233
348
|
|
|
234
349
|
for (let i = 0; i < st.canonicalOrder.length; i++) {
|
|
235
350
|
const id = st.canonicalOrder[i];
|
|
236
|
-
const n
|
|
351
|
+
const n = st.nodes.get(id);
|
|
237
352
|
|
|
238
353
|
// Draw edges whose level is i, before drawing the node at level i.
|
|
239
354
|
const edges = edgesByLevel.get(i);
|
|
@@ -254,7 +369,10 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
254
369
|
if (level !== i) continue;
|
|
255
370
|
const anchorNode = bodyNodes[anchorId];
|
|
256
371
|
if (!anchorNode) continue;
|
|
257
|
-
if (
|
|
372
|
+
if (
|
|
373
|
+
alwaysShow === true ||
|
|
374
|
+
anchorNode.isBoundingBoxOverlappingWith(viewableArea) === true
|
|
375
|
+
) {
|
|
258
376
|
anchorNode.draw(ctx);
|
|
259
377
|
} else {
|
|
260
378
|
anchorNode.updateBoundingBox(ctx, anchorNode.selected);
|
|
@@ -263,7 +381,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
263
381
|
}
|
|
264
382
|
|
|
265
383
|
// Skip anchor nodes here — they were drawn right after their edge above.
|
|
266
|
-
if (n && n.shapeType ===
|
|
384
|
+
if (n && n.shapeType === "anchor") continue;
|
|
267
385
|
|
|
268
386
|
const node = bodyNodes[id];
|
|
269
387
|
if (!node) continue;
|
|
@@ -280,84 +398,117 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
280
398
|
// We neutralise _drawEdges (make it a no-op) and instead draw each edge
|
|
281
399
|
// inside _drawNodes, just before the node whose canonical index equals the
|
|
282
400
|
// max index of its two endpoints. This guarantees true z-order interleaving.
|
|
283
|
-
st.network.renderer._drawEdges = function () {
|
|
401
|
+
st.network.renderer._drawEdges = function () {
|
|
402
|
+
/* no-op — edges drawn in _drawNodes */
|
|
403
|
+
};
|
|
284
404
|
|
|
285
405
|
// Keep canonicalOrder in sync with DataSet add/remove events
|
|
286
|
-
st.nodes.on(
|
|
406
|
+
st.nodes.on("add", (_, { items }) => {
|
|
287
407
|
const existing = new Set(st.canonicalOrder);
|
|
288
|
-
items.forEach((id) => {
|
|
408
|
+
items.forEach((id) => {
|
|
409
|
+
if (!existing.has(id)) st.canonicalOrder.push(id);
|
|
410
|
+
});
|
|
289
411
|
});
|
|
290
|
-
st.nodes.on(
|
|
412
|
+
st.nodes.on("remove", (_, { items, oldData }) => {
|
|
291
413
|
const removed = new Set(items);
|
|
292
414
|
st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
|
|
293
415
|
// If an anchor was deleted directly, also remove its connected edges.
|
|
294
416
|
(oldData || []).forEach((n) => {
|
|
295
|
-
if (n.shapeType !==
|
|
296
|
-
const connected = st.edges.get({
|
|
417
|
+
if (n.shapeType !== "anchor") return;
|
|
418
|
+
const connected = st.edges.get({
|
|
419
|
+
filter: (e) => e.from === n.id || e.to === n.id,
|
|
420
|
+
});
|
|
297
421
|
if (connected.length) st.edges.remove(connected.map((e) => e.id));
|
|
298
422
|
});
|
|
299
423
|
});
|
|
300
424
|
|
|
301
425
|
// When an edge is removed, delete any anchor node that has no remaining edges.
|
|
302
|
-
st.edges.on(
|
|
426
|
+
st.edges.on("remove", (_, { oldData }) => {
|
|
303
427
|
const anchorsToCheck = new Set();
|
|
304
428
|
(oldData || []).forEach((edge) => {
|
|
305
|
-
const toData
|
|
429
|
+
const toData = st.nodes.get(edge.to);
|
|
306
430
|
const fromData = st.nodes.get(edge.from);
|
|
307
|
-
if (toData
|
|
308
|
-
if (fromData && fromData.shapeType ===
|
|
431
|
+
if (toData && toData.shapeType === "anchor") anchorsToCheck.add(edge.to);
|
|
432
|
+
if (fromData && fromData.shapeType === "anchor")
|
|
433
|
+
anchorsToCheck.add(edge.from);
|
|
309
434
|
});
|
|
310
435
|
anchorsToCheck.forEach((anchorId) => {
|
|
311
|
-
const remaining = st.edges.get({
|
|
436
|
+
const remaining = st.edges.get({
|
|
437
|
+
filter: (e) => e.from === anchorId || e.to === anchorId,
|
|
438
|
+
});
|
|
312
439
|
if (remaining.length === 0) st.nodes.remove(anchorId);
|
|
313
440
|
});
|
|
314
441
|
});
|
|
315
442
|
|
|
316
|
-
st.network.on(
|
|
317
|
-
st.network.on(
|
|
318
|
-
st.network.on(
|
|
319
|
-
st.network.on(
|
|
320
|
-
st.network.on(
|
|
321
|
-
st.network.on(
|
|
322
|
-
st.network.on(
|
|
323
|
-
st.network.on(
|
|
324
|
-
st.network.on(
|
|
325
|
-
st.network.on(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
443
|
+
st.network.on("click", onClickNode);
|
|
444
|
+
st.network.on("doubleClick", onDoubleClick);
|
|
445
|
+
st.network.on("dragStart", onDragStart);
|
|
446
|
+
st.network.on("selectNode", onSelectNode);
|
|
447
|
+
st.network.on("deselectNode", onDeselectAll);
|
|
448
|
+
st.network.on("selectEdge", onSelectEdge);
|
|
449
|
+
st.network.on("deselectEdge", onDeselectAll);
|
|
450
|
+
st.network.on("zoom", updateZoomDisplay);
|
|
451
|
+
st.network.on("dragging", onDragging);
|
|
452
|
+
st.network.on("dragEnd", (p) => {
|
|
453
|
+
_draggingAnchorIds.clear();
|
|
454
|
+
_hoveredPortNodeId = null;
|
|
455
|
+
_hoveredPortKey = null;
|
|
456
|
+
_onAnchorSnapConnect(p);
|
|
457
|
+
onDragEnd(p);
|
|
458
|
+
clearAlignGuides();
|
|
459
|
+
});
|
|
460
|
+
st.network.on("beforeDrawing", drawGrid);
|
|
461
|
+
st.network.on("afterDrawing", updateSelectionOverlay);
|
|
462
|
+
st.network.on("afterDrawing", drawAlignmentGuides);
|
|
463
|
+
st.network.on("afterDrawing", (ctx) => drawGroupOutlines(ctx));
|
|
464
|
+
st.network.on("afterDrawing", () => drawDebugOverlay());
|
|
465
|
+
st.network.on("afterDrawing", drawEdgeLabels);
|
|
466
|
+
st.network.on("afterDrawing", (ctx) => {
|
|
467
|
+
if (
|
|
468
|
+
_hoveredPortNodeId &&
|
|
469
|
+
(st.currentTool === "addEdge" || _draggingAnchorIds.size > 0)
|
|
470
|
+
) {
|
|
334
471
|
drawPortDots(ctx, _hoveredPortNodeId, _hoveredPortKey);
|
|
335
472
|
}
|
|
336
473
|
// Rehook: show port dots on both endpoints of the selected port edge so
|
|
337
474
|
// the user can click a different port to reconnect that end of the arrow.
|
|
338
|
-
if (
|
|
475
|
+
if (
|
|
476
|
+
_rehookEdgeId &&
|
|
477
|
+
st.currentTool !== "addEdge" &&
|
|
478
|
+
_draggingAnchorIds.size === 0
|
|
479
|
+
) {
|
|
339
480
|
const edgeData = st.edges.get(_rehookEdgeId);
|
|
340
481
|
if (isRehookable(edgeData)) {
|
|
341
482
|
const fromNode = st.nodes.get(edgeData.from);
|
|
342
|
-
if (fromNode && fromNode.shapeType !==
|
|
343
|
-
drawPortDots(
|
|
483
|
+
if (fromNode && fromNode.shapeType !== "anchor") {
|
|
484
|
+
drawPortDots(
|
|
485
|
+
ctx,
|
|
486
|
+
edgeData.from,
|
|
487
|
+
_rehookHoveredNodeId === edgeData.from
|
|
488
|
+
? _rehookHoveredPortKey
|
|
489
|
+
: null,
|
|
490
|
+
);
|
|
344
491
|
}
|
|
345
492
|
const toNode = st.nodes.get(edgeData.to);
|
|
346
|
-
if (toNode && toNode.shapeType !==
|
|
347
|
-
drawPortDots(
|
|
493
|
+
if (toNode && toNode.shapeType !== "anchor") {
|
|
494
|
+
drawPortDots(
|
|
495
|
+
ctx,
|
|
496
|
+
edgeData.to,
|
|
497
|
+
_rehookHoveredNodeId === edgeData.to ? _rehookHoveredPortKey : null,
|
|
498
|
+
);
|
|
348
499
|
}
|
|
349
500
|
}
|
|
350
501
|
}
|
|
351
502
|
});
|
|
352
503
|
// Two-click free-arrow: draw an orange dot at the pending first-click origin.
|
|
353
|
-
st.network.on(
|
|
354
|
-
if (st.currentTool !==
|
|
504
|
+
st.network.on("afterDrawing", (ctx) => {
|
|
505
|
+
if (st.currentTool !== "addEdge" || !st.freeArrowFirstPoint) return;
|
|
355
506
|
const { x, y } = st.freeArrowFirstPoint;
|
|
356
507
|
ctx.beginPath();
|
|
357
508
|
ctx.arc(x, y, 6, 0, Math.PI * 2);
|
|
358
|
-
ctx.fillStyle =
|
|
509
|
+
ctx.fillStyle = "#f97316";
|
|
359
510
|
ctx.fill();
|
|
360
|
-
ctx.strokeStyle =
|
|
511
|
+
ctx.strokeStyle = "#ffffff";
|
|
361
512
|
ctx.lineWidth = 2;
|
|
362
513
|
ctx.stroke();
|
|
363
514
|
});
|
|
@@ -373,13 +524,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
373
524
|
// hit-detection — this avoids a race with vis-network's capture-phase handlers
|
|
374
525
|
// which may run first and alter selection state before our mousedown fires.
|
|
375
526
|
{
|
|
376
|
-
let _lr
|
|
377
|
-
let _hoverHandle
|
|
378
|
-
let _ld
|
|
527
|
+
let _lr = null; // active resize: { edgeId, bboxCx, bboxCy, rotation }
|
|
528
|
+
let _hoverHandle = null; // { edgeId, bboxCx, bboxCy, rotation } | null
|
|
529
|
+
let _ld = null; // active label drag: { edgeId, startMouse, startOffsetX, startOffsetY, dragging }
|
|
379
530
|
let _hoverLabelDrag = null; // { edgeId } | null
|
|
380
531
|
|
|
381
532
|
// Draw handles for the selected edge label.
|
|
382
|
-
st.network.on(
|
|
533
|
+
st.network.on("afterDrawing", (ctx) => {
|
|
383
534
|
if (!st.selectedEdgeIds || st.selectedEdgeIds.length !== 1) return;
|
|
384
535
|
const edgeId = st.selectedEdgeIds[0];
|
|
385
536
|
const e = st.edges.get(edgeId);
|
|
@@ -393,9 +544,9 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
393
544
|
for (const hx of [-bbox.w / 2, bbox.w / 2]) {
|
|
394
545
|
ctx.beginPath();
|
|
395
546
|
ctx.arc(hx, 0, 5, 0, Math.PI * 2);
|
|
396
|
-
ctx.fillStyle
|
|
397
|
-
ctx.strokeStyle =
|
|
398
|
-
ctx.lineWidth
|
|
547
|
+
ctx.fillStyle = "#f97316";
|
|
548
|
+
ctx.strokeStyle = "#ffffff";
|
|
549
|
+
ctx.lineWidth = 1.5;
|
|
399
550
|
ctx.fill();
|
|
400
551
|
ctx.stroke();
|
|
401
552
|
}
|
|
@@ -405,23 +556,36 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
405
556
|
function edgeLabelHitAt(clientX, clientY) {
|
|
406
557
|
if (!st.network || !st.edgeLabelBBox) return;
|
|
407
558
|
const rect = container.getBoundingClientRect();
|
|
408
|
-
const cp = st.network.DOMtoCanvas({
|
|
559
|
+
const cp = st.network.DOMtoCanvas({
|
|
560
|
+
x: clientX - rect.left,
|
|
561
|
+
y: clientY - rect.top,
|
|
562
|
+
});
|
|
409
563
|
const hr = 8 / st.network.getScale();
|
|
410
564
|
|
|
411
565
|
// ── Resize handle detection ──────────────────────────────────────────────
|
|
412
|
-
for (const edgeId of
|
|
566
|
+
for (const edgeId of st.selectedEdgeIds || []) {
|
|
413
567
|
const edge = st.edges && st.edges.get(edgeId);
|
|
414
568
|
if (!edge || !edge.label) continue;
|
|
415
569
|
const bbox = st.edgeLabelBBox[edgeId];
|
|
416
570
|
if (!bbox) continue;
|
|
417
571
|
|
|
418
|
-
const r
|
|
419
|
-
const dx = cp.x - bbox.cx,
|
|
572
|
+
const r = -(bbox.rotation || 0);
|
|
573
|
+
const dx = cp.x - bbox.cx,
|
|
574
|
+
dy = cp.y - bbox.cy;
|
|
420
575
|
const lx = dx * Math.cos(r) - dy * Math.sin(r);
|
|
421
576
|
const ly = dx * Math.sin(r) + dy * Math.cos(r);
|
|
422
577
|
|
|
423
|
-
if (
|
|
424
|
-
|
|
578
|
+
if (
|
|
579
|
+
Math.hypot(lx - -bbox.w / 2, ly) < hr ||
|
|
580
|
+
Math.hypot(lx - bbox.w / 2, ly) < hr
|
|
581
|
+
) {
|
|
582
|
+
return {
|
|
583
|
+
type: "handle",
|
|
584
|
+
edgeId,
|
|
585
|
+
bboxCx: bbox.cx,
|
|
586
|
+
bboxCy: bbox.cy,
|
|
587
|
+
rotation: bbox.rotation || 0,
|
|
588
|
+
};
|
|
425
589
|
}
|
|
426
590
|
}
|
|
427
591
|
|
|
@@ -432,12 +596,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
432
596
|
if (edge && edge.label) {
|
|
433
597
|
const bbox = st.edgeLabelBBox && st.edgeLabelBBox[edgeId];
|
|
434
598
|
if (bbox) {
|
|
435
|
-
const r
|
|
436
|
-
const dx = cp.x - bbox.cx,
|
|
599
|
+
const r = -(bbox.rotation || 0);
|
|
600
|
+
const dx = cp.x - bbox.cx,
|
|
601
|
+
dy = cp.y - bbox.cy;
|
|
437
602
|
const lx = dx * Math.cos(r) - dy * Math.sin(r);
|
|
438
603
|
const ly = dx * Math.sin(r) + dy * Math.cos(r);
|
|
439
604
|
if (Math.abs(lx) <= bbox.w / 2 && Math.abs(ly) <= bbox.h / 2) {
|
|
440
|
-
return { type:
|
|
605
|
+
return { type: "label", edgeId };
|
|
441
606
|
}
|
|
442
607
|
}
|
|
443
608
|
}
|
|
@@ -447,19 +612,30 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
447
612
|
}
|
|
448
613
|
|
|
449
614
|
// Track hover during mousemove — computed before any mousedown fires.
|
|
450
|
-
container.addEventListener(
|
|
615
|
+
container.addEventListener("mousemove", (e) => {
|
|
451
616
|
const hit = edgeLabelHitAt(e.clientX, e.clientY);
|
|
452
|
-
const found =
|
|
453
|
-
|
|
454
|
-
|
|
617
|
+
const found =
|
|
618
|
+
hit && hit.type === "handle"
|
|
619
|
+
? {
|
|
620
|
+
edgeId: hit.edgeId,
|
|
621
|
+
bboxCx: hit.bboxCx,
|
|
622
|
+
bboxCy: hit.bboxCy,
|
|
623
|
+
rotation: hit.rotation,
|
|
624
|
+
}
|
|
625
|
+
: null;
|
|
455
626
|
if (Boolean(found) !== Boolean(_hoverHandle)) {
|
|
456
|
-
container.style.cursor = found
|
|
627
|
+
container.style.cursor = found
|
|
628
|
+
? "ew-resize"
|
|
629
|
+
: _hoverLabelDrag
|
|
630
|
+
? "grab"
|
|
631
|
+
: "";
|
|
457
632
|
}
|
|
458
633
|
_hoverHandle = found;
|
|
459
634
|
|
|
460
|
-
const labelFound =
|
|
635
|
+
const labelFound =
|
|
636
|
+
!found && hit && hit.type === "label" ? { edgeId: hit.edgeId } : null;
|
|
461
637
|
if (Boolean(labelFound) !== Boolean(_hoverLabelDrag)) {
|
|
462
|
-
if (!found) container.style.cursor = labelFound ?
|
|
638
|
+
if (!found) container.style.cursor = labelFound ? "grab" : "";
|
|
463
639
|
}
|
|
464
640
|
_hoverLabelDrag = labelFound;
|
|
465
641
|
});
|
|
@@ -468,14 +644,19 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
468
644
|
if (e.button !== 0) return false;
|
|
469
645
|
if (!container.contains(e.target)) return false;
|
|
470
646
|
const hit = edgeLabelHitAt(e.clientX, e.clientY);
|
|
471
|
-
if (hit && hit.type ===
|
|
647
|
+
if (hit && hit.type === "handle") {
|
|
472
648
|
e.preventDefault();
|
|
473
649
|
e.stopImmediatePropagation();
|
|
474
|
-
_hoverHandle = {
|
|
650
|
+
_hoverHandle = {
|
|
651
|
+
edgeId: hit.edgeId,
|
|
652
|
+
bboxCx: hit.bboxCx,
|
|
653
|
+
bboxCy: hit.bboxCy,
|
|
654
|
+
rotation: hit.rotation,
|
|
655
|
+
};
|
|
475
656
|
_lr = { ..._hoverHandle, dragging: false };
|
|
476
657
|
st.network.setOptions({ interaction: { dragView: false } });
|
|
477
658
|
return true;
|
|
478
|
-
} else if (hit && hit.type ===
|
|
659
|
+
} else if (hit && hit.type === "label") {
|
|
479
660
|
const edge = st.edges && st.edges.get(hit.edgeId);
|
|
480
661
|
if (!edge) return false;
|
|
481
662
|
e.preventDefault();
|
|
@@ -500,69 +681,103 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
500
681
|
// Mousedown: start resize (handles take priority) or label drag.
|
|
501
682
|
// The document-level capture listener runs before vis-network's internal
|
|
502
683
|
// pointer handlers, so a node underneath the edge label cannot start moving.
|
|
503
|
-
document.addEventListener(
|
|
684
|
+
document.addEventListener("pointerdown", startEdgeLabelPointerInteraction, {
|
|
504
685
|
capture: true,
|
|
505
686
|
signal: _edgeLabelPointerAbort.signal,
|
|
506
687
|
});
|
|
507
|
-
document.addEventListener(
|
|
688
|
+
document.addEventListener("mousedown", startEdgeLabelPointerInteraction, {
|
|
508
689
|
capture: true,
|
|
509
690
|
signal: _edgeLabelPointerAbort.signal,
|
|
510
691
|
});
|
|
511
|
-
container.addEventListener(
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
692
|
+
container.addEventListener(
|
|
693
|
+
"pointerdown",
|
|
694
|
+
(e) => {
|
|
695
|
+
startEdgeLabelPointerInteraction(e);
|
|
696
|
+
},
|
|
697
|
+
{ capture: true, signal: _edgeLabelPointerAbort.signal },
|
|
698
|
+
);
|
|
699
|
+
container.addEventListener(
|
|
700
|
+
"mousedown",
|
|
701
|
+
(e) => {
|
|
702
|
+
startEdgeLabelPointerInteraction(e);
|
|
703
|
+
},
|
|
704
|
+
{ capture: true, signal: _edgeLabelPointerAbort.signal },
|
|
705
|
+
);
|
|
522
706
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
707
|
+
document.addEventListener(
|
|
708
|
+
"dblclick",
|
|
709
|
+
(e) => {
|
|
710
|
+
if (!container.contains(e.target)) return;
|
|
711
|
+
const hit = edgeLabelHitAt(e.clientX, e.clientY);
|
|
712
|
+
if (!hit || hit.type !== "label") return;
|
|
713
|
+
|
|
714
|
+
e.preventDefault();
|
|
715
|
+
e.stopImmediatePropagation();
|
|
716
|
+
_lr = null;
|
|
717
|
+
_ld = null;
|
|
718
|
+
st.network.setOptions({ interaction: { dragView: true } });
|
|
719
|
+
st.selectedNodeIds = [];
|
|
720
|
+
st.selectedEdgeIds = [hit.edgeId];
|
|
721
|
+
st.network.setSelection({ nodes: [], edges: [hit.edgeId] });
|
|
722
|
+
hideNodePanel();
|
|
723
|
+
showEdgePanel();
|
|
724
|
+
startEdgeLabelEdit();
|
|
725
|
+
},
|
|
726
|
+
{ capture: true, signal: _edgeLabelPointerAbort.signal },
|
|
727
|
+
);
|
|
535
728
|
|
|
536
729
|
// Update width (resize) or offset (label drag) while dragging.
|
|
537
730
|
function onEdgeLabelPointerMove(e) {
|
|
538
731
|
if (!st.network) return;
|
|
539
732
|
if (_lr) {
|
|
540
|
-
if (!_lr.dragging) {
|
|
733
|
+
if (!_lr.dragging) {
|
|
734
|
+
_lr.dragging = true;
|
|
735
|
+
pushSnapshot();
|
|
736
|
+
}
|
|
541
737
|
const rect = container.getBoundingClientRect();
|
|
542
|
-
const cp
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
738
|
+
const cp = st.network.DOMtoCanvas({
|
|
739
|
+
x: e.clientX - rect.left,
|
|
740
|
+
y: e.clientY - rect.top,
|
|
741
|
+
});
|
|
742
|
+
const r = -_lr.rotation;
|
|
743
|
+
const dx = cp.x - _lr.bboxCx,
|
|
744
|
+
dy = cp.y - _lr.bboxCy;
|
|
745
|
+
const lx = dx * Math.cos(r) - dy * Math.sin(r);
|
|
746
|
+
st.edges.update({
|
|
747
|
+
id: _lr.edgeId,
|
|
748
|
+
edgeLabelWidth: Math.max(40, Math.abs(lx) * 2),
|
|
749
|
+
});
|
|
547
750
|
st.network.redraw();
|
|
548
751
|
}
|
|
549
752
|
if (_ld) {
|
|
550
753
|
if (!_ld.dragging) {
|
|
551
|
-
if (
|
|
754
|
+
if (
|
|
755
|
+
Math.hypot(
|
|
756
|
+
e.clientX - _ld.startMouse.x,
|
|
757
|
+
e.clientY - _ld.startMouse.y,
|
|
758
|
+
) < 4
|
|
759
|
+
)
|
|
760
|
+
return;
|
|
552
761
|
_ld.dragging = true;
|
|
553
762
|
pushSnapshot();
|
|
554
763
|
}
|
|
555
764
|
const scale = st.network.getScale();
|
|
556
765
|
st.edges.update({
|
|
557
766
|
id: _ld.edgeId,
|
|
558
|
-
edgeLabelOffsetX:
|
|
559
|
-
|
|
767
|
+
edgeLabelOffsetX:
|
|
768
|
+
_ld.startOffsetX + (e.clientX - _ld.startMouse.x) / scale,
|
|
769
|
+
edgeLabelOffsetY:
|
|
770
|
+
_ld.startOffsetY + (e.clientY - _ld.startMouse.y) / scale,
|
|
560
771
|
});
|
|
561
772
|
st.network.redraw();
|
|
562
773
|
}
|
|
563
774
|
}
|
|
564
|
-
document.addEventListener(
|
|
565
|
-
|
|
775
|
+
document.addEventListener("pointermove", onEdgeLabelPointerMove, {
|
|
776
|
+
signal: _edgeLabelPointerAbort.signal,
|
|
777
|
+
});
|
|
778
|
+
document.addEventListener("mousemove", onEdgeLabelPointerMove, {
|
|
779
|
+
signal: _edgeLabelPointerAbort.signal,
|
|
780
|
+
});
|
|
566
781
|
|
|
567
782
|
// Commit on mouseup.
|
|
568
783
|
function onEdgeLabelPointerUp() {
|
|
@@ -576,10 +791,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
576
791
|
if (_ld.dragging) markDirty();
|
|
577
792
|
_ld = null;
|
|
578
793
|
}
|
|
579
|
-
container.style.cursor = _hoverHandle
|
|
794
|
+
container.style.cursor = _hoverHandle
|
|
795
|
+
? "ew-resize"
|
|
796
|
+
: _hoverLabelDrag
|
|
797
|
+
? "grab"
|
|
798
|
+
: "";
|
|
580
799
|
}
|
|
581
|
-
document.addEventListener(
|
|
582
|
-
|
|
800
|
+
document.addEventListener("pointerup", onEdgeLabelPointerUp, {
|
|
801
|
+
signal: _edgeLabelPointerAbort.signal,
|
|
802
|
+
});
|
|
803
|
+
document.addEventListener("mouseup", onEdgeLabelPointerUp, {
|
|
804
|
+
signal: _edgeLabelPointerAbort.signal,
|
|
805
|
+
});
|
|
583
806
|
}
|
|
584
807
|
|
|
585
808
|
// ── Free-arrow body drag ──────────────────────────────────────────────────────
|
|
@@ -588,25 +811,43 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
588
811
|
// Fix: capture mousedown on the edge body, disable dragView, move anchors manually.
|
|
589
812
|
{
|
|
590
813
|
let _fad = null; // free-arrow drag state
|
|
591
|
-
container.addEventListener(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
814
|
+
container.addEventListener(
|
|
815
|
+
"mousedown",
|
|
816
|
+
(e) => {
|
|
817
|
+
if (e.button !== 0 || !st.network) return;
|
|
818
|
+
const domPos = { x: e.offsetX, y: e.offsetY };
|
|
819
|
+
// If the click landed on a node (including an anchor endpoint), don't intercept:
|
|
820
|
+
// the user wants to move only that endpoint (pivot), not the whole arrow.
|
|
821
|
+
if (st.network.getNodeAt(domPos)) return;
|
|
822
|
+
const edgeId = st.network.getEdgeAt(domPos);
|
|
823
|
+
if (!edgeId) return;
|
|
824
|
+
const edge = st.edges.get(edgeId);
|
|
825
|
+
if (!edge) return;
|
|
826
|
+
const fromN = st.nodes.get(edge.from);
|
|
827
|
+
const toN = st.nodes.get(edge.to);
|
|
828
|
+
if (
|
|
829
|
+
!(
|
|
830
|
+
fromN &&
|
|
831
|
+
fromN.shapeType === "anchor" &&
|
|
832
|
+
toN &&
|
|
833
|
+
toN.shapeType === "anchor"
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
return;
|
|
837
|
+
const startPos = st.network.getPositions([edge.from, edge.to]);
|
|
838
|
+
_fad = {
|
|
839
|
+
edgeId,
|
|
840
|
+
fromId: edge.from,
|
|
841
|
+
toId: edge.to,
|
|
842
|
+
startMouse: { x: e.clientX, y: e.clientY },
|
|
843
|
+
startPos,
|
|
844
|
+
dragging: false,
|
|
845
|
+
};
|
|
846
|
+
},
|
|
847
|
+
{ capture: true },
|
|
848
|
+
);
|
|
849
|
+
|
|
850
|
+
document.addEventListener("mousemove", (e) => {
|
|
610
851
|
if (!_fad || !st.network) return;
|
|
611
852
|
const dx = e.clientX - _fad.startMouse.x;
|
|
612
853
|
const dy = e.clientY - _fad.startMouse.y;
|
|
@@ -619,13 +860,19 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
619
860
|
const scale = st.network.getScale();
|
|
620
861
|
const fp = _fad.startPos[_fad.fromId];
|
|
621
862
|
const tp = _fad.startPos[_fad.toId];
|
|
622
|
-
const snap = (x, y) => st.gridEnabled ? snapToGrid(x, y) : { x, y };
|
|
623
|
-
if (fp) {
|
|
624
|
-
|
|
863
|
+
const snap = (x, y) => (st.gridEnabled ? snapToGrid(x, y) : { x, y });
|
|
864
|
+
if (fp) {
|
|
865
|
+
const p = snap(fp.x + dx / scale, fp.y + dy / scale);
|
|
866
|
+
st.network.moveNode(_fad.fromId, p.x, p.y);
|
|
867
|
+
}
|
|
868
|
+
if (tp) {
|
|
869
|
+
const p = snap(tp.x + dx / scale, tp.y + dy / scale);
|
|
870
|
+
st.network.moveNode(_fad.toId, p.x, p.y);
|
|
871
|
+
}
|
|
625
872
|
st.network.redraw();
|
|
626
873
|
});
|
|
627
874
|
|
|
628
|
-
document.addEventListener(
|
|
875
|
+
document.addEventListener("mouseup", () => {
|
|
629
876
|
if (!_fad) return;
|
|
630
877
|
if (_fad.dragging) {
|
|
631
878
|
st.network.setOptions({ interaction: { dragView: true } });
|
|
@@ -642,67 +889,90 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
642
889
|
// Path B — two successive CLICKS on empty canvas → free-standing anchor→anchor.
|
|
643
890
|
// Uses vis-network's own 'click' event: Hammer.js fires 'click' only
|
|
644
891
|
// for taps, NOT for drags, so Path A drags never bleed into Path B.
|
|
645
|
-
let _addEdgeFromId
|
|
646
|
-
let _addEdgeFromPort = null;
|
|
892
|
+
let _addEdgeFromId = null;
|
|
893
|
+
let _addEdgeFromPort = null; // port key on the source node (null = no port)
|
|
647
894
|
// _hoveredPortNodeId, _hoveredPortKey, _draggingAnchorIds are module-level.
|
|
648
|
-
const visCanvas = document.getElementById(
|
|
895
|
+
const visCanvas = document.getElementById("vis-canvas");
|
|
649
896
|
|
|
650
897
|
// Track hovered node + nearest port while in addEdge mode OR while dragging an anchor.
|
|
651
|
-
visCanvas.addEventListener(
|
|
652
|
-
const inAddEdge
|
|
898
|
+
visCanvas.addEventListener("mousemove", (e) => {
|
|
899
|
+
const inAddEdge = st.currentTool === "addEdge";
|
|
653
900
|
const anchorDragging = _draggingAnchorIds.size > 0;
|
|
654
901
|
if ((!inAddEdge && !anchorDragging) || !st.network) return;
|
|
655
902
|
|
|
656
903
|
const pos = { x: e.offsetX, y: e.offsetY };
|
|
657
|
-
const cp
|
|
904
|
+
const cp = st.network.DOMtoCanvas(pos);
|
|
658
905
|
|
|
659
906
|
let newPortNodeId = null;
|
|
660
907
|
|
|
661
908
|
if (inAddEdge) {
|
|
662
909
|
// addEdge mode: use getNodeAt as before
|
|
663
|
-
const nodeId
|
|
910
|
+
const nodeId = st.network.getNodeAt(pos) || null;
|
|
664
911
|
const nodeData = nodeId && st.nodes.get(nodeId);
|
|
665
|
-
const isAnchor = nodeData && nodeData.shapeType ===
|
|
912
|
+
const isAnchor = nodeData && nodeData.shapeType === "anchor";
|
|
666
913
|
const isLocked = nodeData && nodeData.locked;
|
|
667
|
-
newPortNodeId
|
|
914
|
+
newPortNodeId = nodeId && !isAnchor && !isLocked ? nodeId : null;
|
|
668
915
|
} else {
|
|
669
916
|
// Anchor drag: getNodeAt returns the dragged anchor itself — do a canvas-space
|
|
670
917
|
// bounding-box search for any non-anchor node that contains the cursor.
|
|
671
918
|
const SNAP_THRESHOLD = 30;
|
|
672
|
-
let bestId
|
|
919
|
+
let bestId = null;
|
|
673
920
|
let bestDist = Infinity;
|
|
674
|
-
for (const [candidateId, candidatePos] of Object.entries(
|
|
921
|
+
for (const [candidateId, candidatePos] of Object.entries(
|
|
922
|
+
st.network.getPositions(),
|
|
923
|
+
)) {
|
|
675
924
|
if (_draggingAnchorIds.has(candidateId)) continue;
|
|
676
925
|
const candidate = st.nodes.get(candidateId);
|
|
677
|
-
if (!candidate || candidate.shapeType ===
|
|
926
|
+
if (!candidate || candidate.shapeType === "anchor") continue;
|
|
678
927
|
const bodyNode = st.network.body.nodes[candidateId];
|
|
679
928
|
if (!bodyNode) continue;
|
|
680
|
-
const w =
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
929
|
+
const w =
|
|
930
|
+
(bodyNode.shape && bodyNode.shape.width) ||
|
|
931
|
+
SHAPE_DEFAULTS[candidate.shapeType]?.width ||
|
|
932
|
+
120;
|
|
933
|
+
const h =
|
|
934
|
+
(bodyNode.shape && bodyNode.shape.height) ||
|
|
935
|
+
SHAPE_DEFAULTS[candidate.shapeType]?.height ||
|
|
936
|
+
60;
|
|
937
|
+
const inBox =
|
|
938
|
+
cp.x >= candidatePos.x - w / 2 - SNAP_THRESHOLD &&
|
|
939
|
+
cp.x <= candidatePos.x + w / 2 + SNAP_THRESHOLD &&
|
|
940
|
+
cp.y >= candidatePos.y - h / 2 - SNAP_THRESHOLD &&
|
|
941
|
+
cp.y <= candidatePos.y + h / 2 + SNAP_THRESHOLD;
|
|
686
942
|
if (inBox) {
|
|
687
943
|
const dist = Math.hypot(candidatePos.x - cp.x, candidatePos.y - cp.y);
|
|
688
|
-
if (dist < bestDist) {
|
|
944
|
+
if (dist < bestDist) {
|
|
945
|
+
bestDist = dist;
|
|
946
|
+
bestId = candidateId;
|
|
947
|
+
}
|
|
689
948
|
}
|
|
690
949
|
}
|
|
691
950
|
newPortNodeId = bestId;
|
|
692
951
|
}
|
|
693
952
|
|
|
694
953
|
const newPortKey = newPortNodeId ? getNearestPort(newPortNodeId, cp) : null;
|
|
695
|
-
if (
|
|
954
|
+
if (
|
|
955
|
+
newPortNodeId !== _hoveredPortNodeId ||
|
|
956
|
+
newPortKey !== _hoveredPortKey
|
|
957
|
+
) {
|
|
696
958
|
_hoveredPortNodeId = newPortNodeId;
|
|
697
|
-
_hoveredPortKey
|
|
959
|
+
_hoveredPortKey = newPortKey;
|
|
698
960
|
st.network.redraw();
|
|
699
961
|
}
|
|
700
962
|
});
|
|
701
963
|
|
|
702
964
|
// Rehook hover: track the nearest port on the from/to nodes of the selected port edge.
|
|
703
|
-
visCanvas.addEventListener(
|
|
704
|
-
if (
|
|
705
|
-
|
|
965
|
+
visCanvas.addEventListener("mousemove", (e) => {
|
|
966
|
+
if (
|
|
967
|
+
!st.network ||
|
|
968
|
+
st.currentTool === "addEdge" ||
|
|
969
|
+
_draggingAnchorIds.size > 0
|
|
970
|
+
)
|
|
971
|
+
return;
|
|
972
|
+
const selectedEdge =
|
|
973
|
+
st.selectedEdgeIds.length === 1
|
|
974
|
+
? st.edges.get(st.selectedEdgeIds[0])
|
|
975
|
+
: null;
|
|
706
976
|
if (!isRehookable(selectedEdge)) {
|
|
707
977
|
if (_rehookEdgeId !== null) {
|
|
708
978
|
_rehookEdgeId = _rehookHoveredNodeId = _rehookHoveredPortKey = null;
|
|
@@ -714,79 +984,99 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
714
984
|
_rehookEdgeId = selectedEdge.id;
|
|
715
985
|
const cp = st.network.DOMtoCanvas({ x: e.offsetX, y: e.offsetY });
|
|
716
986
|
const REHOOK_THRESHOLD = 15; // world units
|
|
717
|
-
let newNodeId
|
|
718
|
-
let newPortKey
|
|
987
|
+
let newNodeId = null;
|
|
988
|
+
let newPortKey = null;
|
|
719
989
|
let closestDist = Infinity;
|
|
720
990
|
|
|
721
991
|
for (const nodeId of [selectedEdge.from, selectedEdge.to]) {
|
|
722
992
|
const nodeData = st.nodes.get(nodeId);
|
|
723
|
-
if (!nodeData || nodeData.shapeType ===
|
|
993
|
+
if (!nodeData || nodeData.shapeType === "anchor") continue;
|
|
724
994
|
const portKey = getNearestPort(nodeId, cp);
|
|
725
995
|
const portPos = getPortPosition(nodeId, portKey);
|
|
726
996
|
if (!portPos) continue;
|
|
727
997
|
const dist = Math.hypot(cp.x - portPos.x, cp.y - portPos.y);
|
|
728
998
|
if (dist <= REHOOK_THRESHOLD && dist < closestDist) {
|
|
729
999
|
closestDist = dist;
|
|
730
|
-
newNodeId
|
|
1000
|
+
newNodeId = nodeId;
|
|
731
1001
|
newPortKey = portKey;
|
|
732
1002
|
}
|
|
733
1003
|
}
|
|
734
1004
|
|
|
735
|
-
if (
|
|
736
|
-
_rehookHoveredNodeId
|
|
1005
|
+
if (
|
|
1006
|
+
newNodeId !== _rehookHoveredNodeId ||
|
|
1007
|
+
newPortKey !== _rehookHoveredPortKey
|
|
1008
|
+
) {
|
|
1009
|
+
_rehookHoveredNodeId = newNodeId;
|
|
737
1010
|
_rehookHoveredPortKey = newPortKey;
|
|
738
1011
|
st.network.redraw();
|
|
739
1012
|
}
|
|
740
1013
|
});
|
|
741
1014
|
|
|
742
1015
|
// Path A – capture source node on mousedown.
|
|
743
|
-
visCanvas.addEventListener(
|
|
744
|
-
if (st.currentTool !==
|
|
745
|
-
const nodeId
|
|
1016
|
+
visCanvas.addEventListener("mousedown", (e) => {
|
|
1017
|
+
if (st.currentTool !== "addEdge") return;
|
|
1018
|
+
const nodeId = st.network.getNodeAt({ x: e.offsetX, y: e.offsetY }) || null;
|
|
746
1019
|
const nodeData = nodeId && st.nodes.get(nodeId);
|
|
747
1020
|
// Block starting an edge from a locked node or an anchor (free-arrow endpoint).
|
|
748
|
-
if (nodeData && (nodeData.locked || nodeData.shapeType ===
|
|
1021
|
+
if (nodeData && (nodeData.locked || nodeData.shapeType === "anchor")) {
|
|
1022
|
+
_addEdgeFromId = null;
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
749
1025
|
// Dragging from a real node cancels any pending two-click origin.
|
|
750
1026
|
if (nodeId) st.freeArrowFirstPoint = null;
|
|
751
|
-
_addEdgeFromId
|
|
1027
|
+
_addEdgeFromId = nodeId;
|
|
752
1028
|
_addEdgeFromPort = _hoveredPortKey;
|
|
753
1029
|
});
|
|
754
1030
|
|
|
755
1031
|
// Path A – release on empty canvas creates the anchor endpoint.
|
|
756
|
-
visCanvas.addEventListener(
|
|
757
|
-
if (st.currentTool !==
|
|
758
|
-
|
|
759
|
-
|
|
1032
|
+
visCanvas.addEventListener("mouseup", (e) => {
|
|
1033
|
+
if (st.currentTool !== "addEdge" || !_addEdgeFromId) {
|
|
1034
|
+
_addEdgeFromId = null;
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
const pos = { x: e.offsetX, y: e.offsetY };
|
|
1038
|
+
const targetId = st.network.getNodeAt(pos);
|
|
760
1039
|
const targetData = targetId && st.nodes.get(targetId);
|
|
761
1040
|
// Locked targets are non-interactive: treat them exactly like empty canvas
|
|
762
1041
|
// so a free-arrow endpoint is created at the release point — matches the
|
|
763
1042
|
// behaviour of free arrows drawn over a locked shape (Path B).
|
|
764
|
-
// Also: if the
|
|
765
|
-
//
|
|
766
|
-
// point on top of the source's layer, not at the underlying shape.
|
|
1043
|
+
// Also: if the source sits on top of a lower-z target that contains it
|
|
1044
|
+
// (e.g. a post-it on a background image), don't bind to that background.
|
|
767
1045
|
const targetLocked = !!(targetData && targetData.locked);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (!targetId || targetLocked || targetBelow) {
|
|
1046
|
+
const targetContainsSource =
|
|
1047
|
+
targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
|
|
1048
|
+
if (!targetId || targetLocked || targetContainsSource) {
|
|
772
1049
|
pushSnapshot();
|
|
773
|
-
const cp
|
|
774
|
-
const anchorId =
|
|
1050
|
+
const cp = st.network.DOMtoCanvas(pos);
|
|
1051
|
+
const anchorId = "a" + Date.now();
|
|
775
1052
|
st.nodes.add({
|
|
776
|
-
id: anchorId,
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1053
|
+
id: anchorId,
|
|
1054
|
+
label: "",
|
|
1055
|
+
shapeType: "anchor",
|
|
1056
|
+
colorKey: "c-gray",
|
|
1057
|
+
nodeWidth: 8,
|
|
1058
|
+
nodeHeight: 8,
|
|
1059
|
+
fontSize: null,
|
|
1060
|
+
rotation: 0,
|
|
1061
|
+
labelRotation: 0,
|
|
1062
|
+
x: cp.x,
|
|
1063
|
+
y: cp.y,
|
|
1064
|
+
...visNodeProps("anchor", "c-gray", 8, 8, null, null, null),
|
|
780
1065
|
});
|
|
781
1066
|
st.edges.add({
|
|
782
|
-
id:
|
|
783
|
-
|
|
1067
|
+
id: "e" + Date.now(),
|
|
1068
|
+
from: _addEdgeFromId,
|
|
1069
|
+
to: anchorId,
|
|
1070
|
+
arrowDir: "to",
|
|
1071
|
+
dashes: false,
|
|
784
1072
|
smooth: { enabled: false },
|
|
785
|
-
...visEdgeProps(
|
|
1073
|
+
...visEdgeProps("to", false),
|
|
786
1074
|
});
|
|
787
1075
|
markDirty();
|
|
788
1076
|
st.freeArrowFirstPoint = null;
|
|
789
|
-
setTimeout(() => {
|
|
1077
|
+
setTimeout(() => {
|
|
1078
|
+
if (st.currentTool === "addEdge") st.network.addEdgeMode();
|
|
1079
|
+
}, 0);
|
|
790
1080
|
}
|
|
791
1081
|
_addEdgeFromId = null;
|
|
792
1082
|
});
|
|
@@ -794,8 +1084,8 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
794
1084
|
// Path B – two successive clicks on empty canvas → free-standing anchor→anchor arrow.
|
|
795
1085
|
// vis-network's 'click' event (via Hammer.js) only fires for taps, never for drags,
|
|
796
1086
|
// so Path A drag gestures cannot accidentally trigger Path B.
|
|
797
|
-
st.network.on(
|
|
798
|
-
if (st.currentTool !==
|
|
1087
|
+
st.network.on("click", (params) => {
|
|
1088
|
+
if (st.currentTool !== "addEdge") return;
|
|
799
1089
|
// Only handle clicks that landed on empty canvas (no node, no edge).
|
|
800
1090
|
if (params.nodes.length > 0 || params.edges.length > 0) return;
|
|
801
1091
|
const cp = params.pointer.canvas;
|
|
@@ -806,26 +1096,71 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
806
1096
|
} else {
|
|
807
1097
|
// Second click: create the free-standing anchor→anchor arrow.
|
|
808
1098
|
pushSnapshot();
|
|
809
|
-
const t
|
|
810
|
-
const fromId
|
|
811
|
-
const toId
|
|
812
|
-
const anchorProps = visNodeProps(
|
|
1099
|
+
const t = Date.now();
|
|
1100
|
+
const fromId = "a" + t;
|
|
1101
|
+
const toId = "a" + (t + 1);
|
|
1102
|
+
const anchorProps = visNodeProps(
|
|
1103
|
+
"anchor",
|
|
1104
|
+
"c-gray",
|
|
1105
|
+
8,
|
|
1106
|
+
8,
|
|
1107
|
+
null,
|
|
1108
|
+
null,
|
|
1109
|
+
null,
|
|
1110
|
+
);
|
|
813
1111
|
st.nodes.add([
|
|
814
|
-
{
|
|
815
|
-
|
|
1112
|
+
{
|
|
1113
|
+
id: fromId,
|
|
1114
|
+
label: "",
|
|
1115
|
+
shapeType: "anchor",
|
|
1116
|
+
colorKey: "c-gray",
|
|
1117
|
+
nodeWidth: 8,
|
|
1118
|
+
nodeHeight: 8,
|
|
1119
|
+
fontSize: null,
|
|
1120
|
+
rotation: 0,
|
|
1121
|
+
labelRotation: 0,
|
|
1122
|
+
x: st.freeArrowFirstPoint.x,
|
|
1123
|
+
y: st.freeArrowFirstPoint.y,
|
|
1124
|
+
...anchorProps,
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
id: toId,
|
|
1128
|
+
label: "",
|
|
1129
|
+
shapeType: "anchor",
|
|
1130
|
+
colorKey: "c-gray",
|
|
1131
|
+
nodeWidth: 8,
|
|
1132
|
+
nodeHeight: 8,
|
|
1133
|
+
fontSize: null,
|
|
1134
|
+
rotation: 0,
|
|
1135
|
+
labelRotation: 0,
|
|
1136
|
+
x: cp.x,
|
|
1137
|
+
y: cp.y,
|
|
1138
|
+
...anchorProps,
|
|
1139
|
+
},
|
|
816
1140
|
]);
|
|
817
|
-
const edgeId
|
|
1141
|
+
const edgeId = "e" + t;
|
|
818
1142
|
const lastStyle = getLastFreeArrowStyle();
|
|
819
|
-
const arrowDir
|
|
820
|
-
const dashes
|
|
1143
|
+
const arrowDir = lastStyle.arrowDir || "to";
|
|
1144
|
+
const dashes = lastStyle.dashes || false;
|
|
821
1145
|
st.edges.add({
|
|
822
|
-
id: edgeId,
|
|
823
|
-
|
|
1146
|
+
id: edgeId,
|
|
1147
|
+
from: fromId,
|
|
1148
|
+
to: toId,
|
|
1149
|
+
arrowDir,
|
|
1150
|
+
dashes,
|
|
824
1151
|
edgeColor: lastStyle.edgeColor || null,
|
|
825
1152
|
edgeWidth: lastStyle.edgeWidth || null,
|
|
826
1153
|
smooth: { enabled: false },
|
|
827
1154
|
...visEdgeProps(arrowDir, dashes),
|
|
828
|
-
...(lastStyle.edgeColor
|
|
1155
|
+
...(lastStyle.edgeColor
|
|
1156
|
+
? {
|
|
1157
|
+
color: {
|
|
1158
|
+
color: lastStyle.edgeColor,
|
|
1159
|
+
highlight: "#f97316",
|
|
1160
|
+
hover: "#f97316",
|
|
1161
|
+
},
|
|
1162
|
+
}
|
|
1163
|
+
: {}),
|
|
829
1164
|
...(lastStyle.edgeWidth ? { width: lastStyle.edgeWidth } : {}),
|
|
830
1165
|
});
|
|
831
1166
|
st.freeArrowFirstPoint = null;
|
|
@@ -840,18 +1175,18 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
840
1175
|
}
|
|
841
1176
|
});
|
|
842
1177
|
|
|
843
|
-
document.getElementById(
|
|
1178
|
+
document.getElementById("emptyState").classList.add("hidden");
|
|
844
1179
|
updateZoomDisplay();
|
|
845
1180
|
|
|
846
1181
|
// vis-network initialises shape.width/height to 50 (not undefined), so
|
|
847
1182
|
// needsRefresh() returns false and resize() never runs on the first render.
|
|
848
1183
|
// Fix: reset shape.width/height to undefined so needsRefresh() returns true
|
|
849
1184
|
// and vis-network's own render loop runs resize() with the correct context.
|
|
850
|
-
st.network.once(
|
|
1185
|
+
st.network.once("afterDrawing", () => {
|
|
851
1186
|
for (const id of st.network.body.nodeIndices) {
|
|
852
1187
|
const bn = st.network.body.nodes[id];
|
|
853
1188
|
if (!bn || !bn.shape) continue;
|
|
854
|
-
bn.shape.width
|
|
1189
|
+
bn.shape.width = undefined;
|
|
855
1190
|
bn.shape.height = undefined;
|
|
856
1191
|
}
|
|
857
1192
|
// Patch CustomShape.distanceToBorder so anchor nodes report 0: arrow tips
|
|
@@ -867,7 +1202,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
867
1202
|
const orig = proto.distanceToBorder;
|
|
868
1203
|
proto.distanceToBorder = function (ctx, angle) {
|
|
869
1204
|
const data = st.nodes && st.nodes.get(this.options && this.options.id);
|
|
870
|
-
if (data && data.shapeType ===
|
|
1205
|
+
if (data && data.shapeType === "anchor") return 0;
|
|
871
1206
|
return orig.call(this, ctx, angle);
|
|
872
1207
|
};
|
|
873
1208
|
proto.__ldAnchorDistancePatched = true;
|
|
@@ -875,7 +1210,7 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
875
1210
|
}
|
|
876
1211
|
st.network.redraw();
|
|
877
1212
|
});
|
|
878
|
-
window.dispatchEvent(new CustomEvent(
|
|
1213
|
+
window.dispatchEvent(new CustomEvent("diagram:network-ready"));
|
|
879
1214
|
}
|
|
880
1215
|
|
|
881
1216
|
// ── Anchor snap-to-connect ────────────────────────────────────────────────────
|
|
@@ -891,10 +1226,12 @@ function _onAnchorSnapConnect(params) {
|
|
|
891
1226
|
|
|
892
1227
|
for (const anchorId of params.nodes) {
|
|
893
1228
|
const anchor = st.nodes.get(anchorId);
|
|
894
|
-
if (!anchor || anchor.shapeType !==
|
|
1229
|
+
if (!anchor || anchor.shapeType !== "anchor") continue;
|
|
895
1230
|
|
|
896
1231
|
// Find the edge this anchor belongs to (as from or to)
|
|
897
|
-
const connectedEdges = st.edges
|
|
1232
|
+
const connectedEdges = st.edges
|
|
1233
|
+
.get()
|
|
1234
|
+
.filter((e) => e.from === anchorId || e.to === anchorId);
|
|
898
1235
|
if (connectedEdges.length === 0) continue;
|
|
899
1236
|
|
|
900
1237
|
// Current position of dragged anchor
|
|
@@ -910,10 +1247,12 @@ function _onAnchorSnapConnect(params) {
|
|
|
910
1247
|
}
|
|
911
1248
|
siblingIds.delete(anchorId); // keep sibling (other endpoint) in set to block self-loop
|
|
912
1249
|
|
|
913
|
-
let bestId
|
|
1250
|
+
let bestId = null;
|
|
914
1251
|
let bestDist = Infinity;
|
|
915
1252
|
|
|
916
|
-
for (const [candidateId, candidatePos] of Object.entries(
|
|
1253
|
+
for (const [candidateId, candidatePos] of Object.entries(
|
|
1254
|
+
st.network.getPositions(),
|
|
1255
|
+
)) {
|
|
917
1256
|
if (candidateId === anchorId) continue;
|
|
918
1257
|
if (siblingIds.has(candidateId)) continue; // would create a self-loop
|
|
919
1258
|
|
|
@@ -922,26 +1261,33 @@ function _onAnchorSnapConnect(params) {
|
|
|
922
1261
|
|
|
923
1262
|
const dist = Math.hypot(candidatePos.x - pos.x, candidatePos.y - pos.y);
|
|
924
1263
|
|
|
925
|
-
if (candidate.shapeType ===
|
|
1264
|
+
if (candidate.shapeType === "anchor") {
|
|
926
1265
|
// Snap to another anchor if within threshold
|
|
927
1266
|
if (dist < SNAP_THRESHOLD && dist < bestDist) {
|
|
928
|
-
bestId
|
|
1267
|
+
bestId = candidateId;
|
|
929
1268
|
bestDist = dist;
|
|
930
1269
|
}
|
|
931
1270
|
} else {
|
|
932
1271
|
// Snap to a regular node if anchor falls within its bounding box (padded by threshold)
|
|
933
1272
|
const bodyNode = st.network.body.nodes[candidateId];
|
|
934
1273
|
if (!bodyNode) continue;
|
|
935
|
-
const w =
|
|
936
|
-
|
|
1274
|
+
const w =
|
|
1275
|
+
(bodyNode.shape && bodyNode.shape.width) ||
|
|
1276
|
+
SHAPE_DEFAULTS[candidate.shapeType]?.width ||
|
|
1277
|
+
120;
|
|
1278
|
+
const h =
|
|
1279
|
+
(bodyNode.shape && bodyNode.shape.height) ||
|
|
1280
|
+
SHAPE_DEFAULTS[candidate.shapeType]?.height ||
|
|
1281
|
+
60;
|
|
937
1282
|
const cx = candidatePos.x;
|
|
938
1283
|
const cy = candidatePos.y;
|
|
939
|
-
const inBox =
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1284
|
+
const inBox =
|
|
1285
|
+
pos.x >= cx - w / 2 - SNAP_THRESHOLD &&
|
|
1286
|
+
pos.x <= cx + w / 2 + SNAP_THRESHOLD &&
|
|
1287
|
+
pos.y >= cy - h / 2 - SNAP_THRESHOLD &&
|
|
1288
|
+
pos.y <= cy + h / 2 + SNAP_THRESHOLD;
|
|
943
1289
|
if (inBox && dist < bestDist) {
|
|
944
|
-
bestId
|
|
1290
|
+
bestId = candidateId;
|
|
945
1291
|
bestDist = dist;
|
|
946
1292
|
}
|
|
947
1293
|
}
|
|
@@ -949,17 +1295,21 @@ function _onAnchorSnapConnect(params) {
|
|
|
949
1295
|
|
|
950
1296
|
if (!bestId) continue;
|
|
951
1297
|
|
|
952
|
-
if (!snapped) {
|
|
1298
|
+
if (!snapped) {
|
|
1299
|
+
pushSnapshot();
|
|
1300
|
+
snapped = true;
|
|
1301
|
+
}
|
|
953
1302
|
|
|
954
1303
|
// Determine which port to attach to on the target node (non-anchor targets only).
|
|
955
1304
|
const targetNode = st.nodes.get(bestId);
|
|
956
|
-
const isAnchorTarget = targetNode && targetNode.shapeType ===
|
|
1305
|
+
const isAnchorTarget = targetNode && targetNode.shapeType === "anchor";
|
|
957
1306
|
// Use the hovered port (computed by mousemove) when available; fall back to nearest port.
|
|
958
1307
|
let portKey = null;
|
|
959
1308
|
if (!isAnchorTarget) {
|
|
960
|
-
portKey =
|
|
961
|
-
|
|
962
|
-
|
|
1309
|
+
portKey =
|
|
1310
|
+
_hoveredPortNodeId === bestId && _hoveredPortKey
|
|
1311
|
+
? _hoveredPortKey
|
|
1312
|
+
: getNearestPort(bestId, pos);
|
|
963
1313
|
}
|
|
964
1314
|
|
|
965
1315
|
// Reconnect every edge that uses this anchor as an endpoint
|
|
@@ -969,7 +1319,11 @@ function _onAnchorSnapConnect(params) {
|
|
|
969
1319
|
if (portKey) update.fromPort = portKey;
|
|
970
1320
|
// Port edges must be transparent ghosts so only drawPortEdge is visible.
|
|
971
1321
|
if (portKey || e.toPort) {
|
|
972
|
-
update.color
|
|
1322
|
+
update.color = {
|
|
1323
|
+
color: "rgba(0,0,0,0)",
|
|
1324
|
+
highlight: "rgba(0,0,0,0)",
|
|
1325
|
+
hover: "rgba(0,0,0,0)",
|
|
1326
|
+
};
|
|
973
1327
|
update.arrows = { to: { enabled: false }, from: { enabled: false } };
|
|
974
1328
|
}
|
|
975
1329
|
st.edges.update(update);
|
|
@@ -979,7 +1333,11 @@ function _onAnchorSnapConnect(params) {
|
|
|
979
1333
|
if (portKey) update.toPort = portKey;
|
|
980
1334
|
// Port edges must be transparent ghosts so only drawPortEdge is visible.
|
|
981
1335
|
if (portKey || e.fromPort) {
|
|
982
|
-
update.color
|
|
1336
|
+
update.color = {
|
|
1337
|
+
color: "rgba(0,0,0,0)",
|
|
1338
|
+
highlight: "rgba(0,0,0,0)",
|
|
1339
|
+
hover: "rgba(0,0,0,0)",
|
|
1340
|
+
};
|
|
983
1341
|
update.arrows = { to: { enabled: false }, from: { enabled: false } };
|
|
984
1342
|
}
|
|
985
1343
|
st.edges.update(update);
|
|
@@ -999,133 +1357,146 @@ function _onAnchorSnapConnect(params) {
|
|
|
999
1357
|
// labels to appear twice at different positions.
|
|
1000
1358
|
function drawEdgeLabels(ctx) {
|
|
1001
1359
|
try {
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1360
|
+
if (!st.edges || !st.network) return;
|
|
1361
|
+
|
|
1362
|
+
const m = ctx.getTransform();
|
|
1363
|
+
const dpr = window.devicePixelRatio || 1;
|
|
1364
|
+
const canvasEl = ctx.canvas;
|
|
1365
|
+
const container = document.getElementById("vis-canvas").parentElement;
|
|
1366
|
+
const canvasRect = canvasEl.getBoundingClientRect();
|
|
1367
|
+
const containerRect = container.getBoundingClientRect();
|
|
1368
|
+
const offsetX = canvasRect.left - containerRect.left;
|
|
1369
|
+
const offsetY = canvasRect.top - containerRect.top;
|
|
1370
|
+
|
|
1371
|
+
st.edges.get().forEach((e) => {
|
|
1372
|
+
// Port edges draw their own labels inside drawPortEdge — skip here.
|
|
1373
|
+
if (e.fromPort || e.toPort) return;
|
|
1374
|
+
|
|
1375
|
+
// Compute bezier midpoint in layout space for every edge (labeled or not)
|
|
1376
|
+
// so the label editor always has an accurate DOM position to open at.
|
|
1377
|
+
const bodyEdge = st.network.body.edges[e.id];
|
|
1378
|
+
let mx, my;
|
|
1379
|
+
if (
|
|
1380
|
+
bodyEdge &&
|
|
1381
|
+
bodyEdge.edgeType &&
|
|
1382
|
+
typeof bodyEdge.edgeType.getPoint === "function"
|
|
1383
|
+
) {
|
|
1384
|
+
const pt = bodyEdge.edgeType.getPoint(0.5);
|
|
1385
|
+
mx = pt.x;
|
|
1386
|
+
my = pt.y;
|
|
1387
|
+
} else {
|
|
1388
|
+
const positions = st.network.getPositions([e.from, e.to]);
|
|
1389
|
+
const fp = positions[e.from];
|
|
1390
|
+
const tp = positions[e.to];
|
|
1391
|
+
if (!fp || !tp) return;
|
|
1392
|
+
mx = (fp.x + tp.x) / 2;
|
|
1393
|
+
my = (fp.y + tp.y) / 2;
|
|
1394
|
+
}
|
|
1033
1395
|
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1396
|
+
const ox = e.edgeLabelOffsetX || 0;
|
|
1397
|
+
const oy = e.edgeLabelOffsetY || 0;
|
|
1398
|
+
const lx = mx + ox;
|
|
1399
|
+
const ly = my + oy;
|
|
1038
1400
|
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1401
|
+
st.edgeLabelCanvasPos[e.id] = {
|
|
1402
|
+
x: (m.a * lx + m.e) / dpr + offsetX,
|
|
1403
|
+
y: (m.d * ly + m.f) / dpr + offsetY,
|
|
1404
|
+
};
|
|
1043
1405
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1406
|
+
// Only draw the label text for edges that have one.
|
|
1407
|
+
if (!e.label) return;
|
|
1046
1408
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1409
|
+
const fontSize = e.fontSize || 11;
|
|
1410
|
+
const lineHeight = fontSize * 1.5;
|
|
1411
|
+
const PAD_X = 6,
|
|
1412
|
+
PAD_Y = 4;
|
|
1050
1413
|
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
const TEXT_PAD = 3;
|
|
1074
|
-
const isDark = document.documentElement.classList.contains('dark');
|
|
1075
|
-
const bgFill = isDark ? 'rgba(3,7,18,0.82)' : 'rgba(249,250,251,0.82)';
|
|
1414
|
+
ctx.save();
|
|
1415
|
+
ctx.translate(lx, ly);
|
|
1416
|
+
if (e.labelRotation && Math.abs(e.labelRotation) > 0.001) {
|
|
1417
|
+
ctx.rotate(e.labelRotation);
|
|
1418
|
+
}
|
|
1419
|
+
ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
1420
|
+
|
|
1421
|
+
const fixedW = e.edgeLabelWidth || null;
|
|
1422
|
+
const innerW = fixedW ? fixedW - PAD_X * 2 : null;
|
|
1423
|
+
const lines = fixedW ? wrapText(ctx, e.label, innerW) : [e.label];
|
|
1424
|
+
const textW = fixedW ? innerW : ctx.measureText(e.label).width;
|
|
1425
|
+
const boxW = textW + PAD_X * 2;
|
|
1426
|
+
const boxH = lines.length * lineHeight + PAD_Y * 2;
|
|
1427
|
+
|
|
1428
|
+
// Always store bbox (needed for resize handles, even when not selected)
|
|
1429
|
+
st.edgeLabelBBox[e.id] = {
|
|
1430
|
+
cx: lx,
|
|
1431
|
+
cy: ly,
|
|
1432
|
+
w: boxW,
|
|
1433
|
+
h: boxH,
|
|
1434
|
+
rotation: e.labelRotation || 0,
|
|
1435
|
+
};
|
|
1076
1436
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
textW + TEXT_PAD * 2, totalH + TEXT_PAD * 2);
|
|
1082
|
-
ctx.restore();
|
|
1437
|
+
const totalH = lines.length * lineHeight;
|
|
1438
|
+
const TEXT_PAD = 3;
|
|
1439
|
+
const isDark = document.documentElement.classList.contains("dark");
|
|
1440
|
+
const bgFill = isDark ? "rgba(3,7,18,0.82)" : "rgba(249,250,251,0.82)";
|
|
1083
1441
|
|
|
1084
|
-
|
|
1085
|
-
if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
|
|
1442
|
+
// Opaque background tight around the text — makes the arrow appear to pass behind.
|
|
1086
1443
|
ctx.save();
|
|
1087
|
-
ctx.
|
|
1088
|
-
ctx.
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1444
|
+
ctx.fillStyle = bgFill;
|
|
1445
|
+
ctx.fillRect(
|
|
1446
|
+
-textW / 2 - TEXT_PAD,
|
|
1447
|
+
-totalH / 2 - TEXT_PAD,
|
|
1448
|
+
textW + TEXT_PAD * 2,
|
|
1449
|
+
totalH + TEXT_PAD * 2,
|
|
1450
|
+
);
|
|
1092
1451
|
ctx.restore();
|
|
1093
|
-
}
|
|
1094
1452
|
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1453
|
+
// Dashed border box — only when the edge is selected
|
|
1454
|
+
if (st.selectedEdgeIds && st.selectedEdgeIds.includes(e.id)) {
|
|
1455
|
+
ctx.save();
|
|
1456
|
+
ctx.strokeStyle = "#9ca3af";
|
|
1457
|
+
ctx.lineWidth = 0.8;
|
|
1458
|
+
ctx.setLineDash([3, 3]);
|
|
1459
|
+
ctx.strokeRect(-boxW / 2, -boxH / 2, boxW, boxH);
|
|
1460
|
+
ctx.setLineDash([]);
|
|
1461
|
+
ctx.restore();
|
|
1462
|
+
}
|
|
1102
1463
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1464
|
+
ctx.fillStyle = "#6b7280";
|
|
1465
|
+
ctx.textAlign = "center";
|
|
1466
|
+
ctx.textBaseline = "middle";
|
|
1467
|
+
lines.forEach((line, i) => {
|
|
1468
|
+
const y = -totalH / 2 + i * lineHeight + lineHeight / 2;
|
|
1469
|
+
ctx.fillText(line, 0, y);
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
ctx.restore();
|
|
1473
|
+
});
|
|
1474
|
+
} catch (err) {
|
|
1475
|
+
console.error("[draw] EXCEPTION:", err);
|
|
1476
|
+
}
|
|
1106
1477
|
}
|
|
1107
1478
|
|
|
1108
1479
|
// ── Network event handlers ────────────────────────────────────────────────────
|
|
1109
1480
|
|
|
1110
1481
|
// Distance from point (px,py) to segment (ax,ay)-(bx,by) in canvas space.
|
|
1111
1482
|
function _distToSegment(px, py, ax, ay, bx, by) {
|
|
1112
|
-
const dx = bx - ax,
|
|
1483
|
+
const dx = bx - ax,
|
|
1484
|
+
dy = by - ay;
|
|
1113
1485
|
const lenSq = dx * dx + dy * dy;
|
|
1114
1486
|
if (lenSq === 0) return Math.hypot(px - ax, py - ay);
|
|
1115
1487
|
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
|
|
1116
1488
|
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
|
1117
1489
|
}
|
|
1118
1490
|
|
|
1119
|
-
|
|
1120
1491
|
function nodeContainsCanvasPoint(id, canvasPos) {
|
|
1121
|
-
const n
|
|
1492
|
+
const n = st.nodes.get(id);
|
|
1122
1493
|
const bn = st.network && st.network.body.nodes[id];
|
|
1123
|
-
if (!n || !bn || n.shapeType ===
|
|
1494
|
+
if (!n || !bn || n.shapeType === "anchor") return false;
|
|
1124
1495
|
|
|
1125
|
-
const shapeType = n.shapeType ||
|
|
1126
|
-
const defaults
|
|
1496
|
+
const shapeType = n.shapeType || "box";
|
|
1497
|
+
const defaults = SHAPE_DEFAULTS[shapeType] || [60, 28];
|
|
1127
1498
|
const W = n.nodeWidth || defaults[0];
|
|
1128
|
-
const H = shapeType ===
|
|
1499
|
+
const H = shapeType === "circle" ? W : n.nodeHeight || defaults[1];
|
|
1129
1500
|
|
|
1130
1501
|
const dx = canvasPos.x - bn.x;
|
|
1131
1502
|
const dy = canvasPos.y - bn.y;
|
|
@@ -1133,15 +1504,26 @@ function nodeContainsCanvasPoint(id, canvasPos) {
|
|
|
1133
1504
|
const lx = rot ? dx * Math.cos(-rot) - dy * Math.sin(-rot) : dx;
|
|
1134
1505
|
const ly = rot ? dx * Math.sin(-rot) + dy * Math.cos(-rot) : dy;
|
|
1135
1506
|
|
|
1136
|
-
if (shapeType ===
|
|
1507
|
+
if (shapeType === "circle" || shapeType === "ellipse") {
|
|
1137
1508
|
const rx = W / 2;
|
|
1138
1509
|
const ry = H / 2;
|
|
1139
|
-
return
|
|
1510
|
+
return (
|
|
1511
|
+
rx > 0 && ry > 0 && (lx * lx) / (rx * rx) + (ly * ly) / (ry * ry) <= 1
|
|
1512
|
+
);
|
|
1140
1513
|
}
|
|
1141
1514
|
|
|
1142
1515
|
return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
|
|
1143
1516
|
}
|
|
1144
1517
|
|
|
1518
|
+
function lowerZTargetContainsSource(sourceId, targetId) {
|
|
1519
|
+
if (!sourceId || !targetId) return false;
|
|
1520
|
+
const sourceZ = st.canonicalOrder.indexOf(sourceId);
|
|
1521
|
+
const targetZ = st.canonicalOrder.indexOf(targetId);
|
|
1522
|
+
if (sourceZ === -1 || targetZ === -1 || targetZ >= sourceZ) return false;
|
|
1523
|
+
const sourcePos = st.network && st.network.getPositions([sourceId])[sourceId];
|
|
1524
|
+
return !!(sourcePos && nodeContainsCanvasPoint(targetId, sourcePos));
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1145
1527
|
// Returns the topmost (highest z-order) node containing canvasPos.
|
|
1146
1528
|
// Ignores anchor nodes and respects st.canonicalOrder.
|
|
1147
1529
|
function topmostNodeAt(canvasPos) {
|
|
@@ -1155,15 +1537,15 @@ function topmostNodeAt(canvasPos) {
|
|
|
1155
1537
|
function edgeDrawLevel(edgeData) {
|
|
1156
1538
|
if (!edgeData) return -1;
|
|
1157
1539
|
const fromLevel = st.canonicalOrder.indexOf(edgeData.from);
|
|
1158
|
-
const toLevel
|
|
1540
|
+
const toLevel = st.canonicalOrder.indexOf(edgeData.to);
|
|
1159
1541
|
if (fromLevel === -1 && toLevel === -1) return -1;
|
|
1160
1542
|
if (fromLevel === -1) return toLevel;
|
|
1161
1543
|
if (toLevel === -1) return fromLevel;
|
|
1162
1544
|
|
|
1163
1545
|
const fromNode = st.nodes.get(edgeData.from);
|
|
1164
|
-
const toNode
|
|
1165
|
-
const fromIsAnchor = fromNode && fromNode.shapeType ===
|
|
1166
|
-
const toIsAnchor
|
|
1546
|
+
const toNode = st.nodes.get(edgeData.to);
|
|
1547
|
+
const fromIsAnchor = fromNode && fromNode.shapeType === "anchor";
|
|
1548
|
+
const toIsAnchor = toNode && toNode.shapeType === "anchor";
|
|
1167
1549
|
if (toIsAnchor && !fromIsAnchor) return fromLevel;
|
|
1168
1550
|
if (fromIsAnchor && !toIsAnchor) return toLevel;
|
|
1169
1551
|
return Math.max(fromLevel, toLevel);
|
|
@@ -1193,7 +1575,7 @@ function edgeLabelAtCanvasPoint(canvasPos) {
|
|
|
1193
1575
|
const bbox = st.edgeLabelBBox[edge.id];
|
|
1194
1576
|
if (!bbox) continue;
|
|
1195
1577
|
|
|
1196
|
-
const r
|
|
1578
|
+
const r = -(bbox.rotation || 0);
|
|
1197
1579
|
const dx = canvasPos.x - bbox.cx;
|
|
1198
1580
|
const dy = canvasPos.y - bbox.cy;
|
|
1199
1581
|
const lx = dx * Math.cos(r) - dy * Math.sin(r);
|
|
@@ -1212,24 +1594,28 @@ function edgeLabelAtCanvasPoint(canvasPos) {
|
|
|
1212
1594
|
function isEdgeInteractive(edge) {
|
|
1213
1595
|
if (!edge) return false;
|
|
1214
1596
|
const fromN = st.nodes.get(edge.from);
|
|
1215
|
-
const toN
|
|
1216
|
-
const isFreeArrow =
|
|
1597
|
+
const toN = st.nodes.get(edge.to);
|
|
1598
|
+
const isFreeArrow =
|
|
1599
|
+
fromN && fromN.shapeType === "anchor" && toN && toN.shapeType === "anchor";
|
|
1217
1600
|
return isFreeArrow ? !(fromN.locked && toN.locked) : !edge.edgeLocked;
|
|
1218
1601
|
}
|
|
1219
1602
|
|
|
1220
1603
|
function selectableEdgesForNodes(nodeIds) {
|
|
1221
1604
|
const selectedSet = new Set(nodeIds);
|
|
1222
|
-
return st.edges
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1605
|
+
return st.edges
|
|
1606
|
+
.get()
|
|
1607
|
+
.filter((e) => {
|
|
1608
|
+
if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
|
|
1609
|
+
return isEdgeInteractive(e);
|
|
1610
|
+
})
|
|
1611
|
+
.map((e) => e.id);
|
|
1226
1612
|
}
|
|
1227
1613
|
|
|
1228
1614
|
function selectNodesFromClick(nodeId, srcEvent) {
|
|
1229
1615
|
const additive = !!(srcEvent && (srcEvent.metaKey || srcEvent.ctrlKey));
|
|
1230
1616
|
const clicked = expandSelectionToGroup([nodeId]).filter((id) => {
|
|
1231
1617
|
const n = st.nodes.get(id);
|
|
1232
|
-
return n && !n.locked && n.shapeType !==
|
|
1618
|
+
return n && !n.locked && n.shapeType !== "anchor";
|
|
1233
1619
|
});
|
|
1234
1620
|
if (!clicked.length) return;
|
|
1235
1621
|
|
|
@@ -1243,7 +1629,7 @@ function selectNodesFromClick(nodeId, srcEvent) {
|
|
|
1243
1629
|
});
|
|
1244
1630
|
nodeIds = Array.from(next).filter((id) => {
|
|
1245
1631
|
const n = st.nodes.get(id);
|
|
1246
|
-
return n && !n.locked && n.shapeType !==
|
|
1632
|
+
return n && !n.locked && n.shapeType !== "anchor";
|
|
1247
1633
|
});
|
|
1248
1634
|
} else {
|
|
1249
1635
|
nodeIds = clicked;
|
|
@@ -1267,62 +1653,107 @@ function selectNodesFromClick(nodeId, srcEvent) {
|
|
|
1267
1653
|
|
|
1268
1654
|
function onDoubleClick(params) {
|
|
1269
1655
|
const srcEvent = params.event && params.event.srcEvent;
|
|
1270
|
-
const clientPos = srcEvent
|
|
1656
|
+
const clientPos = srcEvent
|
|
1657
|
+
? { x: srcEvent.clientX, y: srcEvent.clientY }
|
|
1658
|
+
: null;
|
|
1271
1659
|
const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
|
|
1272
|
-
const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
|
|
1273
|
-
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1274
|
-
const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
|
|
1275
|
-
.filter((id, index, list) => id && list.indexOf(id) === index)
|
|
1276
|
-
.filter((id) => isEdgeInteractive(st.edges.get(id)));
|
|
1277
|
-
const edgeId = edgeCandidates.reduce((bestId, id) => {
|
|
1278
|
-
if (!bestId) return id;
|
|
1279
|
-
return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
|
|
1280
|
-
}, null);
|
|
1281
|
-
|
|
1282
1660
|
const topNodeId = topmostNodeAt(params.pointer.canvas);
|
|
1283
1661
|
const topNode = topNodeId && st.nodes.get(topNodeId);
|
|
1284
|
-
const canEditTopNode =
|
|
1285
|
-
|
|
1662
|
+
const canEditTopNode =
|
|
1663
|
+
topNode && !topNode.locked && topNode.shapeType !== "anchor";
|
|
1286
1664
|
|
|
1287
|
-
|
|
1665
|
+
// Direct node double-clicks must edit the node. Dense port-edge diagrams can
|
|
1666
|
+
// have many visual edges crossing a node; those should not steal the edit.
|
|
1667
|
+
// The only exception is an actual edge-label hit, which is intentional.
|
|
1668
|
+
if (canEditTopNode && !labelEdgeId) {
|
|
1288
1669
|
st.selectedNodeIds = [topNodeId];
|
|
1289
1670
|
st.selectedEdgeIds = [];
|
|
1290
1671
|
st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
|
|
1291
1672
|
showNodePanel();
|
|
1292
1673
|
hideEdgePanel();
|
|
1293
1674
|
startLabelEdit();
|
|
1294
|
-
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
|
|
1679
|
+
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1680
|
+
const edgeCandidates = [
|
|
1681
|
+
labelEdgeId,
|
|
1682
|
+
nativeEdgeId,
|
|
1683
|
+
portEdge && portEdge.id,
|
|
1684
|
+
...params.edges,
|
|
1685
|
+
]
|
|
1686
|
+
.filter((id, index, list) => id && list.indexOf(id) === index)
|
|
1687
|
+
.filter((id) => isEdgeInteractive(st.edges.get(id)));
|
|
1688
|
+
const edgeId = edgeCandidates.reduce((bestId, id) => {
|
|
1689
|
+
if (!bestId) return id;
|
|
1690
|
+
return edgeDrawLevel(st.edges.get(id)) >=
|
|
1691
|
+
edgeDrawLevel(st.edges.get(bestId))
|
|
1692
|
+
? id
|
|
1693
|
+
: bestId;
|
|
1694
|
+
}, null);
|
|
1695
|
+
|
|
1696
|
+
if (edgeId) {
|
|
1295
1697
|
st.selectedNodeIds = [];
|
|
1296
1698
|
st.selectedEdgeIds = [edgeId];
|
|
1297
1699
|
st.network.setSelection({ nodes: [], edges: [edgeId] });
|
|
1298
1700
|
hideNodePanel();
|
|
1299
1701
|
showEdgePanel();
|
|
1300
1702
|
startEdgeLabelEdit();
|
|
1301
|
-
} else if (st.currentTool ===
|
|
1703
|
+
} else if (st.currentTool === "addNode" && st.pendingShape === "image") {
|
|
1302
1704
|
const canvasPos = params.pointer.canvas;
|
|
1303
1705
|
pickAndCreateImageNode(canvasPos.x, canvasPos.y);
|
|
1304
|
-
} else if (st.currentTool ===
|
|
1706
|
+
} else if (st.currentTool === "addNode") {
|
|
1305
1707
|
pushSnapshot();
|
|
1306
|
-
const id
|
|
1307
|
-
const
|
|
1308
|
-
const
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
const
|
|
1708
|
+
const id = "n" + Date.now();
|
|
1709
|
+
const customShapeId = customShapeIdFromTool(st.pendingShape);
|
|
1710
|
+
const shapeType = customShapeId ? CUSTOM_SHAPE_TYPE : st.pendingShape;
|
|
1711
|
+
const customDef = customShapeId
|
|
1712
|
+
? getCustomShapeDefinition(customShapeId)
|
|
1713
|
+
: null;
|
|
1714
|
+
const defaults = customShapeId
|
|
1715
|
+
? getCustomShapeDefaultSize(customShapeId)
|
|
1716
|
+
: SHAPE_DEFAULTS[shapeType] || [100, 40];
|
|
1717
|
+
const fallbackColor = shapeType === "post-it" ? "c-amber" : "c-gray";
|
|
1718
|
+
const lastStyle = getLastNodeStyle(shapeType);
|
|
1719
|
+
const colorKey = lastStyle.colorKey || fallbackColor;
|
|
1720
|
+
const fontSize = lastStyle.fontSize || null;
|
|
1721
|
+
const textAlign = lastStyle.textAlign || null;
|
|
1313
1722
|
const textValign = lastStyle.textValign || null;
|
|
1314
|
-
const rawPos
|
|
1315
|
-
const pos
|
|
1723
|
+
const rawPos = params.pointer.canvas;
|
|
1724
|
+
const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
|
|
1316
1725
|
st.nodes.add({
|
|
1317
|
-
id,
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1726
|
+
id,
|
|
1727
|
+
label:
|
|
1728
|
+
shapeType === "text-free"
|
|
1729
|
+
? t("diagram.label_input.placeholder")
|
|
1730
|
+
: customDef
|
|
1731
|
+
? customDef.name
|
|
1732
|
+
: "Node",
|
|
1733
|
+
shapeType,
|
|
1734
|
+
customShapeId: customShapeId || null,
|
|
1735
|
+
colorKey,
|
|
1736
|
+
nodeWidth: defaults[0],
|
|
1737
|
+
nodeHeight: defaults[1],
|
|
1738
|
+
fontSize,
|
|
1739
|
+
textAlign,
|
|
1740
|
+
textValign,
|
|
1741
|
+
rotation: 0,
|
|
1742
|
+
labelRotation: 0,
|
|
1743
|
+
x: pos.x,
|
|
1744
|
+
y: pos.y,
|
|
1745
|
+
...visNodeProps(
|
|
1746
|
+
shapeType,
|
|
1747
|
+
colorKey,
|
|
1748
|
+
defaults[0],
|
|
1749
|
+
defaults[1],
|
|
1750
|
+
fontSize,
|
|
1751
|
+
textAlign,
|
|
1752
|
+
textValign,
|
|
1753
|
+
),
|
|
1324
1754
|
});
|
|
1325
1755
|
markDirty();
|
|
1756
|
+
|
|
1326
1757
|
setTimeout(() => {
|
|
1327
1758
|
st.network.selectNodes([id]);
|
|
1328
1759
|
st.selectedNodeIds = [id];
|
|
@@ -1335,9 +1766,9 @@ function onDoubleClick(params) {
|
|
|
1335
1766
|
// ── Image node creation ───────────────────────────────────────────────────────
|
|
1336
1767
|
|
|
1337
1768
|
function pickAndCreateImageNode(canvasX, canvasY) {
|
|
1338
|
-
const input = document.createElement(
|
|
1339
|
-
input.type
|
|
1340
|
-
input.accept =
|
|
1769
|
+
const input = document.createElement("input");
|
|
1770
|
+
input.type = "file";
|
|
1771
|
+
input.accept = "image/*";
|
|
1341
1772
|
input.onchange = async () => {
|
|
1342
1773
|
const file = input.files && input.files[0];
|
|
1343
1774
|
if (!file) return;
|
|
@@ -1347,7 +1778,7 @@ function pickAndCreateImageNode(canvasX, canvasY) {
|
|
|
1347
1778
|
const src = await uploadImageFile(file, name);
|
|
1348
1779
|
createImageNode(src, canvasX, canvasY);
|
|
1349
1780
|
} catch {
|
|
1350
|
-
showToast(t(
|
|
1781
|
+
showToast(t("diagram.toast.image_import_error"), "error");
|
|
1351
1782
|
}
|
|
1352
1783
|
};
|
|
1353
1784
|
input.click();
|
|
@@ -1355,32 +1786,47 @@ function pickAndCreateImageNode(canvasX, canvasY) {
|
|
|
1355
1786
|
|
|
1356
1787
|
export function createImageNode(imageSrc, canvasX, canvasY) {
|
|
1357
1788
|
if (!st.network) return;
|
|
1358
|
-
const id
|
|
1359
|
-
const captionId = id +
|
|
1789
|
+
const id = "n" + Date.now();
|
|
1790
|
+
const captionId = id + "c";
|
|
1360
1791
|
|
|
1361
1792
|
const addNode = (nW, nH) => {
|
|
1362
1793
|
pushSnapshot();
|
|
1363
|
-
const filename
|
|
1364
|
-
const textDefs
|
|
1365
|
-
const captionH
|
|
1366
|
-
const GAP
|
|
1794
|
+
const filename = imageSrc.split("/").pop() || "";
|
|
1795
|
+
const textDefs = SHAPE_DEFAULTS["text-free"];
|
|
1796
|
+
const captionH = textDefs[1];
|
|
1797
|
+
const GAP = 8;
|
|
1367
1798
|
|
|
1368
|
-
const groupId =
|
|
1799
|
+
const groupId = "g" + Date.now();
|
|
1369
1800
|
st.nodes.add({
|
|
1370
|
-
id,
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1801
|
+
id,
|
|
1802
|
+
label: "",
|
|
1803
|
+
imageSrc,
|
|
1804
|
+
groupId,
|
|
1805
|
+
shapeType: "image",
|
|
1806
|
+
colorKey: "c-gray",
|
|
1807
|
+
nodeWidth: nW,
|
|
1808
|
+
nodeHeight: nH,
|
|
1809
|
+
fontSize: null,
|
|
1810
|
+
rotation: 0,
|
|
1811
|
+
labelRotation: 0,
|
|
1812
|
+
x: canvasX,
|
|
1813
|
+
y: canvasY,
|
|
1814
|
+
...visNodeProps("image", "c-gray", nW, nH, null, null, null),
|
|
1376
1815
|
});
|
|
1377
1816
|
st.nodes.add({
|
|
1378
|
-
id: captionId,
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1817
|
+
id: captionId,
|
|
1818
|
+
label: filename,
|
|
1819
|
+
groupId,
|
|
1820
|
+
shapeType: "text-free",
|
|
1821
|
+
colorKey: "c-gray",
|
|
1822
|
+
nodeWidth: nW,
|
|
1823
|
+
nodeHeight: captionH,
|
|
1824
|
+
fontSize: null,
|
|
1825
|
+
rotation: 0,
|
|
1826
|
+
labelRotation: 0,
|
|
1827
|
+
x: canvasX,
|
|
1828
|
+
y: canvasY + nH / 2 + GAP + captionH / 2,
|
|
1829
|
+
...visNodeProps("text-free", "c-gray", nW, captionH, null, null, null),
|
|
1384
1830
|
});
|
|
1385
1831
|
markDirty();
|
|
1386
1832
|
setTimeout(() => {
|
|
@@ -1394,12 +1840,16 @@ export function createImageNode(imageSrc, canvasX, canvasY) {
|
|
|
1394
1840
|
img.onload = () => {
|
|
1395
1841
|
const MAX = 300;
|
|
1396
1842
|
const ratio = img.naturalWidth / img.naturalHeight;
|
|
1397
|
-
let nW = img.naturalWidth,
|
|
1398
|
-
|
|
1843
|
+
let nW = img.naturalWidth,
|
|
1844
|
+
nH = img.naturalHeight;
|
|
1845
|
+
if (nW > MAX) {
|
|
1846
|
+
nW = MAX;
|
|
1847
|
+
nH = Math.round(MAX / ratio);
|
|
1848
|
+
}
|
|
1399
1849
|
addNode(nW, nH);
|
|
1400
1850
|
};
|
|
1401
1851
|
img.onerror = () => {
|
|
1402
|
-
const d = SHAPE_DEFAULTS[
|
|
1852
|
+
const d = SHAPE_DEFAULTS["image"];
|
|
1403
1853
|
addNode(d[0], d[1]);
|
|
1404
1854
|
};
|
|
1405
1855
|
img.src = imageSrc;
|
|
@@ -1410,17 +1860,20 @@ export function toggleEdgeStraight() {
|
|
|
1410
1860
|
if (!st.network) return;
|
|
1411
1861
|
pushSnapshot();
|
|
1412
1862
|
st.edgesStraight = !st.edgesStraight;
|
|
1413
|
-
const smooth = st.edgesStraight ? { enabled: false } : { type:
|
|
1863
|
+
const smooth = st.edgesStraight ? { enabled: false } : { type: "continuous" };
|
|
1414
1864
|
// Update global network option first (overrides per-edge inherited defaults).
|
|
1415
1865
|
st.network.setOptions({ edges: { smooth } });
|
|
1416
1866
|
// Then update each edge individually, keeping anchor edges always straight.
|
|
1417
1867
|
const updates = st.edges.get().map((e) => {
|
|
1418
1868
|
const toData = st.nodes.get(e.to);
|
|
1419
|
-
const s =
|
|
1869
|
+
const s =
|
|
1870
|
+
toData && toData.shapeType === "anchor" ? { enabled: false } : smooth;
|
|
1420
1871
|
return { id: e.id, smooth: s };
|
|
1421
1872
|
});
|
|
1422
1873
|
if (updates.length) st.edges.update(updates);
|
|
1423
|
-
document
|
|
1874
|
+
document
|
|
1875
|
+
.getElementById("btnEdgeStraight")
|
|
1876
|
+
.classList.toggle("tool-active", st.edgesStraight);
|
|
1424
1877
|
markDirty();
|
|
1425
1878
|
}
|
|
1426
1879
|
|
|
@@ -1435,15 +1888,24 @@ function onClickNode(params) {
|
|
|
1435
1888
|
// endpoint nodes, reconnect that end to the new port without losing selection.
|
|
1436
1889
|
if (_rehookEdgeId && _rehookHoveredNodeId && _rehookHoveredPortKey) {
|
|
1437
1890
|
const edgeData = st.edges.get(_rehookEdgeId);
|
|
1438
|
-
if (
|
|
1891
|
+
if (
|
|
1892
|
+
edgeData &&
|
|
1893
|
+
(edgeData.from === _rehookHoveredNodeId ||
|
|
1894
|
+
edgeData.to === _rehookHoveredNodeId)
|
|
1895
|
+
) {
|
|
1439
1896
|
const wasPortEdge = !!(edgeData.fromPort || edgeData.toPort);
|
|
1440
1897
|
const update = { id: edgeData.id };
|
|
1441
|
-
if (edgeData.from === _rehookHoveredNodeId)
|
|
1442
|
-
|
|
1898
|
+
if (edgeData.from === _rehookHoveredNodeId)
|
|
1899
|
+
update.fromPort = _rehookHoveredPortKey;
|
|
1900
|
+
else update.toPort = _rehookHoveredPortKey;
|
|
1443
1901
|
// First port assigned on a native edge: hide vis-network's rendering so
|
|
1444
1902
|
// drawPortEdge() takes over without double-rendering.
|
|
1445
1903
|
if (!wasPortEdge) {
|
|
1446
|
-
update.color
|
|
1904
|
+
update.color = {
|
|
1905
|
+
color: "rgba(0,0,0,0)",
|
|
1906
|
+
highlight: "rgba(0,0,0,0)",
|
|
1907
|
+
hover: "rgba(0,0,0,0)",
|
|
1908
|
+
};
|
|
1447
1909
|
update.arrows = { to: { enabled: false }, from: { enabled: false } };
|
|
1448
1910
|
}
|
|
1449
1911
|
pushSnapshot();
|
|
@@ -1467,36 +1929,45 @@ function onClickNode(params) {
|
|
|
1467
1929
|
// passing it to getEdgeAt() causes a double-subtraction of the container rect and
|
|
1468
1930
|
// returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
|
|
1469
1931
|
if (params.nodes.length > 0) {
|
|
1470
|
-
const
|
|
1471
|
-
const nativeEdgeId = st.network.getEdgeAt(clientPos);
|
|
1472
|
-
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1473
|
-
const edgeId = nativeEdgeId || (portEdge && portEdge.id);
|
|
1474
|
-
|
|
1475
|
-
// First honour the app-managed z-order. vis-network may report an edge or a
|
|
1476
|
-
// lower node at the click point; compare draw levels so the visibly top
|
|
1477
|
-
// element gets the click.
|
|
1932
|
+
const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
|
|
1478
1933
|
const top = topmostNodeAt(params.pointer.canvas);
|
|
1479
1934
|
if (top) {
|
|
1480
1935
|
const topNode = st.nodes.get(top);
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
}
|
|
1936
|
+
if (
|
|
1937
|
+
topNode &&
|
|
1938
|
+
!topNode.locked &&
|
|
1939
|
+
topNode.shapeType !== "anchor" &&
|
|
1940
|
+
!labelEdgeId
|
|
1941
|
+
) {
|
|
1942
|
+
setTimeout(() => {
|
|
1943
|
+
selectNodesFromClick(top, params.event.srcEvent);
|
|
1944
|
+
}, 0);
|
|
1945
|
+
return;
|
|
1490
1946
|
}
|
|
1491
1947
|
}
|
|
1948
|
+
|
|
1949
|
+
const clientPos = {
|
|
1950
|
+
x: params.event.srcEvent.clientX,
|
|
1951
|
+
y: params.event.srcEvent.clientY,
|
|
1952
|
+
};
|
|
1953
|
+
const nativeEdgeId = st.network.getEdgeAt(clientPos);
|
|
1954
|
+
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1955
|
+
const edgeId = labelEdgeId || nativeEdgeId || (portEdge && portEdge.id);
|
|
1492
1956
|
if (edgeId) {
|
|
1493
|
-
const edge
|
|
1957
|
+
const edge = st.edges.get(edgeId);
|
|
1494
1958
|
const fromN = edge && st.nodes.get(edge.from);
|
|
1495
|
-
const toN
|
|
1496
|
-
const isFreeArrow =
|
|
1959
|
+
const toN = edge && st.nodes.get(edge.to);
|
|
1960
|
+
const isFreeArrow =
|
|
1961
|
+
fromN &&
|
|
1962
|
+
fromN.shapeType === "anchor" &&
|
|
1963
|
+
toN &&
|
|
1964
|
+
toN.shapeType === "anchor";
|
|
1497
1965
|
// If the user clicked directly on an anchor node (not the edge body), keep only
|
|
1498
1966
|
// that single anchor selected so dragging pivots the arrow instead of moving it whole.
|
|
1499
|
-
const clickedAnAnchor = params.nodes.some(id => {
|
|
1967
|
+
const clickedAnAnchor = params.nodes.some((id) => {
|
|
1968
|
+
const n = st.nodes.get(id);
|
|
1969
|
+
return n && n.shapeType === "anchor";
|
|
1970
|
+
});
|
|
1500
1971
|
if (isFreeArrow && clickedAnAnchor) return;
|
|
1501
1972
|
setTimeout(() => {
|
|
1502
1973
|
const sel = isFreeArrow
|
|
@@ -1515,9 +1986,9 @@ function onClickNode(params) {
|
|
|
1515
1986
|
}
|
|
1516
1987
|
// No edge at click position — apply z-order correction for the topmost node.
|
|
1517
1988
|
const fallbackTop = topmostNodeAt(params.pointer.canvas);
|
|
1518
|
-
const clickable = params.nodes.filter(id => {
|
|
1989
|
+
const clickable = params.nodes.filter((id) => {
|
|
1519
1990
|
const n = st.nodes.get(id);
|
|
1520
|
-
return n && !n.locked && n.shapeType !==
|
|
1991
|
+
return n && !n.locked && n.shapeType !== "anchor";
|
|
1521
1992
|
});
|
|
1522
1993
|
if (!fallbackTop || !clickable.includes(fallbackTop)) {
|
|
1523
1994
|
const fallbackEdgeId = params.edges.length > 0 ? params.edges[0] : null;
|
|
@@ -1541,7 +2012,7 @@ function onClickNode(params) {
|
|
|
1541
2012
|
// misses them when they diverge from the centre-to-centre line.
|
|
1542
2013
|
// Also check the canvas for port-edge proximity when nothing was hit.
|
|
1543
2014
|
if (params.nodes.length === 0 && params.edges.length === 0) {
|
|
1544
|
-
const cp
|
|
2015
|
+
const cp = params.pointer.canvas;
|
|
1545
2016
|
|
|
1546
2017
|
// ── Port edge proximity check ──────────────────────────────────────────
|
|
1547
2018
|
// vis-network's hit detection uses the invisible centre-to-centre ghost,
|
|
@@ -1570,7 +2041,7 @@ function onDragStart(params) {
|
|
|
1570
2041
|
_draggingAnchorIds.clear();
|
|
1571
2042
|
for (const id of params.nodes) {
|
|
1572
2043
|
const n = st.nodes && st.nodes.get(id);
|
|
1573
|
-
if (n && n.shapeType ===
|
|
2044
|
+
if (n && n.shapeType === "anchor") _draggingAnchorIds.add(id);
|
|
1574
2045
|
}
|
|
1575
2046
|
const expanded = expandSelectionToGroup(params.nodes);
|
|
1576
2047
|
if (expanded.length > params.nodes.length) {
|
|
@@ -1584,17 +2055,32 @@ let _addingEdgesToSelection = false;
|
|
|
1584
2055
|
function onSelectNode(params) {
|
|
1585
2056
|
if (_expandingGroup || _addingEdgesToSelection) return;
|
|
1586
2057
|
// Filter out anchor nodes — they have no formatting panel.
|
|
1587
|
-
const nonAnchors = params.nodes.filter((id) => {
|
|
2058
|
+
const nonAnchors = params.nodes.filter((id) => {
|
|
2059
|
+
const n = st.nodes.get(id);
|
|
2060
|
+
return !(n && n.shapeType === "anchor");
|
|
2061
|
+
});
|
|
1588
2062
|
// Drop locked nodes: they are non-interactive until unlocked via long-press.
|
|
1589
|
-
const usable = nonAnchors.filter((id) => {
|
|
2063
|
+
const usable = nonAnchors.filter((id) => {
|
|
2064
|
+
const n = st.nodes.get(id);
|
|
2065
|
+
return n && !n.locked;
|
|
2066
|
+
});
|
|
1590
2067
|
if (usable.length !== nonAnchors.length) {
|
|
1591
2068
|
_addingEdgesToSelection = true;
|
|
1592
|
-
const anchorIds = params.nodes.filter((id) => {
|
|
1593
|
-
|
|
2069
|
+
const anchorIds = params.nodes.filter((id) => {
|
|
2070
|
+
const n = st.nodes.get(id);
|
|
2071
|
+
return n && n.shapeType === "anchor";
|
|
2072
|
+
});
|
|
2073
|
+
st.network.setSelection({
|
|
2074
|
+
nodes: [...usable, ...anchorIds],
|
|
2075
|
+
edges: st.network.getSelectedEdges(),
|
|
2076
|
+
});
|
|
1594
2077
|
_addingEdgesToSelection = false;
|
|
1595
2078
|
}
|
|
1596
2079
|
if (!usable.length) {
|
|
1597
|
-
const anchorIds = params.nodes.filter((id) => {
|
|
2080
|
+
const anchorIds = params.nodes.filter((id) => {
|
|
2081
|
+
const n = st.nodes.get(id);
|
|
2082
|
+
return n && n.shapeType === "anchor";
|
|
2083
|
+
});
|
|
1598
2084
|
if (anchorIds.length) {
|
|
1599
2085
|
// Only anchors selected (individual endpoint drag) — keep selection but no panel.
|
|
1600
2086
|
st.selectedNodeIds = anchorIds;
|
|
@@ -1608,7 +2094,10 @@ function onSelectNode(params) {
|
|
|
1608
2094
|
hideEdgePanel();
|
|
1609
2095
|
return;
|
|
1610
2096
|
}
|
|
1611
|
-
const expanded = expandSelectionToGroup(usable).filter((id) => {
|
|
2097
|
+
const expanded = expandSelectionToGroup(usable).filter((id) => {
|
|
2098
|
+
const n = st.nodes.get(id);
|
|
2099
|
+
return n && !n.locked;
|
|
2100
|
+
});
|
|
1612
2101
|
if (expanded.length > usable.length) {
|
|
1613
2102
|
_expandingGroup = true;
|
|
1614
2103
|
st.network.selectNodes(expanded);
|
|
@@ -1621,18 +2110,28 @@ function onSelectNode(params) {
|
|
|
1621
2110
|
// This makes rubber-band select and multi-select automatically include connected edges.
|
|
1622
2111
|
// Exclude locked edges — they are non-interactive.
|
|
1623
2112
|
const selectedSet = new Set(st.selectedNodeIds);
|
|
1624
|
-
st.selectedEdgeIds = st.edges
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
2113
|
+
st.selectedEdgeIds = st.edges
|
|
2114
|
+
.get()
|
|
2115
|
+
.filter((e) => {
|
|
2116
|
+
if (!selectedSet.has(e.from) || !selectedSet.has(e.to)) return false;
|
|
2117
|
+
const fromN = st.nodes.get(e.from);
|
|
2118
|
+
const toN = st.nodes.get(e.to);
|
|
2119
|
+
const isFreeArrow =
|
|
2120
|
+
fromN &&
|
|
2121
|
+
fromN.shapeType === "anchor" &&
|
|
2122
|
+
toN &&
|
|
2123
|
+
toN.shapeType === "anchor";
|
|
2124
|
+
return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
|
|
2125
|
+
})
|
|
2126
|
+
.map((e) => e.id);
|
|
1631
2127
|
// Tell vis-network to visually highlight the free-arrow edges.
|
|
1632
2128
|
// Use a guard to prevent the resulting selectNode event from re-entering.
|
|
1633
2129
|
if (st.selectedEdgeIds.length > 0) {
|
|
1634
2130
|
_addingEdgesToSelection = true;
|
|
1635
|
-
st.network.setSelection({
|
|
2131
|
+
st.network.setSelection({
|
|
2132
|
+
nodes: st.network.getSelectedNodes(),
|
|
2133
|
+
edges: st.selectedEdgeIds,
|
|
2134
|
+
});
|
|
1636
2135
|
_addingEdgesToSelection = false;
|
|
1637
2136
|
}
|
|
1638
2137
|
hideEdgePanel();
|
|
@@ -1647,12 +2146,19 @@ function onSelectEdge(params) {
|
|
|
1647
2146
|
const e = st.edges.get(id);
|
|
1648
2147
|
if (!e) return false;
|
|
1649
2148
|
const fromN = st.nodes.get(e.from);
|
|
1650
|
-
const toN
|
|
1651
|
-
const isFreeArrow =
|
|
2149
|
+
const toN = st.nodes.get(e.to);
|
|
2150
|
+
const isFreeArrow =
|
|
2151
|
+
fromN &&
|
|
2152
|
+
fromN.shapeType === "anchor" &&
|
|
2153
|
+
toN &&
|
|
2154
|
+
toN.shapeType === "anchor";
|
|
1652
2155
|
return isFreeArrow ? !(fromN.locked && toN.locked) : !e.edgeLocked;
|
|
1653
2156
|
});
|
|
1654
2157
|
if (usable.length !== params.edges.length) {
|
|
1655
|
-
st.network.setSelection({
|
|
2158
|
+
st.network.setSelection({
|
|
2159
|
+
nodes: st.network.getSelectedNodes(),
|
|
2160
|
+
edges: usable,
|
|
2161
|
+
});
|
|
1656
2162
|
}
|
|
1657
2163
|
if (!usable.length) {
|
|
1658
2164
|
st.selectedEdgeIds = [];
|
|
@@ -1672,8 +2178,13 @@ function onSelectEdge(params) {
|
|
|
1672
2178
|
const e = st.edges.get(edgeId);
|
|
1673
2179
|
if (!e) continue;
|
|
1674
2180
|
const fromN = st.nodes.get(e.from);
|
|
1675
|
-
const toN
|
|
1676
|
-
if (
|
|
2181
|
+
const toN = st.nodes.get(e.to);
|
|
2182
|
+
if (
|
|
2183
|
+
fromN &&
|
|
2184
|
+
fromN.shapeType === "anchor" &&
|
|
2185
|
+
toN &&
|
|
2186
|
+
toN.shapeType === "anchor"
|
|
2187
|
+
) {
|
|
1677
2188
|
freeAnchors.push(e.from, e.to);
|
|
1678
2189
|
}
|
|
1679
2190
|
}
|