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 +2 -0
- package/dist/src/frontend/diagram/clipboard.js +11 -1
- package/dist/src/frontend/diagram/groups.js +99 -0
- package/dist/src/frontend/diagram/link-panel.js +138 -0
- package/dist/src/frontend/diagram/main.js +8 -1
- package/dist/src/frontend/diagram/network.js +63 -7
- package/dist/src/frontend/diagram/node-rendering.js +68 -1
- package/dist/src/frontend/diagram/persistence.js +6 -1
- package/dist/src/frontend/diagram.html +81 -3
- 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
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)
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|