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.

@@ -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
- for (const id of st.canonicalOrder) {
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
- const r = node.draw(ctx);
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() { for (const draw of drawExternalLabelCallbacks) draw(); } };
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
- st.selectedNodeIds = params.nodes;
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 > 0) openDiagram(st.diagrams[0].id);
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
+ }