living-documentation 3.7.0 → 4.0.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/README.md +2 -0
- package/dist/src/frontend/diagram/clipboard.js +80 -1
- package/dist/src/frontend/diagram/constants.js +1 -0
- package/dist/src/frontend/diagram/groups.js +99 -0
- package/dist/src/frontend/diagram/image-upload.js +34 -0
- package/dist/src/frontend/diagram/link-panel.js +138 -0
- package/dist/src/frontend/diagram/main.js +51 -6
- package/dist/src/frontend/diagram/network.js +107 -7
- package/dist/src/frontend/diagram/node-panel.js +16 -0
- package/dist/src/frontend/diagram/node-rendering.js +130 -0
- package/dist/src/frontend/diagram/persistence.js +7 -1
- package/dist/src/frontend/diagram/toast.js +21 -0
- package/dist/src/frontend/diagram.html +177 -22
- package/dist/src/frontend/index.html +75 -239
- package/dist/src/frontend/wordcloud.js +321 -0
- package/dist/src/routes/wordcloud.d.ts +3 -0
- package/dist/src/routes/wordcloud.d.ts.map +1 -0
- package/dist/src/routes/wordcloud.js +73 -0
- package/dist/src/routes/wordcloud.js.map +1 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +2 -0
- package/dist/src/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { st, markDirty } from './state.js';
|
|
6
6
|
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
7
|
+
import { uploadImageFile } from './image-upload.js';
|
|
8
|
+
import { showToast } from './toast.js';
|
|
7
9
|
import { visEdgeProps } from './edge-rendering.js';
|
|
8
10
|
import { showNodePanel, hideNodePanel } from './node-panel.js';
|
|
9
11
|
import { showEdgePanel, hideEdgePanel } from './edge-panel.js';
|
|
@@ -12,6 +14,8 @@ import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overla
|
|
|
12
14
|
import { drawGrid, onDragEnd } from './grid.js';
|
|
13
15
|
import { drawDebugOverlay } from './debug.js';
|
|
14
16
|
import { updateZoomDisplay } from './zoom.js';
|
|
17
|
+
import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
|
|
18
|
+
import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
|
|
15
19
|
|
|
16
20
|
export function initNetwork(savedNodes, savedEdges) {
|
|
17
21
|
const container = document.getElementById('vis-canvas');
|
|
@@ -63,26 +67,49 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
63
67
|
st.canonicalOrder = [...st.network.body.nodeIndices];
|
|
64
68
|
st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
|
|
65
69
|
const bodyNodes = this.body.nodes;
|
|
70
|
+
const bodyEdges = this.body.edges;
|
|
66
71
|
const margin = 20;
|
|
67
72
|
const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
|
|
68
73
|
const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
|
|
69
74
|
const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
|
|
70
|
-
const drawExternalLabelCallbacks = [];
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
// Build a map: canonical index → list of edges whose topmost endpoint is at that index.
|
|
77
|
+
const orderMap = new Map();
|
|
78
|
+
st.canonicalOrder.forEach((id, i) => orderMap.set(id, i));
|
|
79
|
+
|
|
80
|
+
const edgesByLevel = new Map(); // canonicalIndex → edge[]
|
|
81
|
+
for (const edgeId of Object.keys(bodyEdges)) {
|
|
82
|
+
const edge = bodyEdges[edgeId];
|
|
83
|
+
if (!edge.connected) continue;
|
|
84
|
+
const level = Math.min(orderMap.get(edge.fromId) ?? 0, orderMap.get(edge.toId) ?? 0);
|
|
85
|
+
if (!edgesByLevel.has(level)) edgesByLevel.set(level, []);
|
|
86
|
+
edgesByLevel.get(level).push(edge);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < st.canonicalOrder.length; i++) {
|
|
90
|
+
const id = st.canonicalOrder[i];
|
|
91
|
+
// Draw edges whose topmost node is at this level, before drawing the node.
|
|
92
|
+
const edges = edgesByLevel.get(i);
|
|
93
|
+
if (edges) edges.forEach((e) => e.draw(ctx));
|
|
94
|
+
|
|
73
95
|
const node = bodyNodes[id];
|
|
74
96
|
if (!node) continue;
|
|
75
97
|
if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
|
|
76
|
-
|
|
77
|
-
// All shapes are ctxRenderer (shape:'custom') and draw their own labels.
|
|
78
|
-
// Skip drawExternalLabel entirely to avoid double-rendering.
|
|
98
|
+
node.draw(ctx);
|
|
79
99
|
} else {
|
|
80
100
|
node.updateBoundingBox(ctx, node.selected);
|
|
81
101
|
}
|
|
82
102
|
}
|
|
83
|
-
return { drawExternalLabels() {
|
|
103
|
+
return { drawExternalLabels() {} };
|
|
84
104
|
};
|
|
85
105
|
|
|
106
|
+
// ── Z-order patch for edges ────────────────────────────────────────────────
|
|
107
|
+
// vis.js draws all edges before all nodes in separate passes.
|
|
108
|
+
// We neutralise _drawEdges (make it a no-op) and instead draw each edge
|
|
109
|
+
// inside _drawNodes, just before the node whose canonical index equals the
|
|
110
|
+
// max index of its two endpoints. This guarantees true z-order interleaving.
|
|
111
|
+
st.network.renderer._drawEdges = function () { /* no-op — edges drawn in _drawNodes */ };
|
|
112
|
+
|
|
86
113
|
// Keep canonicalOrder in sync with DataSet add/remove events
|
|
87
114
|
st.nodes.on('add', (_, { items }) => {
|
|
88
115
|
const existing = new Set(st.canonicalOrder);
|
|
@@ -93,7 +120,9 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
93
120
|
st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
|
|
94
121
|
});
|
|
95
122
|
|
|
123
|
+
st.network.on('click', onClickNode);
|
|
96
124
|
st.network.on('doubleClick', onDoubleClick);
|
|
125
|
+
st.network.on('dragStart', onDragStart);
|
|
97
126
|
st.network.on('selectNode', onSelectNode);
|
|
98
127
|
st.network.on('deselectNode', onDeselectAll);
|
|
99
128
|
st.network.on('selectEdge', onSelectEdge);
|
|
@@ -102,6 +131,7 @@ export function initNetwork(savedNodes, savedEdges) {
|
|
|
102
131
|
st.network.on('dragEnd', onDragEnd);
|
|
103
132
|
st.network.on('beforeDrawing', drawGrid);
|
|
104
133
|
st.network.on('afterDrawing', updateSelectionOverlay);
|
|
134
|
+
st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
|
|
105
135
|
st.network.on('afterDrawing', () => drawDebugOverlay());
|
|
106
136
|
|
|
107
137
|
document.getElementById('emptyState').classList.add('hidden');
|
|
@@ -120,6 +150,9 @@ function onDoubleClick(params) {
|
|
|
120
150
|
st.selectedEdgeIds = [params.edges[0]];
|
|
121
151
|
showEdgePanel();
|
|
122
152
|
startEdgeLabelEdit();
|
|
153
|
+
} else if (st.currentTool === 'addNode' && st.pendingShape === 'image') {
|
|
154
|
+
const canvasPos = params.pointer.canvas;
|
|
155
|
+
pickAndCreateImageNode(canvasPos.x, canvasPos.y);
|
|
123
156
|
} else if (st.currentTool === 'addNode') {
|
|
124
157
|
const id = 'n' + Date.now();
|
|
125
158
|
const defaults = SHAPE_DEFAULTS[st.pendingShape] || [100, 40];
|
|
@@ -142,8 +175,74 @@ function onDoubleClick(params) {
|
|
|
142
175
|
}
|
|
143
176
|
}
|
|
144
177
|
|
|
178
|
+
// ── Image node creation ───────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function pickAndCreateImageNode(canvasX, canvasY) {
|
|
181
|
+
const input = document.createElement('input');
|
|
182
|
+
input.type = 'file';
|
|
183
|
+
input.accept = 'image/*';
|
|
184
|
+
input.onchange = async () => {
|
|
185
|
+
const file = input.files && input.files[0];
|
|
186
|
+
if (!file) return;
|
|
187
|
+
try {
|
|
188
|
+
const src = await uploadImageFile(file);
|
|
189
|
+
createImageNode(src, canvasX, canvasY);
|
|
190
|
+
} catch {
|
|
191
|
+
showToast('Impossible d\'importer l\'image', 'error');
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
input.click();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function createImageNode(imageSrc, canvasX, canvasY) {
|
|
198
|
+
if (!st.network) return;
|
|
199
|
+
const id = 'n' + Date.now();
|
|
200
|
+
const defaults = SHAPE_DEFAULTS['image'];
|
|
201
|
+
st.nodes.add({
|
|
202
|
+
id, label: '', imageSrc,
|
|
203
|
+
shapeType: 'image', colorKey: 'c-gray',
|
|
204
|
+
nodeWidth: defaults[0], nodeHeight: defaults[1],
|
|
205
|
+
fontSize: null, rotation: 0, labelRotation: 0,
|
|
206
|
+
x: canvasX, y: canvasY,
|
|
207
|
+
...visNodeProps('image', 'c-gray', defaults[0], defaults[1], null, null, null),
|
|
208
|
+
});
|
|
209
|
+
markDirty();
|
|
210
|
+
setTimeout(() => {
|
|
211
|
+
st.network.selectNodes([id]);
|
|
212
|
+
st.selectedNodeIds = [id];
|
|
213
|
+
showNodePanel();
|
|
214
|
+
}, 50);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function onClickNode(params) {
|
|
218
|
+
if (params.nodes.length === 1 && params.event.srcEvent.shiftKey) {
|
|
219
|
+
navigateNodeLink(params.nodes[0]);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Expand group selection at dragStart so vis-network moves all members together.
|
|
224
|
+
// dragStart fires before the move, unlike selectNode which fires after mouseup.
|
|
225
|
+
function onDragStart(params) {
|
|
226
|
+
if (!params.nodes.length) return;
|
|
227
|
+
const expanded = expandSelectionToGroup(params.nodes);
|
|
228
|
+
if (expanded.length > params.nodes.length) {
|
|
229
|
+
st.network.selectNodes(expanded);
|
|
230
|
+
st.selectedNodeIds = expanded;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let _expandingGroup = false;
|
|
145
235
|
function onSelectNode(params) {
|
|
146
|
-
|
|
236
|
+
if (_expandingGroup) return;
|
|
237
|
+
const expanded = expandSelectionToGroup(params.nodes);
|
|
238
|
+
if (expanded.length > params.nodes.length) {
|
|
239
|
+
_expandingGroup = true;
|
|
240
|
+
st.network.selectNodes(expanded);
|
|
241
|
+
_expandingGroup = false;
|
|
242
|
+
st.selectedNodeIds = expanded;
|
|
243
|
+
} else {
|
|
244
|
+
st.selectedNodeIds = params.nodes;
|
|
245
|
+
}
|
|
147
246
|
st.selectedEdgeIds = [];
|
|
148
247
|
hideEdgePanel();
|
|
149
248
|
showNodePanel();
|
|
@@ -158,6 +257,7 @@ function onSelectEdge(params) {
|
|
|
158
257
|
}
|
|
159
258
|
|
|
160
259
|
function onDeselectAll() {
|
|
260
|
+
hideLinkPanel();
|
|
161
261
|
st.selectedNodeIds = [];
|
|
162
262
|
st.selectedEdgeIds = [];
|
|
163
263
|
hideNodePanel();
|
|
@@ -154,6 +154,22 @@ document.getElementById('stampOverlay').addEventListener('click', (e) => {
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
// ── Step rotation ─────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
export function stepRotate(degrees) {
|
|
160
|
+
if (!st.selectedNodeIds.length) return;
|
|
161
|
+
const delta = degrees * (Math.PI / 180);
|
|
162
|
+
st.selectedNodeIds.forEach((id) => {
|
|
163
|
+
const n = st.nodes.get(id);
|
|
164
|
+
if (!n) return;
|
|
165
|
+
st.nodes.update({ id, rotation: (n.rotation || 0) + delta });
|
|
166
|
+
const bn = st.network && st.network.body.nodes[id];
|
|
167
|
+
if (bn) bn.refreshNeeded = true;
|
|
168
|
+
});
|
|
169
|
+
if (st.network) st.network.redraw();
|
|
170
|
+
markDirty();
|
|
171
|
+
}
|
|
172
|
+
|
|
157
173
|
export function changeZOrder(direction) {
|
|
158
174
|
// direction: +1 = bring to front (last in canonicalOrder = drawn on top)
|
|
159
175
|
// -1 = send to back (first in canonicalOrder = drawn below)
|
|
@@ -7,6 +7,31 @@
|
|
|
7
7
|
import { NODE_COLORS } from './constants.js';
|
|
8
8
|
import { st } from './state.js';
|
|
9
9
|
|
|
10
|
+
// ── Link indicator ────────────────────────────────────────────────────────────
|
|
11
|
+
// Small chain icon drawn at bottom-right of any node that has a nodeLink.
|
|
12
|
+
function drawLinkIndicator(ctx, id, W, H) {
|
|
13
|
+
const n = st.nodes && st.nodes.get(id);
|
|
14
|
+
if (!n || !n.nodeLink) return;
|
|
15
|
+
const r = 7;
|
|
16
|
+
const bx = W / 2 - r;
|
|
17
|
+
const by = H / 2 - r;
|
|
18
|
+
ctx.save();
|
|
19
|
+
ctx.fillStyle = n.nodeLink.type === 'url' ? '#3b82f6' : '#f97316';
|
|
20
|
+
ctx.strokeStyle = '#fff';
|
|
21
|
+
ctx.lineWidth = 1;
|
|
22
|
+
ctx.beginPath(); ctx.arc(bx, by, r, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
23
|
+
ctx.strokeStyle = '#fff';
|
|
24
|
+
ctx.lineWidth = 1.2;
|
|
25
|
+
ctx.lineCap = 'round';
|
|
26
|
+
// Tiny link icon inside the badge
|
|
27
|
+
ctx.beginPath();
|
|
28
|
+
ctx.moveTo(bx - 1.5, by + 1.5); ctx.lineTo(bx + 1.5, by - 1.5);
|
|
29
|
+
ctx.moveTo(bx - 2.5, by - 0.5); ctx.lineTo(bx - 0.5, by - 2.5);
|
|
30
|
+
ctx.moveTo(bx + 0.5, by + 2.5); ctx.lineTo(bx + 2.5, by + 0.5);
|
|
31
|
+
ctx.stroke();
|
|
32
|
+
ctx.restore();
|
|
33
|
+
}
|
|
34
|
+
|
|
10
35
|
// ── Drawing helpers ───────────────────────────────────────────────────────────
|
|
11
36
|
|
|
12
37
|
// Draw multi-line label centred at (0,0) in the current (possibly rotated) ctx.
|
|
@@ -91,6 +116,7 @@ export function makeBoxRenderer(colorKey) {
|
|
|
91
116
|
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
92
117
|
ctx.fill(); ctx.stroke();
|
|
93
118
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
119
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
94
120
|
ctx.restore();
|
|
95
121
|
},
|
|
96
122
|
nodeDimensions: { width: W, height: H },
|
|
@@ -110,6 +136,7 @@ export function makeEllipseRenderer(colorKey) {
|
|
|
110
136
|
ctx.beginPath(); ctx.ellipse(0, 0, W / 2, H / 2, 0, 0, Math.PI * 2);
|
|
111
137
|
ctx.fill(); ctx.stroke();
|
|
112
138
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
139
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
113
140
|
ctx.restore();
|
|
114
141
|
},
|
|
115
142
|
nodeDimensions: { width: W, height: H },
|
|
@@ -130,6 +157,7 @@ export function makeCircleRenderer(colorKey) {
|
|
|
130
157
|
ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2);
|
|
131
158
|
ctx.fill(); ctx.stroke();
|
|
132
159
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, W, labelRotation);
|
|
160
|
+
drawLinkIndicator(ctx, id, W, W);
|
|
133
161
|
ctx.restore();
|
|
134
162
|
},
|
|
135
163
|
nodeDimensions: { width: W, height: W },
|
|
@@ -160,6 +188,7 @@ export function makeDatabaseRenderer(colorKey) {
|
|
|
160
188
|
ctx.beginPath(); ctx.ellipse(0, bodyTop, rx, ry, 0, 0, Math.PI * 2);
|
|
161
189
|
ctx.fill(); ctx.stroke();
|
|
162
190
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
191
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
163
192
|
ctx.restore();
|
|
164
193
|
},
|
|
165
194
|
nodeDimensions: { width: W, height: H },
|
|
@@ -201,6 +230,7 @@ export function makePostItRenderer(colorKey) {
|
|
|
201
230
|
ctx.lineTo(W / 2, -H / 2 + fold);
|
|
202
231
|
ctx.stroke();
|
|
203
232
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
233
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
204
234
|
ctx.restore();
|
|
205
235
|
},
|
|
206
236
|
nodeDimensions: { width: W, height: H },
|
|
@@ -224,6 +254,7 @@ export function makeTextFreeRenderer(colorKey) {
|
|
|
224
254
|
ctx.setLineDash([]);
|
|
225
255
|
}
|
|
226
256
|
drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
|
|
257
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
227
258
|
ctx.restore();
|
|
228
259
|
},
|
|
229
260
|
nodeDimensions: { width: W, height: H },
|
|
@@ -266,6 +297,103 @@ export function makeActorRenderer(colorKey) {
|
|
|
266
297
|
lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
|
|
267
298
|
ctx.restore();
|
|
268
299
|
}
|
|
300
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
301
|
+
ctx.restore();
|
|
302
|
+
},
|
|
303
|
+
nodeDimensions: { width: W, height: H },
|
|
304
|
+
};
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Image node ────────────────────────────────────────────────────────────────
|
|
309
|
+
// Images are loaded once and cached. When still loading a placeholder is drawn;
|
|
310
|
+
// once the image is ready network.redraw() is called so the frame updates.
|
|
311
|
+
|
|
312
|
+
const _imgCache = new Map(); // src → HTMLImageElement | 'loading' | 'error'
|
|
313
|
+
|
|
314
|
+
function getCachedImage(src, redrawFn) {
|
|
315
|
+
if (!src) return null;
|
|
316
|
+
const cached = _imgCache.get(src);
|
|
317
|
+
if (cached === 'loading' || cached === 'error') return null;
|
|
318
|
+
if (cached) return cached;
|
|
319
|
+
_imgCache.set(src, 'loading');
|
|
320
|
+
const img = new Image();
|
|
321
|
+
img.onload = () => { _imgCache.set(src, img); redrawFn && redrawFn(); };
|
|
322
|
+
img.onerror = () => { _imgCache.set(src, 'error'); };
|
|
323
|
+
img.src = src;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function makeImageRenderer(colorKey) {
|
|
328
|
+
return function ({ ctx, x, y, id, state: visState, label }) {
|
|
329
|
+
const { W, H, rotation, labelRotation, textAlign, textValign, fontSize, c } = nodeData(id, 160, 120, colorKey || 'c-gray');
|
|
330
|
+
const n = st.nodes && st.nodes.get(id);
|
|
331
|
+
const src = n && n.imageSrc;
|
|
332
|
+
const img = getCachedImage(src, () => st.network && st.network.redraw());
|
|
333
|
+
return {
|
|
334
|
+
drawNode() {
|
|
335
|
+
ctx.save(); ctx.translate(x, y); ctx.rotate(rotation);
|
|
336
|
+
// Border (always visible, orange when selected)
|
|
337
|
+
ctx.strokeStyle = visState.selected ? '#f97316' : c.border;
|
|
338
|
+
ctx.lineWidth = visState.selected ? 2 : 1;
|
|
339
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
340
|
+
ctx.stroke();
|
|
341
|
+
|
|
342
|
+
if (img) {
|
|
343
|
+
// Clip to rounded rect then draw image
|
|
344
|
+
ctx.save();
|
|
345
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
346
|
+
ctx.clip();
|
|
347
|
+
ctx.drawImage(img, -W / 2, -H / 2, W, H);
|
|
348
|
+
ctx.restore();
|
|
349
|
+
} else {
|
|
350
|
+
// Placeholder: light fill + icon
|
|
351
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
352
|
+
roundRect(ctx, -W / 2, -H / 2, W, H, 4);
|
|
353
|
+
ctx.fill();
|
|
354
|
+
ctx.fillStyle = c.border;
|
|
355
|
+
ctx.font = `${Math.round(Math.min(W, H) * 0.25)}px system-ui`;
|
|
356
|
+
ctx.textAlign = 'center';
|
|
357
|
+
ctx.textBaseline = 'middle';
|
|
358
|
+
ctx.fillText(src ? '…' : '🖼', 0, 0);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (label) {
|
|
362
|
+
const lines = String(label).split('\n');
|
|
363
|
+
const lineH = fontSize * 1.3;
|
|
364
|
+
const pad = 6;
|
|
365
|
+
const stripH = lines.length * lineH + pad * 2 - (lineH - fontSize);
|
|
366
|
+
ctx.save();
|
|
367
|
+
ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
368
|
+
const maxTextW = Math.max(...lines.map((l) => ctx.measureText(l).width));
|
|
369
|
+
const stripW = maxTextW + pad * 2;
|
|
370
|
+
const M = 5; // margin from image edge
|
|
371
|
+
// Horizontal position based on textAlign
|
|
372
|
+
let stripX;
|
|
373
|
+
if (textAlign === 'left') stripX = -W / 2 + M;
|
|
374
|
+
else if (textAlign === 'right') stripX = W / 2 - stripW - M;
|
|
375
|
+
else stripX = -stripW / 2;
|
|
376
|
+
// Vertical position based on textValign
|
|
377
|
+
let stripY;
|
|
378
|
+
if (textValign === 'top') stripY = -H / 2 + M;
|
|
379
|
+
else if (textValign === 'bottom') stripY = H / 2 - stripH - M;
|
|
380
|
+
else stripY = -stripH / 2;
|
|
381
|
+
ctx.globalAlpha = 0.7;
|
|
382
|
+
ctx.fillStyle = '#000';
|
|
383
|
+
roundRect(ctx, stripX, stripY, stripW, stripH, 4);
|
|
384
|
+
ctx.fill();
|
|
385
|
+
ctx.globalAlpha = 1;
|
|
386
|
+
// Draw text centered inside the box
|
|
387
|
+
if (labelRotation) ctx.rotate(labelRotation);
|
|
388
|
+
ctx.fillStyle = '#fff';
|
|
389
|
+
ctx.textAlign = 'center';
|
|
390
|
+
ctx.textBaseline = 'middle';
|
|
391
|
+
const textCX = stripX + stripW / 2;
|
|
392
|
+
const startY = stripY + pad + fontSize / 2;
|
|
393
|
+
lines.forEach((line, i) => ctx.fillText(line, textCX, startY + i * lineH));
|
|
394
|
+
ctx.restore();
|
|
395
|
+
}
|
|
396
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
269
397
|
ctx.restore();
|
|
270
398
|
},
|
|
271
399
|
nodeDimensions: { width: W, height: H },
|
|
@@ -294,6 +422,7 @@ const RENDERER_MAP = {
|
|
|
294
422
|
'post-it': makePostItRenderer,
|
|
295
423
|
'text-free':makeTextFreeRenderer,
|
|
296
424
|
actor: makeActorRenderer,
|
|
425
|
+
image: makeImageRenderer,
|
|
297
426
|
};
|
|
298
427
|
|
|
299
428
|
// Default dimensions per shape type (used when nodeWidth/nodeHeight are null).
|
|
@@ -305,6 +434,7 @@ export const SHAPE_DEFAULTS = {
|
|
|
305
434
|
actor: [30, 52],
|
|
306
435
|
'post-it': [120, 100],
|
|
307
436
|
'text-free':[80, 30],
|
|
437
|
+
image: [160, 120],
|
|
308
438
|
};
|
|
309
439
|
|
|
310
440
|
// Builds the full vis.js node property object.
|
|
@@ -52,7 +52,10 @@ export async function loadDiagramList() {
|
|
|
52
52
|
const res = await fetch('/api/diagrams');
|
|
53
53
|
st.diagrams = await res.json();
|
|
54
54
|
renderDiagramList();
|
|
55
|
-
if (st.diagrams.length
|
|
55
|
+
if (!st.diagrams.length) return;
|
|
56
|
+
const urlId = new URLSearchParams(window.location.search).get('id');
|
|
57
|
+
const target = urlId && st.diagrams.find((d) => d.id === urlId) ? urlId : st.diagrams[0].id;
|
|
58
|
+
openDiagram(target);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
export async function openDiagram(id) {
|
|
@@ -116,6 +119,9 @@ export async function saveDiagram() {
|
|
|
116
119
|
nodeWidth: n.nodeWidth || null, nodeHeight: n.nodeHeight || null,
|
|
117
120
|
fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
|
|
118
121
|
rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
|
|
122
|
+
imageSrc: n.imageSrc || null,
|
|
123
|
+
groupId: n.groupId || null,
|
|
124
|
+
nodeLink: n.nodeLink || null,
|
|
119
125
|
x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
|
|
120
126
|
}));
|
|
121
127
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ── Toast notifications ───────────────────────────────────────────────────────
|
|
2
|
+
// Minimal sonner-style toasts, no dependencies.
|
|
3
|
+
|
|
4
|
+
export function showToast(message, type = 'success', duration = 3500) {
|
|
5
|
+
const container = document.getElementById('toastContainer');
|
|
6
|
+
const el = document.createElement('div');
|
|
7
|
+
el.className = 'ld-toast ld-toast--' + type;
|
|
8
|
+
el.textContent = message;
|
|
9
|
+
container.appendChild(el);
|
|
10
|
+
|
|
11
|
+
// Animate in on next frame
|
|
12
|
+
requestAnimationFrame(() => el.classList.add('ld-toast--visible'));
|
|
13
|
+
|
|
14
|
+
const hide = () => {
|
|
15
|
+
el.classList.remove('ld-toast--visible');
|
|
16
|
+
el.addEventListener('transitionend', () => el.remove(), { once: true });
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const timer = setTimeout(hide, duration);
|
|
20
|
+
el.addEventListener('click', () => { clearTimeout(timer); hide(); });
|
|
21
|
+
}
|