living-documentation 3.5.0 → 3.6.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.
Potentially problematic release.
This version of living-documentation might be problematic. Click here for more details.
- package/dist/src/frontend/diagram/clipboard.js +58 -0
- package/dist/src/frontend/diagram/constants.js +32 -0
- package/dist/src/frontend/diagram/debug.js +43 -0
- package/dist/src/frontend/diagram/edge-panel.js +61 -0
- package/dist/src/frontend/diagram/edge-rendering.js +12 -0
- package/dist/src/frontend/diagram/grid.js +68 -0
- package/dist/src/frontend/diagram/label-editor.js +90 -0
- package/dist/src/frontend/diagram/main.js +158 -0
- package/dist/src/frontend/diagram/network.js +168 -0
- package/dist/src/frontend/diagram/node-panel.js +73 -0
- package/dist/src/frontend/diagram/node-rendering.js +113 -0
- package/dist/src/frontend/diagram/persistence.js +138 -0
- package/dist/src/frontend/diagram/selection-overlay.js +149 -0
- package/dist/src/frontend/diagram/state.js +29 -0
- package/dist/src/frontend/diagram/zoom.js +20 -0
- package/dist/src/frontend/diagram.html +736 -1128
- package/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ── Network initialisation & vis.js event handlers ────────────────────────────
|
|
2
|
+
// Creates the vis.js Network, patches _drawNodes for canonical z-order,
|
|
3
|
+
// and wires all network-level events.
|
|
4
|
+
|
|
5
|
+
import { st, markDirty } from './state.js';
|
|
6
|
+
import { visNodeProps } from './node-rendering.js';
|
|
7
|
+
import { visEdgeProps } from './edge-rendering.js';
|
|
8
|
+
import { showNodePanel, hideNodePanel } from './node-panel.js';
|
|
9
|
+
import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
|
|
10
|
+
import { startLabelEdit, startEdgeLabelEdit, commitLabelEdit, hideLabelInput } from './label-editor.js';
|
|
11
|
+
import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overlay.js';
|
|
12
|
+
import { drawGrid, onDragEnd } from './grid.js';
|
|
13
|
+
import { drawDebugOverlay } from './debug.js';
|
|
14
|
+
import { updateZoomDisplay } from './zoom.js';
|
|
15
|
+
|
|
16
|
+
export function initNetwork(savedNodes, savedEdges) {
|
|
17
|
+
const container = document.getElementById('vis-canvas');
|
|
18
|
+
|
|
19
|
+
st.nodes = new vis.DataSet(
|
|
20
|
+
savedNodes.map((n) => ({
|
|
21
|
+
...n,
|
|
22
|
+
...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign),
|
|
23
|
+
}))
|
|
24
|
+
);
|
|
25
|
+
st.edges = new vis.DataSet(
|
|
26
|
+
savedEdges.map((e) => ({
|
|
27
|
+
...e,
|
|
28
|
+
...visEdgeProps(e.arrowDir ?? 'to', e.dashes ?? false),
|
|
29
|
+
...(e.fontSize ? { font: { size: e.fontSize, align: 'middle', color: '#6b7280' } } : {}),
|
|
30
|
+
}))
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const options = {
|
|
34
|
+
physics: {
|
|
35
|
+
enabled: st.physicsEnabled,
|
|
36
|
+
stabilization: { enabled: false },
|
|
37
|
+
barnesHut: { gravitationalConstant: -3000, centralGravity: 0.3, springLength: 150, springConstant: 0.04, damping: 0.09 },
|
|
38
|
+
},
|
|
39
|
+
interaction: { hover: true, navigationButtons: false, keyboard: false, multiselect: true },
|
|
40
|
+
nodes: { font: { size: 13, face: 'system-ui,-apple-system,sans-serif' }, borderWidth: 1.5, borderWidthSelected: 2.5, shadow: false, widthConstraint: { minimum: 60 }, heightConstraint: { minimum: 28 } },
|
|
41
|
+
edges: { smooth: { type: 'continuous' }, color: { color: '#a8a29e', highlight: '#f97316', hover: '#f97316' }, width: 1.5, selectionWidth: 2.5, font: { size: 11, align: 'middle', color: '#6b7280' } },
|
|
42
|
+
manipulation: {
|
|
43
|
+
enabled: false,
|
|
44
|
+
addEdge(data, callback) {
|
|
45
|
+
data.id = 'e' + Date.now();
|
|
46
|
+
data.arrowDir = 'to';
|
|
47
|
+
data.dashes = false;
|
|
48
|
+
Object.assign(data, visEdgeProps('to', false));
|
|
49
|
+
callback(data);
|
|
50
|
+
markDirty();
|
|
51
|
+
setTimeout(() => st.network.addEdgeMode(), 0);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (st.network) st.network.destroy();
|
|
57
|
+
st.network = new vis.Network(container, { nodes: st.nodes, edges: st.edges }, options);
|
|
58
|
+
|
|
59
|
+
// ── Z-order patch ──────────────────────────────────────────────────────────
|
|
60
|
+
// vis.js renders in 3 passes (normal → selected → hovered), which breaks
|
|
61
|
+
// user-defined stacking. We replace _drawNodes with a single pass in
|
|
62
|
+
// canonicalOrder so hover/selection never override the user's z-order.
|
|
63
|
+
st.canonicalOrder = [...st.network.body.nodeIndices];
|
|
64
|
+
st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
|
|
65
|
+
const bodyNodes = this.body.nodes;
|
|
66
|
+
const margin = 20;
|
|
67
|
+
const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
|
|
68
|
+
const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
|
|
69
|
+
const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
|
|
70
|
+
const drawExternalLabelCallbacks = [];
|
|
71
|
+
|
|
72
|
+
for (const id of st.canonicalOrder) {
|
|
73
|
+
const node = bodyNodes[id];
|
|
74
|
+
if (!node) continue;
|
|
75
|
+
if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
|
|
76
|
+
const r = node.draw(ctx);
|
|
77
|
+
// Custom shapes (actor) draw their own label inside drawNode(); skip
|
|
78
|
+
// vis-network's external-label callback to avoid double-rendering.
|
|
79
|
+
if (r.drawExternalLabel != null && node.options.shape !== 'custom') {
|
|
80
|
+
drawExternalLabelCallbacks.push(r.drawExternalLabel);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
node.updateBoundingBox(ctx, node.selected);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { drawExternalLabels() { for (const draw of drawExternalLabelCallbacks) draw(); } };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Keep canonicalOrder in sync with DataSet add/remove events
|
|
90
|
+
st.nodes.on('add', (_, { items }) => {
|
|
91
|
+
const existing = new Set(st.canonicalOrder);
|
|
92
|
+
items.forEach((id) => { if (!existing.has(id)) st.canonicalOrder.push(id); });
|
|
93
|
+
});
|
|
94
|
+
st.nodes.on('remove', (_, { items }) => {
|
|
95
|
+
const removed = new Set(items);
|
|
96
|
+
st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
st.network.on('doubleClick', onDoubleClick);
|
|
100
|
+
st.network.on('selectNode', onSelectNode);
|
|
101
|
+
st.network.on('deselectNode', onDeselectAll);
|
|
102
|
+
st.network.on('selectEdge', onSelectEdge);
|
|
103
|
+
st.network.on('deselectEdge', onDeselectAll);
|
|
104
|
+
st.network.on('zoom', updateZoomDisplay);
|
|
105
|
+
st.network.on('dragEnd', onDragEnd);
|
|
106
|
+
st.network.on('beforeDrawing', drawGrid);
|
|
107
|
+
st.network.on('afterDrawing', updateSelectionOverlay);
|
|
108
|
+
st.network.on('afterDrawing', () => drawDebugOverlay());
|
|
109
|
+
|
|
110
|
+
document.getElementById('emptyState').classList.add('hidden');
|
|
111
|
+
updateZoomDisplay();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Network event handlers ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function onDoubleClick(params) {
|
|
117
|
+
if (params.nodes.length > 0) {
|
|
118
|
+
st.selectedNodeIds = params.nodes;
|
|
119
|
+
st.network.selectNodes(st.selectedNodeIds);
|
|
120
|
+
showNodePanel();
|
|
121
|
+
startLabelEdit();
|
|
122
|
+
} else if (params.edges.length > 0 && params.nodes.length === 0) {
|
|
123
|
+
st.selectedEdgeIds = [params.edges[0]];
|
|
124
|
+
showEdgePanel();
|
|
125
|
+
startEdgeLabelEdit();
|
|
126
|
+
} else if (st.currentTool === 'addNode') {
|
|
127
|
+
const id = 'n' + Date.now();
|
|
128
|
+
st.nodes.add({
|
|
129
|
+
id, label: 'Node',
|
|
130
|
+
shapeType: st.pendingShape, colorKey: 'c-gray',
|
|
131
|
+
nodeWidth: null, nodeHeight: null, fontSize: null,
|
|
132
|
+
x: params.pointer.canvas.x, y: params.pointer.canvas.y,
|
|
133
|
+
...visNodeProps(st.pendingShape, 'c-gray', null, null, null, null, null),
|
|
134
|
+
});
|
|
135
|
+
markDirty();
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
st.network.selectNodes([id]);
|
|
138
|
+
st.selectedNodeIds = [id];
|
|
139
|
+
showNodePanel();
|
|
140
|
+
startLabelEdit();
|
|
141
|
+
}, 50);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onSelectNode(params) {
|
|
146
|
+
st.selectedNodeIds = params.nodes;
|
|
147
|
+
st.selectedEdgeIds = [];
|
|
148
|
+
hideEdgePanel();
|
|
149
|
+
showNodePanel();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onSelectEdge(params) {
|
|
153
|
+
if (st.selectedNodeIds.length > 0) return; // node takes priority
|
|
154
|
+
st.selectedEdgeIds = params.edges;
|
|
155
|
+
st.selectedNodeIds = [];
|
|
156
|
+
hideNodePanel();
|
|
157
|
+
showEdgePanel();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onDeselectAll() {
|
|
161
|
+
st.selectedNodeIds = [];
|
|
162
|
+
st.selectedEdgeIds = [];
|
|
163
|
+
hideNodePanel();
|
|
164
|
+
hideEdgePanel();
|
|
165
|
+
commitLabelEdit();
|
|
166
|
+
hideLabelInput();
|
|
167
|
+
hideSelectionOverlay();
|
|
168
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// ── Node panel ────────────────────────────────────────────────────────────────
|
|
2
|
+
// Floating formatting toolbar for selected nodes (color, font, alignment, z-order).
|
|
3
|
+
|
|
4
|
+
import { st, markDirty } from './state.js';
|
|
5
|
+
import { visNodeProps, getActualNodeHeight } from './node-rendering.js';
|
|
6
|
+
|
|
7
|
+
export function showNodePanel() {
|
|
8
|
+
document.getElementById('nodePanel').classList.remove('hidden');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function hideNodePanel() {
|
|
12
|
+
document.getElementById('nodePanel').classList.add('hidden');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setNodeColor(colorKey) {
|
|
16
|
+
if (!st.selectedNodeIds.length) return;
|
|
17
|
+
st.selectedNodeIds.forEach((id) => {
|
|
18
|
+
const n = st.nodes.get(id);
|
|
19
|
+
if (!n) return;
|
|
20
|
+
st.nodes.update({ id, colorKey, ...visNodeProps(n.shapeType || 'box', colorKey, n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, n.textValign) });
|
|
21
|
+
});
|
|
22
|
+
markDirty();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function changeNodeFontSize(delta) {
|
|
26
|
+
if (!st.selectedNodeIds.length) return;
|
|
27
|
+
st.selectedNodeIds.forEach((id) => {
|
|
28
|
+
const n = st.nodes.get(id);
|
|
29
|
+
if (!n) return;
|
|
30
|
+
const newSize = Math.max(8, Math.min(48, (n.fontSize || 13) + delta));
|
|
31
|
+
const ah = getActualNodeHeight(id);
|
|
32
|
+
st.nodes.update({ id, fontSize: newSize, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, newSize, n.textAlign, n.textValign, ah) });
|
|
33
|
+
});
|
|
34
|
+
markDirty();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function setTextAlign(align) {
|
|
38
|
+
if (!st.selectedNodeIds.length) return;
|
|
39
|
+
st.selectedNodeIds.forEach((id) => {
|
|
40
|
+
const n = st.nodes.get(id);
|
|
41
|
+
if (!n) return;
|
|
42
|
+
const ah = getActualNodeHeight(id);
|
|
43
|
+
st.nodes.update({ id, textAlign: align, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, align, n.textValign, ah) });
|
|
44
|
+
});
|
|
45
|
+
markDirty();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setTextValign(valign) {
|
|
49
|
+
if (!st.selectedNodeIds.length) return;
|
|
50
|
+
st.selectedNodeIds.forEach((id) => {
|
|
51
|
+
const n = st.nodes.get(id);
|
|
52
|
+
if (!n) return;
|
|
53
|
+
const ah = getActualNodeHeight(id);
|
|
54
|
+
st.nodes.update({ id, textValign: valign, ...visNodeProps(n.shapeType || 'box', n.colorKey || 'c-gray', n.nodeWidth, n.nodeHeight, n.fontSize, n.textAlign, valign, ah) });
|
|
55
|
+
});
|
|
56
|
+
markDirty();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function changeZOrder(direction) {
|
|
60
|
+
// direction: +1 = bring to front (last in canonicalOrder = drawn on top)
|
|
61
|
+
// -1 = send to back (first in canonicalOrder = drawn below)
|
|
62
|
+
if (!st.selectedNodeIds.length) return;
|
|
63
|
+
st.selectedNodeIds.forEach((id) => {
|
|
64
|
+
const idx = st.canonicalOrder.indexOf(id);
|
|
65
|
+
if (idx === -1) return;
|
|
66
|
+
st.canonicalOrder.splice(idx, 1);
|
|
67
|
+
if (direction > 0) st.canonicalOrder.push(id);
|
|
68
|
+
else st.canonicalOrder.unshift(id);
|
|
69
|
+
});
|
|
70
|
+
st.network.redraw();
|
|
71
|
+
st.network.selectNodes(st.selectedNodeIds);
|
|
72
|
+
markDirty();
|
|
73
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ── Node rendering ────────────────────────────────────────────────────────────
|
|
2
|
+
// Actor canvas renderer + vis.js node property builder.
|
|
3
|
+
|
|
4
|
+
import { NODE_COLORS } from './constants.js';
|
|
5
|
+
import { st } from './state.js';
|
|
6
|
+
|
|
7
|
+
// Reference dimensions of the actor figure at scale 1.
|
|
8
|
+
const ACTOR_W0 = 30;
|
|
9
|
+
const ACTOR_H0 = 52;
|
|
10
|
+
|
|
11
|
+
// Factory: returns a vis.js ctxRenderer for the "actor" custom shape.
|
|
12
|
+
//
|
|
13
|
+
// Key design: this is a STABLE function — it is created ONCE per colorKey and
|
|
14
|
+
// never replaced. vis-network caches the ctxRenderer reference inside its
|
|
15
|
+
// CustomShape object and does NOT re-read it from the DataSet after updates,
|
|
16
|
+
// so rebuilding the closure on every resize would have no effect.
|
|
17
|
+
//
|
|
18
|
+
// Instead, the renderer reads live dimensions on every draw call via the node
|
|
19
|
+
// `id` that vis-network passes to ctxRenderer. `st.nodes.get(id)` always
|
|
20
|
+
// returns the latest nodeWidth / nodeHeight written by the resize handler.
|
|
21
|
+
export function makeActorRenderer(colorKey) {
|
|
22
|
+
const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
|
|
23
|
+
|
|
24
|
+
return function ({ ctx, x, y, id, state: visState, label, style }) {
|
|
25
|
+
// Read the current dimensions from the DataSet on every draw.
|
|
26
|
+
const n = st.nodes && st.nodes.get(id);
|
|
27
|
+
const W = (n && n.nodeWidth) || ACTOR_W0;
|
|
28
|
+
const H = (n && n.nodeHeight) || ACTOR_H0;
|
|
29
|
+
const sx = W / ACTOR_W0;
|
|
30
|
+
const sy = H / ACTOR_H0;
|
|
31
|
+
const fontSize = (style && style.font && style.font.size) ? style.font.size : 13;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
drawNode() {
|
|
35
|
+
// ── Scaled stick figure ───────────────────────────────────────────────
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
|
|
38
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
39
|
+
ctx.lineWidth = 2;
|
|
40
|
+
ctx.lineCap = 'round';
|
|
41
|
+
ctx.beginPath(); ctx.arc(x, y - 20*sy, 8*sy, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
42
|
+
ctx.beginPath(); ctx.moveTo(x, y - 12*sy); ctx.lineTo(x, y + 8*sy); ctx.stroke();
|
|
43
|
+
ctx.beginPath(); ctx.moveTo(x - 13*sx, y - 3*sy); ctx.lineTo(x + 13*sx, y - 3*sy); ctx.stroke();
|
|
44
|
+
ctx.beginPath(); ctx.moveTo(x, y + 8*sy); ctx.lineTo(x - 10*sx, y + 24*sy); ctx.stroke();
|
|
45
|
+
ctx.beginPath(); ctx.moveTo(x, y + 8*sy); ctx.lineTo(x + 10*sx, y + 24*sy); ctx.stroke();
|
|
46
|
+
ctx.restore();
|
|
47
|
+
|
|
48
|
+
// ── Label below the figure ────────────────────────────────────────────
|
|
49
|
+
if (label) {
|
|
50
|
+
ctx.save();
|
|
51
|
+
ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
52
|
+
ctx.fillStyle = c.font;
|
|
53
|
+
ctx.textAlign = 'center';
|
|
54
|
+
ctx.textBaseline = 'top';
|
|
55
|
+
const lines = String(label).split('\n');
|
|
56
|
+
const lineH = fontSize * 1.3;
|
|
57
|
+
const startY = y + 24*sy + 4; // 4 px gap below scaled leg tips
|
|
58
|
+
lines.forEach((line, i) => ctx.fillText(line, x, startY + i * lineH));
|
|
59
|
+
ctx.restore();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
// nodeDimensions must also reflect the current size so vis-network uses
|
|
63
|
+
// the right bounding box for collision detection and layout.
|
|
64
|
+
nodeDimensions: { width: W, height: H },
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Vertical text offset for top/middle/bottom alignment inside a node.
|
|
70
|
+
export function computeVadjust(textValign, nodeHeight, fontSize) {
|
|
71
|
+
if (!textValign || textValign === 'middle') return 0;
|
|
72
|
+
const h = nodeHeight || 50;
|
|
73
|
+
const fs = fontSize || 13;
|
|
74
|
+
const pad = 8;
|
|
75
|
+
if (textValign === 'top') return -(h / 2 - fs / 2 - pad);
|
|
76
|
+
if (textValign === 'bottom') return h / 2 - fs / 2 - pad;
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Returns the rendered height of a node from vis.js internals, or null.
|
|
81
|
+
export function getActualNodeHeight(id) {
|
|
82
|
+
if (!st.network) return null;
|
|
83
|
+
const bn = st.network.body.nodes[id];
|
|
84
|
+
return bn && bn.shape && bn.shape.height ? bn.shape.height : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Builds the full vis.js node property object (color, font, size constraints, shape).
|
|
88
|
+
export function visNodeProps(shapeType, colorKey, nodeWidth, nodeHeight, fontSize, textAlign, textValign, vadjustHeight) {
|
|
89
|
+
const c = NODE_COLORS[colorKey] || NODE_COLORS['c-gray'];
|
|
90
|
+
const size = fontSize || 13;
|
|
91
|
+
const align = textAlign || 'center';
|
|
92
|
+
const vadjust = computeVadjust(textValign, vadjustHeight || nodeHeight, size);
|
|
93
|
+
|
|
94
|
+
const colorP = {
|
|
95
|
+
color: {
|
|
96
|
+
background: c.bg,
|
|
97
|
+
border: c.border,
|
|
98
|
+
highlight: { background: c.hbg, border: c.hborder },
|
|
99
|
+
hover: { background: c.hbg, border: c.hborder },
|
|
100
|
+
},
|
|
101
|
+
font: { color: c.font, size, face: 'system-ui,-apple-system,sans-serif', align, vadjust },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const sizeP = {};
|
|
105
|
+
if (nodeWidth) sizeP.widthConstraint = { minimum: nodeWidth, maximum: nodeWidth };
|
|
106
|
+
if (nodeHeight) sizeP.heightConstraint = { minimum: nodeHeight, maximum: nodeHeight };
|
|
107
|
+
|
|
108
|
+
if (shapeType === 'actor') {
|
|
109
|
+
// The renderer reads dimensions from st.nodes at draw time — never recreated.
|
|
110
|
+
return { shape: 'custom', ctxRenderer: makeActorRenderer(colorKey), ...colorP, ...sizeP };
|
|
111
|
+
}
|
|
112
|
+
return { shape: shapeType, ...colorP, ...sizeP };
|
|
113
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// ── Persistence ───────────────────────────────────────────────────────────────
|
|
2
|
+
// API calls (CRUD) for diagrams + sidebar list rendering.
|
|
3
|
+
|
|
4
|
+
import { st, markDirty } from './state.js';
|
|
5
|
+
import { initNetwork } from './network.js';
|
|
6
|
+
import { hideNodePanel } from './node-panel.js';
|
|
7
|
+
import { hideEdgePanel } from './edge-panel.js';
|
|
8
|
+
import { hideLabelInput } from './label-editor.js';
|
|
9
|
+
import { hideSelectionOverlay } from './selection-overlay.js';
|
|
10
|
+
|
|
11
|
+
function escapeHtml(s) {
|
|
12
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function renderDiagramList() {
|
|
16
|
+
const list = document.getElementById('diagramList');
|
|
17
|
+
list.innerHTML = '';
|
|
18
|
+
if (!st.diagrams.length) {
|
|
19
|
+
list.innerHTML = '<p class="text-xs text-gray-400 dark:text-gray-600 px-3 py-3">Aucun diagramme</p>';
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
st.diagrams.forEach((d) => {
|
|
23
|
+
const isActive = d.id === st.currentDiagramId;
|
|
24
|
+
const item = document.createElement('div');
|
|
25
|
+
item.className = [
|
|
26
|
+
'group flex items-center gap-1 px-2 py-1.5 mx-1 my-0.5 rounded-md cursor-pointer',
|
|
27
|
+
'hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors',
|
|
28
|
+
isActive ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'text-gray-700 dark:text-gray-300',
|
|
29
|
+
].join(' ');
|
|
30
|
+
|
|
31
|
+
const titleSpan = document.createElement('span');
|
|
32
|
+
titleSpan.className = 'flex-1 text-sm truncate';
|
|
33
|
+
titleSpan.textContent = d.title;
|
|
34
|
+
|
|
35
|
+
const deleteBtn = document.createElement('button');
|
|
36
|
+
deleteBtn.title = 'Supprimer';
|
|
37
|
+
deleteBtn.className = 'hidden group-hover:flex items-center justify-center w-4 h-4 rounded text-gray-400 hover:text-red-500 shrink-0';
|
|
38
|
+
deleteBtn.textContent = '✕';
|
|
39
|
+
deleteBtn.addEventListener('click', async (e) => {
|
|
40
|
+
e.stopPropagation();
|
|
41
|
+
await deleteDiagram(d.id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
item.appendChild(titleSpan);
|
|
45
|
+
item.appendChild(deleteBtn);
|
|
46
|
+
item.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON') return; openDiagram(d.id); });
|
|
47
|
+
list.appendChild(item);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function loadDiagramList() {
|
|
52
|
+
const res = await fetch('/api/diagrams');
|
|
53
|
+
st.diagrams = await res.json();
|
|
54
|
+
renderDiagramList();
|
|
55
|
+
if (st.diagrams.length > 0) openDiagram(st.diagrams[0].id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function openDiagram(id) {
|
|
59
|
+
const res = await fetch(`/api/diagrams/${id}`);
|
|
60
|
+
const diagram = await res.json();
|
|
61
|
+
st.currentDiagramId = id;
|
|
62
|
+
st.isDirty = false;
|
|
63
|
+
document.getElementById('btnSave').disabled = true;
|
|
64
|
+
document.getElementById('diagramTitle').value = diagram.title || '';
|
|
65
|
+
initNetwork(diagram.nodes || [], diagram.edges || []);
|
|
66
|
+
renderDiagramList();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function newDiagram() {
|
|
70
|
+
const id = 'd' + Date.now();
|
|
71
|
+
await fetch(`/api/diagrams/${id}`, {
|
|
72
|
+
method: 'PUT',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ title: 'Nouveau diagramme', nodes: [], edges: [] }),
|
|
75
|
+
});
|
|
76
|
+
const res = await fetch('/api/diagrams');
|
|
77
|
+
st.diagrams = await res.json();
|
|
78
|
+
renderDiagramList();
|
|
79
|
+
openDiagram(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function deleteDiagram(id) {
|
|
83
|
+
if (!confirm('Supprimer ce diagramme ?')) return;
|
|
84
|
+
await fetch(`/api/diagrams/${id}`, { method: 'DELETE' });
|
|
85
|
+
const res = await fetch('/api/diagrams');
|
|
86
|
+
st.diagrams = await res.json();
|
|
87
|
+
|
|
88
|
+
if (st.currentDiagramId === id) {
|
|
89
|
+
st.currentDiagramId = null;
|
|
90
|
+
if (st.network) { st.network.destroy(); st.network = null; }
|
|
91
|
+
st.nodes = null;
|
|
92
|
+
st.edges = null;
|
|
93
|
+
document.getElementById('diagramTitle').value = '';
|
|
94
|
+
document.getElementById('btnSave').disabled = true;
|
|
95
|
+
hideNodePanel();
|
|
96
|
+
hideEdgePanel();
|
|
97
|
+
hideLabelInput();
|
|
98
|
+
hideSelectionOverlay();
|
|
99
|
+
if (st.diagrams.length > 0) openDiagram(st.diagrams[0].id);
|
|
100
|
+
else document.getElementById('emptyState').classList.remove('hidden');
|
|
101
|
+
}
|
|
102
|
+
renderDiagramList();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function saveDiagram() {
|
|
106
|
+
if (!st.currentDiagramId || !st.network) return;
|
|
107
|
+
const positions = st.network.getPositions();
|
|
108
|
+
|
|
109
|
+
// Serialise in canonicalOrder so z-order is restored on next load.
|
|
110
|
+
const nodeData = st.canonicalOrder
|
|
111
|
+
.map((id) => st.nodes.get(id))
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.map((n) => ({
|
|
114
|
+
id: n.id, label: n.label,
|
|
115
|
+
shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
|
|
116
|
+
nodeWidth: n.nodeWidth || null, nodeHeight: n.nodeHeight || null,
|
|
117
|
+
fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
|
|
118
|
+
x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const edgeData = st.edges.get().map((e) => ({
|
|
122
|
+
id: e.id, from: e.from, to: e.to,
|
|
123
|
+
label: e.label || '', arrowDir: e.arrowDir || 'to',
|
|
124
|
+
dashes: e.dashes || false, fontSize: e.fontSize || null,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
const title = document.getElementById('diagramTitle').value || 'Sans titre';
|
|
128
|
+
await fetch(`/api/diagrams/${st.currentDiagramId}`, {
|
|
129
|
+
method: 'PUT',
|
|
130
|
+
headers: { 'Content-Type': 'application/json' },
|
|
131
|
+
body: JSON.stringify({ title, nodes: nodeData, edges: edgeData }),
|
|
132
|
+
});
|
|
133
|
+
st.isDirty = false;
|
|
134
|
+
document.getElementById('btnSave').disabled = true;
|
|
135
|
+
const res = await fetch('/api/diagrams');
|
|
136
|
+
st.diagrams = await res.json();
|
|
137
|
+
renderDiagramList();
|
|
138
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// ── Selection / resize overlay ────────────────────────────────────────────────
|
|
2
|
+
// Dashed selection box + corner resize handles for selected nodes.
|
|
3
|
+
|
|
4
|
+
import { st, markDirty } from './state.js';
|
|
5
|
+
import { visNodeProps } from './node-rendering.js';
|
|
6
|
+
|
|
7
|
+
export function updateSelectionOverlay() {
|
|
8
|
+
if (!st.network || !st.selectedNodeIds.length) { hideSelectionOverlay(); return; }
|
|
9
|
+
|
|
10
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
11
|
+
for (const id of st.selectedNodeIds) {
|
|
12
|
+
try {
|
|
13
|
+
const n = st.nodes.get(id);
|
|
14
|
+
if (n && n.shapeType === 'actor') {
|
|
15
|
+
// getBoundingBox() returns wrong values for custom ctxRenderer shapes.
|
|
16
|
+
// Compute the visual bounds from the node's centre + scaled actor geometry.
|
|
17
|
+
const bodyNode = st.network.body.nodes[id];
|
|
18
|
+
if (!bodyNode) continue;
|
|
19
|
+
const cx = bodyNode.x, cy = bodyNode.y;
|
|
20
|
+
const W = n.nodeWidth || 30;
|
|
21
|
+
const H = n.nodeHeight || 52;
|
|
22
|
+
const sy = H / 52;
|
|
23
|
+
minX = Math.min(minX, cx - W / 2);
|
|
24
|
+
minY = Math.min(minY, cy - 28 * sy); // head top
|
|
25
|
+
maxX = Math.max(maxX, cx + W / 2);
|
|
26
|
+
maxY = Math.max(maxY, cy + 24 * sy); // legs bottom
|
|
27
|
+
} else {
|
|
28
|
+
const bb = st.network.getBoundingBox(id);
|
|
29
|
+
minX = Math.min(minX, bb.left);
|
|
30
|
+
minY = Math.min(minY, bb.top);
|
|
31
|
+
maxX = Math.max(maxX, bb.right);
|
|
32
|
+
maxY = Math.max(maxY, bb.bottom);
|
|
33
|
+
}
|
|
34
|
+
} catch (_) { /* node still being created */ }
|
|
35
|
+
}
|
|
36
|
+
if (minX === Infinity) { hideSelectionOverlay(); return; }
|
|
37
|
+
|
|
38
|
+
const PAD = 10;
|
|
39
|
+
const tl = st.network.canvasToDOM({ x: minX, y: minY });
|
|
40
|
+
const br = st.network.canvasToDOM({ x: maxX, y: maxY });
|
|
41
|
+
const ov = document.getElementById('selectionOverlay');
|
|
42
|
+
ov.style.display = 'block';
|
|
43
|
+
ov.style.left = tl.x - PAD + 'px';
|
|
44
|
+
ov.style.top = tl.y - PAD + 'px';
|
|
45
|
+
ov.style.width = br.x - tl.x + PAD * 2 + 'px';
|
|
46
|
+
ov.style.height = br.y - tl.y + PAD * 2 + 'px';
|
|
47
|
+
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hideSelectionOverlay() {
|
|
51
|
+
document.getElementById('selectionOverlay').style.display = 'none';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function onResizeStart(e, corner) {
|
|
55
|
+
if (!st.selectedNodeIds.length || !st.network) return;
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
|
|
59
|
+
const startBBs = st.selectedNodeIds.map((id) => {
|
|
60
|
+
const n = st.nodes.get(id);
|
|
61
|
+
// getBoundingBox() returns wrong values for custom ctxRenderer shapes (actor).
|
|
62
|
+
// Fall back to the actor's reference dimensions when no resize has happened yet.
|
|
63
|
+
let initW, initH;
|
|
64
|
+
if (n && n.shapeType === 'actor') {
|
|
65
|
+
initW = n.nodeWidth || 30;
|
|
66
|
+
initH = n.nodeHeight || 52;
|
|
67
|
+
} else {
|
|
68
|
+
const bb = st.network.getBoundingBox(id);
|
|
69
|
+
initW = n.nodeWidth || Math.round(bb.right - bb.left);
|
|
70
|
+
initH = n.nodeHeight || Math.round(bb.bottom - bb.top);
|
|
71
|
+
}
|
|
72
|
+
return { id, node: n, initW, initH };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const initBoxW = startBBs.reduce((max, b) => Math.max(max, b.initW), 0);
|
|
76
|
+
const initBoxH = startBBs.reduce((max, b) => Math.max(max, b.initH), 0);
|
|
77
|
+
|
|
78
|
+
st.resizeDrag = { corner, startMouse: { x: e.clientX, y: e.clientY }, startBBs, initBoxW, initBoxH };
|
|
79
|
+
|
|
80
|
+
st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: true }));
|
|
81
|
+
document.getElementById('vis-canvas').style.pointerEvents = 'none';
|
|
82
|
+
document.addEventListener('mousemove', onResizeDrag);
|
|
83
|
+
document.addEventListener('mouseup', onResizeEnd);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onResizeDrag(e) {
|
|
87
|
+
if (!st.resizeDrag || !st.network) return;
|
|
88
|
+
const scale = st.network.getScale();
|
|
89
|
+
const cdx = (e.clientX - st.resizeDrag.startMouse.x) / scale;
|
|
90
|
+
const cdy = (e.clientY - st.resizeDrag.startMouse.y) / scale;
|
|
91
|
+
const MIN = 40;
|
|
92
|
+
const c = st.resizeDrag.corner;
|
|
93
|
+
|
|
94
|
+
const updatedIds = [];
|
|
95
|
+
|
|
96
|
+
if (st.resizeDrag.startBBs.length === 1) {
|
|
97
|
+
const { id, node, initW, initH } = st.resizeDrag.startBBs[0];
|
|
98
|
+
let nW = initW, nH = initH;
|
|
99
|
+
if (c === 'br') { nW = initW + cdx; nH = initH + cdy; }
|
|
100
|
+
if (c === 'bl') { nW = initW - cdx; nH = initH + cdy; }
|
|
101
|
+
if (c === 'tr') { nW = initW + cdx; nH = initH - cdy; }
|
|
102
|
+
if (c === 'tl') { nW = initW - cdx; nH = initH - cdy; }
|
|
103
|
+
nW = Math.max(MIN, Math.round(nW));
|
|
104
|
+
nH = Math.max(MIN, Math.round(nH));
|
|
105
|
+
st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign, nH) });
|
|
106
|
+
updatedIds.push(id);
|
|
107
|
+
} else {
|
|
108
|
+
const { initBoxW, initBoxH } = st.resizeDrag;
|
|
109
|
+
let sx = 1, sy = 1;
|
|
110
|
+
if (c === 'br') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
|
|
111
|
+
if (c === 'bl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH + cdy) / initBoxH; }
|
|
112
|
+
if (c === 'tr') { sx = (initBoxW + cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
|
|
113
|
+
if (c === 'tl') { sx = (initBoxW - cdx) / initBoxW; sy = (initBoxH - cdy) / initBoxH; }
|
|
114
|
+
sx = Math.max(0.1, sx);
|
|
115
|
+
sy = Math.max(0.1, sy);
|
|
116
|
+
for (const { id, node, initW, initH } of st.resizeDrag.startBBs) {
|
|
117
|
+
const nW = Math.max(MIN, Math.round(initW * sx));
|
|
118
|
+
const nH = Math.max(MIN, Math.round(initH * sy));
|
|
119
|
+
st.nodes.update({ id, nodeWidth: nW, nodeHeight: nH, ...visNodeProps(node.shapeType || 'box', node.colorKey || 'c-gray', nW, nH, node.fontSize, node.textAlign, node.textValign, nH) });
|
|
120
|
+
updatedIds.push(id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// vis-network's needsRefresh() can return false for certain shapes (e.g. database)
|
|
125
|
+
// when only widthConstraint or heightConstraint changes, because the check is based
|
|
126
|
+
// on label/font state, not constraints. Force the internal refresh flag and redraw.
|
|
127
|
+
updatedIds.forEach((id) => {
|
|
128
|
+
const bn = st.network.body.nodes[id];
|
|
129
|
+
if (bn) bn.refreshNeeded = true;
|
|
130
|
+
});
|
|
131
|
+
st.network.redraw();
|
|
132
|
+
|
|
133
|
+
updateSelectionOverlay();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onResizeEnd() {
|
|
137
|
+
if (!st.resizeDrag) return;
|
|
138
|
+
st.selectedNodeIds.forEach((id) => st.nodes.update({ id, fixed: false }));
|
|
139
|
+
document.getElementById('vis-canvas').style.pointerEvents = '';
|
|
140
|
+
document.removeEventListener('mousemove', onResizeDrag);
|
|
141
|
+
document.removeEventListener('mouseup', onResizeEnd);
|
|
142
|
+
st.resizeDrag = null;
|
|
143
|
+
markDirty();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Wire corner handle mouse events ───────────────────────────────────────────
|
|
147
|
+
['tl', 'tr', 'bl', 'br'].forEach((corner) => {
|
|
148
|
+
document.getElementById('rh-' + corner).addEventListener('mousedown', (e) => onResizeStart(e, corner));
|
|
149
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ── Shared mutable state ──────────────────────────────────────────────────────
|
|
2
|
+
// Single object imported by reference so all modules see the same mutations.
|
|
3
|
+
|
|
4
|
+
export const st = {
|
|
5
|
+
network: null,
|
|
6
|
+
nodes: null,
|
|
7
|
+
edges: null,
|
|
8
|
+
diagrams: [],
|
|
9
|
+
currentDiagramId: null,
|
|
10
|
+
currentTool: 'select',
|
|
11
|
+
pendingShape: 'box',
|
|
12
|
+
selectedNodeIds: [],
|
|
13
|
+
selectedEdgeIds: [],
|
|
14
|
+
physicsEnabled: false,
|
|
15
|
+
gridEnabled: false,
|
|
16
|
+
debugMode: false,
|
|
17
|
+
isDirty: false,
|
|
18
|
+
sidebarOpen: true,
|
|
19
|
+
editingNodeId: null,
|
|
20
|
+
editingEdgeId: null,
|
|
21
|
+
resizeDrag: null,
|
|
22
|
+
clipboard: null, // { nodes: [], edges: [] }
|
|
23
|
+
canonicalOrder: [], // user-defined z-order, immune to vis.js hover reordering
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function markDirty() {
|
|
27
|
+
st.isDirty = true;
|
|
28
|
+
document.getElementById('btnSave').disabled = false;
|
|
29
|
+
}
|