living-documentation 3.8.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.
package/README.md CHANGED
@@ -22,6 +22,8 @@ No cloud, no database, no build step — just point it at a folder of `.md` file
22
22
  - **Admin panel** — configure title, theme, filename pattern, and extra files in the browser
23
23
  - **Inline editing** — edit any document directly in the browser, saves to disk instantly
24
24
  - **Image paste** — paste an image from clipboard in the editor; auto-uploaded and inserted as Markdown
25
+ - **Word Cloud** — visualise the dominant vocabulary of any folder on disk; supports `.md`, `.ts`, `.java`, `.kt`, `.py`, `.go`, `.rs`, `.cs`, `.swift`, `.rb`, `.html`, `.css`, `.yml`, `.json` and more; stop words filtered per language
26
+ - **Diagram editor** — built-in canvas diagram editor (vis-network); deep-link to any diagram with `?id=`
25
27
  - **Zero frontend build** — Tailwind and highlight.js loaded from CDN
26
28
 
27
29
  ---
@@ -45,9 +45,17 @@ export async function copySelectionAsPng() {
45
45
  const cropW = Math.ceil(br.x - tl.x + PAD * 2);
46
46
  const cropH = Math.ceil(br.y - tl.y + PAD * 2);
47
47
 
48
+ // Temporarily deselect everything so orange highlights don't appear in the PNG
49
+ const savedNodeIds = [...st.selectedNodeIds];
50
+ st.network.unselectAll();
51
+ st.network.redraw();
52
+
48
53
  // Grab vis-network's canvas element
49
54
  const visCanvas = document.querySelector('#vis-canvas canvas');
50
- if (!visCanvas) return;
55
+ if (!visCanvas) {
56
+ st.network.selectNodes(savedNodeIds);
57
+ return;
58
+ }
51
59
 
52
60
  // Crop into an offscreen canvas
53
61
  const out = document.createElement('canvas');
@@ -71,6 +79,8 @@ export async function copySelectionAsPng() {
71
79
  showToast('PNG copié dans le presse-papier');
72
80
  } catch {
73
81
  showToast('Impossible de copier l\'image', 'error');
82
+ } finally {
83
+ st.network.selectNodes(savedNodeIds);
74
84
  }
75
85
  }
76
86
 
@@ -0,0 +1,99 @@
1
+ // ── Group management ──────────────────────────────────────────────────────────
2
+
3
+ import { st, markDirty } from './state.js';
4
+ import { SHAPE_DEFAULTS } from './node-rendering.js';
5
+
6
+ // ── Create / destroy ──────────────────────────────────────────────────────────
7
+
8
+ export function groupNodes() {
9
+ if (st.selectedNodeIds.length < 2) return;
10
+ const groupId = 'g' + Date.now();
11
+ st.selectedNodeIds.forEach((id) => st.nodes.update({ id, groupId }));
12
+ markDirty();
13
+ }
14
+
15
+ export function ungroupNodes() {
16
+ if (!st.selectedNodeIds.length) return;
17
+ // Collect all members of any group touched by the selection
18
+ const groupIds = new Set(
19
+ st.selectedNodeIds.map((id) => { const n = st.nodes.get(id); return n && n.groupId; }).filter(Boolean)
20
+ );
21
+ st.nodes.get().forEach((n) => {
22
+ if (n.groupId && groupIds.has(n.groupId)) st.nodes.update({ id: n.id, groupId: null });
23
+ });
24
+ markDirty();
25
+ }
26
+
27
+ // ── Selection expansion ───────────────────────────────────────────────────────
28
+ // Called from onSelectNode — expands selection to all group members.
29
+
30
+ export function expandSelectionToGroup(nodeIds) {
31
+ const groupIds = new Set();
32
+ nodeIds.forEach((id) => {
33
+ const n = st.nodes.get(id);
34
+ if (n && n.groupId) groupIds.add(n.groupId);
35
+ });
36
+ if (!groupIds.size) return nodeIds;
37
+
38
+ const expanded = new Set(nodeIds);
39
+ st.nodes.get().forEach((n) => {
40
+ if (n.groupId && groupIds.has(n.groupId)) expanded.add(n.id);
41
+ });
42
+ return [...expanded];
43
+ }
44
+
45
+ // ── Group outline (drawn on canvas in afterDrawing) ───────────────────────────
46
+
47
+ function nodeBounds(id) {
48
+ const n = st.nodes.get(id);
49
+ const bodyNode = st.network.body.nodes[id];
50
+ if (!bodyNode) return null;
51
+ const shape = (n && n.shapeType) || 'box';
52
+ const defaults = SHAPE_DEFAULTS[shape] || [100, 40];
53
+ const W = (n && n.nodeWidth) || defaults[0];
54
+ const H = (n && n.nodeHeight) || defaults[1];
55
+ const rot = (n && n.rotation) || 0;
56
+ const cx = bodyNode.x, cy = bodyNode.y;
57
+ if (rot === 0) {
58
+ return { minX: cx - W / 2, minY: cy - H / 2, maxX: cx + W / 2, maxY: cy + H / 2 };
59
+ }
60
+ const cos = Math.abs(Math.cos(rot)); const sin = Math.abs(Math.sin(rot));
61
+ const hw = (W * cos + H * sin) / 2;
62
+ const hh = (W * sin + H * cos) / 2;
63
+ return { minX: cx - hw, minY: cy - hh, maxX: cx + hw, maxY: cy + hh };
64
+ }
65
+
66
+ export function drawGroupOutlines(ctx) {
67
+ if (!st.network || !st.selectedNodeIds.length) return;
68
+
69
+ // Find groupIds that have at least one selected member
70
+ const selectedSet = new Set(st.selectedNodeIds);
71
+ const activeGroups = new Set();
72
+ st.selectedNodeIds.forEach((id) => {
73
+ const n = st.nodes.get(id);
74
+ if (n && n.groupId) activeGroups.add(n.groupId);
75
+ });
76
+ if (!activeGroups.size) return;
77
+
78
+ // For each active group, compute bounding box over ALL members
79
+ activeGroups.forEach((groupId) => {
80
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
81
+ st.nodes.get().forEach((n) => {
82
+ if (n.groupId !== groupId) return;
83
+ const b = nodeBounds(n.id);
84
+ if (!b) return;
85
+ minX = Math.min(minX, b.minX); minY = Math.min(minY, b.minY);
86
+ maxX = Math.max(maxX, b.maxX); maxY = Math.max(maxY, b.maxY);
87
+ });
88
+ if (minX === Infinity) return;
89
+
90
+ const PAD = 14;
91
+ ctx.save();
92
+ ctx.strokeStyle = '#6366f1';
93
+ ctx.lineWidth = 1.5;
94
+ ctx.setLineDash([6, 4]);
95
+ ctx.strokeRect(minX - PAD, minY - PAD, maxX - minX + PAD * 2, maxY - minY + PAD * 2);
96
+ ctx.setLineDash([]);
97
+ ctx.restore();
98
+ });
99
+ }
@@ -0,0 +1,138 @@
1
+ // ── Node link panel ───────────────────────────────────────────────────────────
2
+ // Lets the user attach a URL or diagram link to a node.
3
+
4
+ import { st, markDirty } from './state.js';
5
+ import { openDiagram } from './persistence.js';
6
+ import { showToast } from './toast.js';
7
+
8
+ let _panelNodeId = null;
9
+
10
+ export function showLinkPanel(nodeId) {
11
+ _panelNodeId = nodeId;
12
+ const n = st.nodes.get(nodeId);
13
+ const link = (n && n.nodeLink) || null;
14
+
15
+ const panel = document.getElementById('linkPanel');
16
+ panel.classList.remove('hidden');
17
+
18
+ // Populate form from existing link
19
+ const typeUrl = document.getElementById('linkTypeUrl');
20
+ const typeDiagram = document.getElementById('linkTypeDiagram');
21
+ const typeNew = document.getElementById('linkTypeNew');
22
+ const urlInput = document.getElementById('linkUrlInput');
23
+ const diagSelect = document.getElementById('linkDiagramSelect');
24
+ const newNameInput = document.getElementById('linkNewName');
25
+
26
+ // Populate diagram list
27
+ diagSelect.innerHTML = '';
28
+ st.diagrams.forEach((d) => {
29
+ if (d.id === st.currentDiagramId) return; // skip current
30
+ const opt = document.createElement('option');
31
+ opt.value = d.id;
32
+ opt.textContent = d.title;
33
+ diagSelect.appendChild(opt);
34
+ });
35
+
36
+ if (link && link.type === 'url') {
37
+ typeUrl.checked = true;
38
+ urlInput.value = link.value;
39
+ newNameInput.value = '';
40
+ } else if (link && link.type === 'diagram') {
41
+ typeDiagram.checked = true;
42
+ diagSelect.value = link.value;
43
+ urlInput.value = '';
44
+ newNameInput.value = '';
45
+ } else {
46
+ typeUrl.checked = true;
47
+ urlInput.value = '';
48
+ newNameInput.value = '';
49
+ }
50
+
51
+ _syncLinkPanelVisibility();
52
+ }
53
+
54
+ export function hideLinkPanel() {
55
+ document.getElementById('linkPanel').classList.add('hidden');
56
+ _panelNodeId = null;
57
+ }
58
+
59
+ function _syncLinkPanelVisibility() {
60
+ const typeUrl = document.getElementById('linkTypeUrl').checked;
61
+ const typeDiagram = document.getElementById('linkTypeDiagram').checked;
62
+ const typeNew = document.getElementById('linkTypeNew').checked;
63
+ document.getElementById('linkUrlRow').classList.toggle('hidden', !typeUrl);
64
+ document.getElementById('linkDiagramRow').classList.toggle('hidden', !typeDiagram);
65
+ document.getElementById('linkNewRow').classList.toggle('hidden', !typeNew);
66
+ }
67
+
68
+ export function saveLinkPanel() {
69
+ if (_panelNodeId === null) return;
70
+ const typeUrl = document.getElementById('linkTypeUrl').checked;
71
+ const typeDiagram = document.getElementById('linkTypeDiagram').checked;
72
+ const typeNew = document.getElementById('linkTypeNew').checked;
73
+
74
+ if (typeUrl) {
75
+ const value = document.getElementById('linkUrlInput').value.trim();
76
+ st.nodes.update({ id: _panelNodeId, nodeLink: value ? { type: 'url', value } : null });
77
+ markDirty();
78
+ hideLinkPanel();
79
+ } else if (typeDiagram) {
80
+ const value = document.getElementById('linkDiagramSelect').value;
81
+ if (!value) return;
82
+ st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value } });
83
+ markDirty();
84
+ hideLinkPanel();
85
+ } else if (typeNew) {
86
+ const name = document.getElementById('linkNewName').value.trim();
87
+ if (!name) return;
88
+ _createAndLinkDiagram(name);
89
+ }
90
+ }
91
+
92
+ async function _createAndLinkDiagram(title) {
93
+ const id = 'd' + Date.now();
94
+ await fetch(`/api/diagrams/${id}`, {
95
+ method: 'PUT',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ title, nodes: [], edges: [] }),
98
+ });
99
+ const res = await fetch('/api/diagrams');
100
+ st.diagrams = await res.json();
101
+ st.nodes.update({ id: _panelNodeId, nodeLink: { type: 'diagram', value: id } });
102
+ markDirty();
103
+ hideLinkPanel();
104
+ showToast(`Diagramme "${title}" créé et lié`);
105
+ }
106
+
107
+ export function removeLinkPanel() {
108
+ if (_panelNodeId === null) return;
109
+ st.nodes.update({ id: _panelNodeId, nodeLink: null });
110
+ markDirty();
111
+ hideLinkPanel();
112
+ }
113
+
114
+ // ── Navigation ────────────────────────────────────────────────────────────────
115
+ // Called on click (not drag). Returns true if navigation happened.
116
+
117
+ export function navigateNodeLink(nodeId) {
118
+ const n = st.nodes.get(nodeId);
119
+ const link = n && n.nodeLink;
120
+ if (!link) return false;
121
+ if (link.type === 'url') {
122
+ window.open(link.value, '_blank', 'noopener');
123
+ return true;
124
+ }
125
+ if (link.type === 'diagram') {
126
+ openDiagram(link.value);
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+
132
+ // ── Wire radio buttons ────────────────────────────────────────────────────────
133
+ ['linkTypeUrl', 'linkTypeDiagram', 'linkTypeNew'].forEach((id) => {
134
+ document.getElementById(id).addEventListener('change', _syncLinkPanelVisibility);
135
+ });
136
+ document.getElementById('btnLinkSave').addEventListener('click', saveLinkPanel);
137
+ document.getElementById('btnLinkRemove').addEventListener('click', removeLinkPanel);
138
+ document.getElementById('btnLinkCancel').addEventListener('click', hideLinkPanel);
@@ -4,6 +4,8 @@
4
4
  import { st, markDirty } from './state.js';
5
5
  import { TOOL_BTN_MAP } from './constants.js';
6
6
  import { showNodePanel, hideNodePanel, setNodeColor, changeNodeFontSize, setTextAlign, setTextValign, changeZOrder, activateStamp, cancelStamp, stepRotate } from './node-panel.js';
7
+ import { groupNodes, ungroupNodes } from './groups.js';
8
+ import { showLinkPanel, hideLinkPanel } from './link-panel.js';
7
9
  import { hideEdgePanel, setEdgeArrow, setEdgeDashes, changeEdgeFontSize } from './edge-panel.js';
8
10
  import { startLabelEdit, startEdgeLabelEdit, hideLabelInput } from './label-editor.js';
9
11
  import { hideSelectionOverlay } from './selection-overlay.js';
@@ -108,6 +110,9 @@ document.getElementById('nodePanel').addEventListener('click', (e) => {
108
110
  if (colorBtn) setNodeColor(colorBtn.dataset.color);
109
111
  });
110
112
  document.getElementById('btnNodeLabelEdit').addEventListener('click', startLabelEdit);
113
+ document.getElementById('btnNodeLink').addEventListener('click', () => {
114
+ if (st.selectedNodeIds.length === 1) showLinkPanel(st.selectedNodeIds[0]);
115
+ });
111
116
  document.getElementById('btnNodeFontDecrease').addEventListener('click', () => changeNodeFontSize(-1));
112
117
  document.getElementById('btnNodeFontIncrease').addEventListener('click', () => changeNodeFontSize(1));
113
118
  document.getElementById('btnAlignLeft').addEventListener('click', () => setTextAlign('left'));
@@ -131,6 +136,8 @@ document.getElementById('btnRotateCCW').addEventListener('click', () => stepRo
131
136
  document.getElementById('btnStampColor').addEventListener('click', () => activateStamp('color'));
132
137
  document.getElementById('btnStampFontSize').addEventListener('click', () => activateStamp('fontSize'));
133
138
  document.getElementById('btnCopyPng').addEventListener('click', () => copySelectionAsPng());
139
+ document.getElementById('btnGroup').addEventListener('click', () => groupNodes());
140
+ document.getElementById('btnUngroup').addEventListener('click', () => ungroupNodes());
134
141
 
135
142
  // ── Edge panel wiring ─────────────────────────────────────────────────────────
136
143
 
@@ -153,7 +160,7 @@ document.addEventListener('keydown', (e) => {
153
160
  if ((e.metaKey || e.ctrlKey) && e.key === 'c') { e.preventDefault(); copySelected(); return; }
154
161
  if ((e.metaKey || e.ctrlKey) && e.key === 'v') { e.preventDefault(); pasteClipboard(); return; }
155
162
  if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); saveDiagram(); return; }
156
- if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); setTool('select'); return; }
163
+ if (e.key === 'Escape' || e.key === 's' || e.key === 'S') { cancelStamp(); hideLinkPanel(); setTool('select'); return; }
157
164
  if (e.key === 'r' || e.key === 'R') { setTool('addNode', 'box'); return; }
158
165
  if (e.key === 'e' || e.key === 'E') { setTool('addNode', 'ellipse'); return; }
159
166
  if (e.key === 'd' || e.key === 'D') { setTool('addNode', 'database'); return; }
@@ -14,6 +14,8 @@ import { updateSelectionOverlay, hideSelectionOverlay } from './selection-overla
14
14
  import { drawGrid, onDragEnd } from './grid.js';
15
15
  import { drawDebugOverlay } from './debug.js';
16
16
  import { updateZoomDisplay } from './zoom.js';
17
+ import { expandSelectionToGroup, drawGroupOutlines } from './groups.js';
18
+ import { navigateNodeLink, hideLinkPanel } from './link-panel.js';
17
19
 
18
20
  export function initNetwork(savedNodes, savedEdges) {
19
21
  const container = document.getElementById('vis-canvas');
@@ -65,26 +67,49 @@ export function initNetwork(savedNodes, savedEdges) {
65
67
  st.canonicalOrder = [...st.network.body.nodeIndices];
66
68
  st.network.renderer._drawNodes = function (ctx, alwaysShow = false) {
67
69
  const bodyNodes = this.body.nodes;
70
+ const bodyEdges = this.body.edges;
68
71
  const margin = 20;
69
72
  const topLeft = this.canvas.DOMtoCanvas({ x: -margin, y: -margin });
70
73
  const bottomRight = this.canvas.DOMtoCanvas({ x: this.canvas.frame.canvas.clientWidth + margin, y: this.canvas.frame.canvas.clientHeight + margin });
71
74
  const viewableArea = { top: topLeft.y, left: topLeft.x, bottom: bottomRight.y, right: bottomRight.x };
72
- const drawExternalLabelCallbacks = [];
73
75
 
74
- 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
+
75
95
  const node = bodyNodes[id];
76
96
  if (!node) continue;
77
97
  if (alwaysShow === true || node.isBoundingBoxOverlappingWith(viewableArea) === true) {
78
- const r = node.draw(ctx);
79
- // All shapes are ctxRenderer (shape:'custom') and draw their own labels.
80
- // Skip drawExternalLabel entirely to avoid double-rendering.
98
+ node.draw(ctx);
81
99
  } else {
82
100
  node.updateBoundingBox(ctx, node.selected);
83
101
  }
84
102
  }
85
- return { drawExternalLabels() { for (const draw of drawExternalLabelCallbacks) draw(); } };
103
+ return { drawExternalLabels() {} };
86
104
  };
87
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
+
88
113
  // Keep canonicalOrder in sync with DataSet add/remove events
89
114
  st.nodes.on('add', (_, { items }) => {
90
115
  const existing = new Set(st.canonicalOrder);
@@ -95,7 +120,9 @@ export function initNetwork(savedNodes, savedEdges) {
95
120
  st.canonicalOrder = st.canonicalOrder.filter((id) => !removed.has(id));
96
121
  });
97
122
 
123
+ st.network.on('click', onClickNode);
98
124
  st.network.on('doubleClick', onDoubleClick);
125
+ st.network.on('dragStart', onDragStart);
99
126
  st.network.on('selectNode', onSelectNode);
100
127
  st.network.on('deselectNode', onDeselectAll);
101
128
  st.network.on('selectEdge', onSelectEdge);
@@ -104,6 +131,7 @@ export function initNetwork(savedNodes, savedEdges) {
104
131
  st.network.on('dragEnd', onDragEnd);
105
132
  st.network.on('beforeDrawing', drawGrid);
106
133
  st.network.on('afterDrawing', updateSelectionOverlay);
134
+ st.network.on('afterDrawing', (ctx) => drawGroupOutlines(ctx));
107
135
  st.network.on('afterDrawing', () => drawDebugOverlay());
108
136
 
109
137
  document.getElementById('emptyState').classList.add('hidden');
@@ -186,8 +214,35 @@ export function createImageNode(imageSrc, canvasX, canvasY) {
186
214
  }, 50);
187
215
  }
188
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;
189
235
  function onSelectNode(params) {
190
- 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
+ }
191
246
  st.selectedEdgeIds = [];
192
247
  hideEdgePanel();
193
248
  showNodePanel();
@@ -202,6 +257,7 @@ function onSelectEdge(params) {
202
257
  }
203
258
 
204
259
  function onDeselectAll() {
260
+ hideLinkPanel();
205
261
  st.selectedNodeIds = [];
206
262
  st.selectedEdgeIds = [];
207
263
  hideNodePanel();
@@ -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,7 @@ 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);
269
301
  ctx.restore();
270
302
  },
271
303
  nodeDimensions: { width: W, height: H },
@@ -326,7 +358,42 @@ export function makeImageRenderer(colorKey) {
326
358
  ctx.fillText(src ? '…' : '🖼', 0, 0);
327
359
  }
328
360
 
329
- if (label) drawLabel(ctx, label, fontSize, c.font, textAlign, textValign, W, H, labelRotation);
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);
330
397
  ctx.restore();
331
398
  },
332
399
  nodeDimensions: { width: W, height: H },
@@ -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) {
@@ -117,6 +120,8 @@ export async function saveDiagram() {
117
120
  fontSize: n.fontSize || null, textAlign: n.textAlign || null, textValign: n.textValign || null,
118
121
  rotation: n.rotation || 0, labelRotation: n.labelRotation || 0,
119
122
  imageSrc: n.imageSrc || null,
123
+ groupId: n.groupId || null,
124
+ nodeLink: n.nodeLink || null,
120
125
  x: positions[n.id]?.x ?? n.x, y: positions[n.id]?.y ?? n.y,
121
126
  }));
122
127