living-documentation 7.42.0 → 7.43.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/dist/src/frontend/diagram/custom-shapes.js +96 -0
- package/dist/src/frontend/diagram/main.js +7 -0
- package/dist/src/frontend/diagram/network.js +57 -49
- package/dist/src/frontend/diagram/node-rendering.js +96 -0
- package/dist/src/frontend/diagram/persistence.js +1 -0
- package/dist/src/frontend/diagram/ports.js +46 -13
- package/dist/src/frontend/diagram/state.js +2 -0
- package/dist/src/frontend/diagram.html +65 -0
- package/dist/src/frontend/i18n/en.json +1 -0
- package/dist/src/frontend/i18n/fr.json +1 -0
- package/dist/src/frontend/shape-editor.html +465 -0
- package/dist/src/routes/shape-libraries.d.ts +3 -0
- package/dist/src/routes/shape-libraries.d.ts.map +1 -0
- package/dist/src/routes/shape-libraries.js +98 -0
- package/dist/src/routes/shape-libraries.js.map +1 -0
- package/dist/src/server.d.ts.map +1 -1
- package/dist/src/server.js +3 -0
- package/dist/src/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { st } from './state.js';
|
|
2
|
+
|
|
3
|
+
export const CUSTOM_SHAPE_TYPE = 'custom-shape';
|
|
4
|
+
export const CUSTOM_SHAPE_TOOL_PREFIX = 'custom-shape:';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CUSTOM_ANCHORS = [
|
|
7
|
+
{ id: 'N', x: 0.5, y: 0 },
|
|
8
|
+
{ id: 'NE', x: 1, y: 0 },
|
|
9
|
+
{ id: 'E', x: 1, y: 0.5 },
|
|
10
|
+
{ id: 'SE', x: 1, y: 1 },
|
|
11
|
+
{ id: 'S', x: 0.5, y: 1 },
|
|
12
|
+
{ id: 'SW', x: 0, y: 1 },
|
|
13
|
+
{ id: 'W', x: 0, y: 0.5 },
|
|
14
|
+
{ id: 'NW', x: 0, y: 0 },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function isCustomShapeTool(shape) {
|
|
18
|
+
return typeof shape === 'string' && shape.startsWith(CUSTOM_SHAPE_TOOL_PREFIX);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function customShapeIdFromTool(shape) {
|
|
22
|
+
return isCustomShapeTool(shape) ? shape.slice(CUSTOM_SHAPE_TOOL_PREFIX.length) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getCustomShapeDefinition(id) {
|
|
26
|
+
if (!id) return null;
|
|
27
|
+
return st.customShapeDefs && st.customShapeDefs.get(id) || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getCustomShapeDefaultSize(id) {
|
|
31
|
+
const def = getCustomShapeDefinition(id);
|
|
32
|
+
return [def && def.width || 96, def && def.height || 96];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getCustomShapeAnchors(id) {
|
|
36
|
+
const def = getCustomShapeDefinition(id);
|
|
37
|
+
return def && Array.isArray(def.anchors) && def.anchors.length
|
|
38
|
+
? def.anchors
|
|
39
|
+
: DEFAULT_CUSTOM_ANCHORS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getCustomShapeLabelPlacement(id) {
|
|
43
|
+
const def = getCustomShapeDefinition(id);
|
|
44
|
+
return def && def.labelPlacement === 'center' ? 'center' : 'below';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function loadCustomShapeLibraries() {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch('/api/shape-libraries');
|
|
50
|
+
if (!res.ok) throw new Error('shape libraries unavailable');
|
|
51
|
+
const store = await res.json();
|
|
52
|
+
const libraries = Array.isArray(store.libraries) ? store.libraries : [];
|
|
53
|
+
st.customShapeLibraries = libraries;
|
|
54
|
+
st.customShapeDefs = new Map();
|
|
55
|
+
libraries.forEach((library) => {
|
|
56
|
+
(library.shapes || []).forEach((shape) => st.customShapeDefs.set(shape.id, shape));
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
st.customShapeLibraries = [];
|
|
60
|
+
st.customShapeDefs = new Map();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderCustomShapeBar() {
|
|
65
|
+
const bar = document.getElementById('customShapeBar');
|
|
66
|
+
const body = document.getElementById('customShapeBarBody');
|
|
67
|
+
if (!bar || !body) return;
|
|
68
|
+
|
|
69
|
+
body.innerHTML = '';
|
|
70
|
+
const libraries = st.customShapeLibraries || [];
|
|
71
|
+
const shapes = libraries.flatMap((library) =>
|
|
72
|
+
(library.shapes || []).map((shape) => ({ ...shape, libraryName: library.name })),
|
|
73
|
+
);
|
|
74
|
+
bar.classList.toggle('hidden', shapes.length === 0);
|
|
75
|
+
|
|
76
|
+
shapes.forEach((shape) => {
|
|
77
|
+
const btn = document.createElement('button');
|
|
78
|
+
btn.type = 'button';
|
|
79
|
+
btn.className = 'custom-shape-btn';
|
|
80
|
+
btn.title = `${shape.libraryName || ''}${shape.libraryName ? ' · ' : ''}${shape.name}`;
|
|
81
|
+
btn.dataset.customShapeId = shape.id;
|
|
82
|
+
|
|
83
|
+
const img = document.createElement('img');
|
|
84
|
+
img.src = shape.imageSrc;
|
|
85
|
+
img.alt = '';
|
|
86
|
+
img.draggable = false;
|
|
87
|
+
btn.appendChild(img);
|
|
88
|
+
|
|
89
|
+
btn.addEventListener('click', () => {
|
|
90
|
+
window.dispatchEvent(new CustomEvent('diagram:setTool', {
|
|
91
|
+
detail: { tool: 'addNode', shape: `${CUSTOM_SHAPE_TOOL_PREFIX}${shape.id}` },
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
body.appendChild(btn);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -22,6 +22,7 @@ import { promptImageName } from './image-name-modal.js';
|
|
|
22
22
|
import { showToast } from './toast.js';
|
|
23
23
|
import { t } from './t.js';
|
|
24
24
|
import { initEvidenceMode, toggleEvidenceMode } from './evidence.js';
|
|
25
|
+
import { customShapeIdFromTool, isCustomShapeTool } from './custom-shapes.js';
|
|
25
26
|
|
|
26
27
|
// ── Tool management ───────────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -36,6 +37,12 @@ function setTool(tool, shape) {
|
|
|
36
37
|
const key = tool === 'addNode' ? `addNode:${shape || st.pendingShape}` : tool;
|
|
37
38
|
const btn = document.getElementById(TOOL_BTN_MAP[key]);
|
|
38
39
|
if (btn) btn.classList.add('tool-active');
|
|
40
|
+
document.querySelectorAll('.custom-shape-btn').forEach((b) => {
|
|
41
|
+
b.classList.toggle(
|
|
42
|
+
'tool-active',
|
|
43
|
+
tool === 'addNode' && isCustomShapeTool(shape || st.pendingShape) && b.dataset.customShapeId === customShapeIdFromTool(shape || st.pendingShape),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
39
46
|
|
|
40
47
|
document.getElementById('vis-canvas').classList.toggle('cursor-crosshair', tool === 'addNode' || tool === 'addEdge');
|
|
41
48
|
|
|
@@ -24,6 +24,7 @@ import { getNearestPort, getPortPosition, drawPortDots, drawPortEdge, distanceTo
|
|
|
24
24
|
import { getLastFreeArrowStyle } from './edge-panel.js';
|
|
25
25
|
import { getLastNodeStyle } from './node-panel.js';
|
|
26
26
|
import { installUnlockHold } from './unlock-hold.js';
|
|
27
|
+
import { CUSTOM_SHAPE_TYPE, customShapeIdFromTool, getCustomShapeDefaultSize, getCustomShapeDefinition } from './custom-shapes.js';
|
|
27
28
|
|
|
28
29
|
// Module-level port-hover state — shared between initNetwork event handlers and
|
|
29
30
|
// module-level helpers (_onAnchorSnapConnect).
|
|
@@ -102,14 +103,13 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
102
103
|
enabled: false,
|
|
103
104
|
addEdge(data, callback) {
|
|
104
105
|
// Block edges from/to locked nodes or anchors (free-arrow endpoints).
|
|
105
|
-
// Also block when the
|
|
106
|
-
//
|
|
106
|
+
// Also block when the source sits on top of a lower-z target that
|
|
107
|
+
// contains it: that target is acting as a background surface, and the
|
|
108
|
+
// mouseup handler turns the gesture into a free-arrow endpoint instead.
|
|
107
109
|
const fromNode = st.nodes.get(data.from) || {};
|
|
108
110
|
const toNode = st.nodes.get(data.to) || {};
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const toBelow = fromZ !== -1 && toZ !== -1 && toZ < fromZ;
|
|
112
|
-
if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || toBelow) {
|
|
111
|
+
const targetContainsSource = lowerZTargetContainsSource(data.from, data.to);
|
|
112
|
+
if (fromNode.locked || toNode.locked || fromNode.shapeType === 'anchor' || toNode.shapeType === 'anchor' || targetContainsSource) {
|
|
113
113
|
_addEdgeFromPort = null;
|
|
114
114
|
setTimeout(() => { if (st.currentTool === 'addEdge') st.network.addEdgeMode(); }, 0);
|
|
115
115
|
return;
|
|
@@ -761,14 +761,11 @@ export function initNetwork(savedNodes, savedEdges, edgesStraight = false) {
|
|
|
761
761
|
// Locked targets are non-interactive: treat them exactly like empty canvas
|
|
762
762
|
// so a free-arrow endpoint is created at the release point — matches the
|
|
763
763
|
// behaviour of free arrows drawn over a locked shape (Path B).
|
|
764
|
-
// Also: if the
|
|
765
|
-
//
|
|
766
|
-
// point on top of the source's layer, not at the underlying shape.
|
|
764
|
+
// Also: if the source sits on top of a lower-z target that contains it
|
|
765
|
+
// (e.g. a post-it on a background image), don't bind to that background.
|
|
767
766
|
const targetLocked = !!(targetData && targetData.locked);
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
const targetBelow = targetId && sourceZ !== -1 && targetZ !== -1 && targetZ < sourceZ;
|
|
771
|
-
if (!targetId || targetLocked || targetBelow) {
|
|
767
|
+
const targetContainsSource = targetId && lowerZTargetContainsSource(_addEdgeFromId, targetId);
|
|
768
|
+
if (!targetId || targetLocked || targetContainsSource) {
|
|
772
769
|
pushSnapshot();
|
|
773
770
|
const cp = st.network.DOMtoCanvas(pos);
|
|
774
771
|
const anchorId = 'a' + Date.now();
|
|
@@ -1142,6 +1139,15 @@ function nodeContainsCanvasPoint(id, canvasPos) {
|
|
|
1142
1139
|
return Math.abs(lx) <= W / 2 && Math.abs(ly) <= H / 2;
|
|
1143
1140
|
}
|
|
1144
1141
|
|
|
1142
|
+
function lowerZTargetContainsSource(sourceId, targetId) {
|
|
1143
|
+
if (!sourceId || !targetId) return false;
|
|
1144
|
+
const sourceZ = st.canonicalOrder.indexOf(sourceId);
|
|
1145
|
+
const targetZ = st.canonicalOrder.indexOf(targetId);
|
|
1146
|
+
if (sourceZ === -1 || targetZ === -1 || targetZ >= sourceZ) return false;
|
|
1147
|
+
const sourcePos = st.network && st.network.getPositions([sourceId])[sourceId];
|
|
1148
|
+
return !!(sourcePos && nodeContainsCanvasPoint(targetId, sourcePos));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1145
1151
|
// Returns the topmost (highest z-order) node containing canvasPos.
|
|
1146
1152
|
// Ignores anchor nodes and respects st.canonicalOrder.
|
|
1147
1153
|
function topmostNodeAt(canvasPos) {
|
|
@@ -1269,29 +1275,34 @@ function onDoubleClick(params) {
|
|
|
1269
1275
|
const srcEvent = params.event && params.event.srcEvent;
|
|
1270
1276
|
const clientPos = srcEvent ? { x: srcEvent.clientX, y: srcEvent.clientY } : null;
|
|
1271
1277
|
const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
|
|
1272
|
-
const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
|
|
1273
|
-
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1274
|
-
const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
|
|
1275
|
-
.filter((id, index, list) => id && list.indexOf(id) === index)
|
|
1276
|
-
.filter((id) => isEdgeInteractive(st.edges.get(id)));
|
|
1277
|
-
const edgeId = edgeCandidates.reduce((bestId, id) => {
|
|
1278
|
-
if (!bestId) return id;
|
|
1279
|
-
return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
|
|
1280
|
-
}, null);
|
|
1281
|
-
|
|
1282
1278
|
const topNodeId = topmostNodeAt(params.pointer.canvas);
|
|
1283
1279
|
const topNode = topNodeId && st.nodes.get(topNodeId);
|
|
1284
1280
|
const canEditTopNode = topNode && !topNode.locked && topNode.shapeType !== 'anchor';
|
|
1285
|
-
const topNodeWins = canEditTopNode && !labelEdgeId && (!edgeId || st.canonicalOrder.indexOf(topNodeId) >= edgeDrawLevel(st.edges.get(edgeId)));
|
|
1286
1281
|
|
|
1287
|
-
|
|
1282
|
+
// Direct node double-clicks must edit the node. Dense port-edge diagrams can
|
|
1283
|
+
// have many visual edges crossing a node; those should not steal the edit.
|
|
1284
|
+
// The only exception is an actual edge-label hit, which is intentional.
|
|
1285
|
+
if (canEditTopNode && !labelEdgeId) {
|
|
1288
1286
|
st.selectedNodeIds = [topNodeId];
|
|
1289
1287
|
st.selectedEdgeIds = [];
|
|
1290
1288
|
st.network.setSelection({ nodes: st.selectedNodeIds, edges: [] });
|
|
1291
1289
|
showNodePanel();
|
|
1292
1290
|
hideEdgePanel();
|
|
1293
1291
|
startLabelEdit();
|
|
1294
|
-
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const nativeEdgeId = clientPos ? st.network.getEdgeAt(clientPos) : null;
|
|
1296
|
+
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1297
|
+
const edgeCandidates = [labelEdgeId, nativeEdgeId, portEdge && portEdge.id, ...params.edges]
|
|
1298
|
+
.filter((id, index, list) => id && list.indexOf(id) === index)
|
|
1299
|
+
.filter((id) => isEdgeInteractive(st.edges.get(id)));
|
|
1300
|
+
const edgeId = edgeCandidates.reduce((bestId, id) => {
|
|
1301
|
+
if (!bestId) return id;
|
|
1302
|
+
return edgeDrawLevel(st.edges.get(id)) >= edgeDrawLevel(st.edges.get(bestId)) ? id : bestId;
|
|
1303
|
+
}, null);
|
|
1304
|
+
|
|
1305
|
+
if (edgeId) {
|
|
1295
1306
|
st.selectedNodeIds = [];
|
|
1296
1307
|
st.selectedEdgeIds = [edgeId];
|
|
1297
1308
|
st.network.setSelection({ nodes: [], edges: [edgeId] });
|
|
@@ -1304,9 +1315,12 @@ function onDoubleClick(params) {
|
|
|
1304
1315
|
} else if (st.currentTool === 'addNode') {
|
|
1305
1316
|
pushSnapshot();
|
|
1306
1317
|
const id = 'n' + Date.now();
|
|
1307
|
-
const
|
|
1308
|
-
const
|
|
1309
|
-
const
|
|
1318
|
+
const customShapeId = customShapeIdFromTool(st.pendingShape);
|
|
1319
|
+
const shapeType = customShapeId ? CUSTOM_SHAPE_TYPE : st.pendingShape;
|
|
1320
|
+
const customDef = customShapeId ? getCustomShapeDefinition(customShapeId) : null;
|
|
1321
|
+
const defaults = customShapeId ? getCustomShapeDefaultSize(customShapeId) : SHAPE_DEFAULTS[shapeType] || [100, 40];
|
|
1322
|
+
const fallbackColor = shapeType === 'post-it' ? 'c-amber' : 'c-gray';
|
|
1323
|
+
const lastStyle = getLastNodeStyle(shapeType);
|
|
1310
1324
|
const colorKey = lastStyle.colorKey || fallbackColor;
|
|
1311
1325
|
const fontSize = lastStyle.fontSize || null;
|
|
1312
1326
|
const textAlign = lastStyle.textAlign || null;
|
|
@@ -1314,13 +1328,13 @@ function onDoubleClick(params) {
|
|
|
1314
1328
|
const rawPos = params.pointer.canvas;
|
|
1315
1329
|
const pos = st.gridEnabled ? snapToGrid(rawPos.x, rawPos.y) : rawPos;
|
|
1316
1330
|
st.nodes.add({
|
|
1317
|
-
id, label:
|
|
1318
|
-
shapeType:
|
|
1331
|
+
id, label: shapeType === 'text-free' ? t('diagram.label_input.placeholder') : (customDef ? customDef.name : 'Node'),
|
|
1332
|
+
shapeType, customShapeId: customShapeId || null, colorKey,
|
|
1319
1333
|
nodeWidth: defaults[0], nodeHeight: defaults[1],
|
|
1320
1334
|
fontSize, textAlign, textValign,
|
|
1321
1335
|
rotation: 0, labelRotation: 0,
|
|
1322
1336
|
x: pos.x, y: pos.y,
|
|
1323
|
-
...visNodeProps(
|
|
1337
|
+
...visNodeProps(shapeType, colorKey, defaults[0], defaults[1], fontSize, textAlign, textValign),
|
|
1324
1338
|
});
|
|
1325
1339
|
markDirty();
|
|
1326
1340
|
setTimeout(() => {
|
|
@@ -1467,28 +1481,22 @@ function onClickNode(params) {
|
|
|
1467
1481
|
// passing it to getEdgeAt() causes a double-subtraction of the container rect and
|
|
1468
1482
|
// returns null. Passing clientX/Y lets vis-network do its own pixel-perfect detection.
|
|
1469
1483
|
if (params.nodes.length > 0) {
|
|
1470
|
-
const
|
|
1471
|
-
const nativeEdgeId = st.network.getEdgeAt(clientPos);
|
|
1472
|
-
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1473
|
-
const edgeId = nativeEdgeId || (portEdge && portEdge.id);
|
|
1474
|
-
|
|
1475
|
-
// First honour the app-managed z-order. vis-network may report an edge or a
|
|
1476
|
-
// lower node at the click point; compare draw levels so the visibly top
|
|
1477
|
-
// element gets the click.
|
|
1484
|
+
const labelEdgeId = edgeLabelAtCanvasPoint(params.pointer.canvas);
|
|
1478
1485
|
const top = topmostNodeAt(params.pointer.canvas);
|
|
1479
1486
|
if (top) {
|
|
1480
1487
|
const topNode = st.nodes.get(top);
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
selectNodesFromClick(top, params.event.srcEvent);
|
|
1487
|
-
}, 0);
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1488
|
+
if (topNode && !topNode.locked && topNode.shapeType !== 'anchor' && !labelEdgeId) {
|
|
1489
|
+
setTimeout(() => {
|
|
1490
|
+
selectNodesFromClick(top, params.event.srcEvent);
|
|
1491
|
+
}, 0);
|
|
1492
|
+
return;
|
|
1490
1493
|
}
|
|
1491
1494
|
}
|
|
1495
|
+
|
|
1496
|
+
const clientPos = { x: params.event.srcEvent.clientX, y: params.event.srcEvent.clientY };
|
|
1497
|
+
const nativeEdgeId = st.network.getEdgeAt(clientPos);
|
|
1498
|
+
const portEdge = nearestPortEdgeAt(params.pointer.canvas);
|
|
1499
|
+
const edgeId = labelEdgeId || nativeEdgeId || (portEdge && portEdge.id);
|
|
1492
1500
|
if (edgeId) {
|
|
1493
1501
|
const edge = st.edges.get(edgeId);
|
|
1494
1502
|
const fromN = edge && st.nodes.get(edge.from);
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { NODE_COLORS } from "./constants.js";
|
|
8
8
|
import { st } from "./state.js";
|
|
9
|
+
import { CUSTOM_SHAPE_TYPE, getCustomShapeDefinition } from "./custom-shapes.js";
|
|
9
10
|
|
|
10
11
|
// Returns the color object for a key, checking runtime overrides first.
|
|
11
12
|
function getNodeColor(colorKey) {
|
|
@@ -182,6 +183,19 @@ function nodeData(id, defaultW, defaultH, defaultColorKey) {
|
|
|
182
183
|
};
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
function textLines(label) {
|
|
187
|
+
return label ? String(label).split("\n") : [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function customShapeImageOffsetY(node, label) {
|
|
191
|
+
const def = getCustomShapeDefinition(node && node.customShapeId);
|
|
192
|
+
if (!def || def.labelPlacement !== "below" || !label) return 0;
|
|
193
|
+
const fontSize = (node && node.fontSize) || 13;
|
|
194
|
+
const lines = textLines(label);
|
|
195
|
+
if (!lines.length) return 0;
|
|
196
|
+
return -(lines.length * fontSize * 1.3 + 8) / 2;
|
|
197
|
+
}
|
|
198
|
+
|
|
185
199
|
// ── Shape renderers ───────────────────────────────────────────────────────────
|
|
186
200
|
|
|
187
201
|
export function makeBoxRenderer(colorKey) {
|
|
@@ -653,6 +667,86 @@ export function makeImageRenderer(colorKey) {
|
|
|
653
667
|
};
|
|
654
668
|
}
|
|
655
669
|
|
|
670
|
+
export function makeCustomShapeRenderer(colorKey) {
|
|
671
|
+
return function ({ ctx, x, y, id, state: visState, label }) {
|
|
672
|
+
const n = st.nodes && st.nodes.get(id);
|
|
673
|
+
const def = getCustomShapeDefinition(n && n.customShapeId);
|
|
674
|
+
const defaultW = def && def.width || 96;
|
|
675
|
+
const defaultH = def && def.height || 96;
|
|
676
|
+
const {
|
|
677
|
+
W,
|
|
678
|
+
H,
|
|
679
|
+
rotation,
|
|
680
|
+
labelRotation,
|
|
681
|
+
textAlign,
|
|
682
|
+
textValign,
|
|
683
|
+
fontSize,
|
|
684
|
+
c,
|
|
685
|
+
} = nodeData(id, defaultW, defaultH, colorKey || "c-gray");
|
|
686
|
+
const img = getCachedImage(def && def.imageSrc, () => st.network && st.network.redraw());
|
|
687
|
+
const labelBelow = def && def.labelPlacement === "below";
|
|
688
|
+
const lines = textLines(label);
|
|
689
|
+
const belowLabelH = labelBelow && lines.length ? lines.length * fontSize * 1.3 + 8 : 0;
|
|
690
|
+
const imageOffsetY = labelBelow ? -belowLabelH / 2 : 0;
|
|
691
|
+
const totalH = H + belowLabelH;
|
|
692
|
+
return {
|
|
693
|
+
drawNode() {
|
|
694
|
+
ctx.save();
|
|
695
|
+
ctx.translate(x, y);
|
|
696
|
+
ctx.rotate(rotation);
|
|
697
|
+
|
|
698
|
+
if (img) {
|
|
699
|
+
ctx.drawImage(img, -W / 2, imageOffsetY - H / 2, W, H);
|
|
700
|
+
} else {
|
|
701
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
702
|
+
ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
|
|
703
|
+
ctx.lineWidth = 1.5;
|
|
704
|
+
roundRect(ctx, -W / 2, imageOffsetY - H / 2, W, H, 4);
|
|
705
|
+
ctx.fill();
|
|
706
|
+
ctx.stroke();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (visState.selected || visState.hover) {
|
|
710
|
+
ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
|
|
711
|
+
ctx.lineWidth = visState.selected ? 2 : 1;
|
|
712
|
+
ctx.setLineDash([4, 3]);
|
|
713
|
+
roundRect(ctx, -W / 2, -totalH / 2, W, totalH, 4);
|
|
714
|
+
ctx.stroke();
|
|
715
|
+
ctx.setLineDash([]);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (labelBelow && lines.length) {
|
|
719
|
+
ctx.save();
|
|
720
|
+
if (labelRotation) ctx.rotate(labelRotation);
|
|
721
|
+
ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
722
|
+
ctx.fillStyle = c.font;
|
|
723
|
+
ctx.textAlign = "center";
|
|
724
|
+
ctx.textBaseline = "top";
|
|
725
|
+
const lineH = fontSize * 1.3;
|
|
726
|
+
const startY = imageOffsetY + H / 2 + 4;
|
|
727
|
+
lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
|
|
728
|
+
ctx.restore();
|
|
729
|
+
} else {
|
|
730
|
+
drawLabel(
|
|
731
|
+
ctx,
|
|
732
|
+
label,
|
|
733
|
+
fontSize,
|
|
734
|
+
c.font,
|
|
735
|
+
textAlign,
|
|
736
|
+
textValign,
|
|
737
|
+
W,
|
|
738
|
+
H,
|
|
739
|
+
labelRotation,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
743
|
+
ctx.restore();
|
|
744
|
+
},
|
|
745
|
+
nodeDimensions: { width: W, height: totalH },
|
|
746
|
+
};
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
656
750
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
657
751
|
|
|
658
752
|
// Returns the rendered height from vis-network internals (used by node-panel
|
|
@@ -703,6 +797,7 @@ const RENDERER_MAP = {
|
|
|
703
797
|
"text-free": makeTextFreeRenderer,
|
|
704
798
|
actor: makeActorRenderer,
|
|
705
799
|
image: makeImageRenderer,
|
|
800
|
+
[CUSTOM_SHAPE_TYPE]: makeCustomShapeRenderer,
|
|
706
801
|
anchor: makeAnchorRenderer,
|
|
707
802
|
};
|
|
708
803
|
|
|
@@ -716,6 +811,7 @@ export const SHAPE_DEFAULTS = {
|
|
|
716
811
|
"post-it": [120, 100],
|
|
717
812
|
"text-free": [80, 30],
|
|
718
813
|
image: [160, 120],
|
|
814
|
+
[CUSTOM_SHAPE_TYPE]: [96, 96],
|
|
719
815
|
anchor: [8, 8],
|
|
720
816
|
};
|
|
721
817
|
|
|
@@ -126,6 +126,7 @@ export async function saveDiagram() {
|
|
|
126
126
|
.map((n) => ({
|
|
127
127
|
id: n.id, label: n.label,
|
|
128
128
|
shapeType: n.shapeType || 'box', colorKey: n.colorKey || 'c-gray',
|
|
129
|
+
customShapeId: n.customShapeId || null,
|
|
129
130
|
kind: n.kind || null, renderAs: n.renderAs || null,
|
|
130
131
|
description: n.description || null,
|
|
131
132
|
evidence: Array.isArray(n.evidence) ? n.evidence : null,
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// vis-network's native rendering unchanged.
|
|
8
8
|
|
|
9
9
|
import { st } from './state.js';
|
|
10
|
-
import { SHAPE_DEFAULTS } from './node-rendering.js';
|
|
10
|
+
import { customShapeImageOffsetY, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
11
|
+
import { CUSTOM_SHAPE_TYPE, getCustomShapeAnchors } from './custom-shapes.js';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Splits `text` into lines that each fit within `maxWidth` canvas units.
|
|
@@ -83,20 +84,52 @@ function nodeGeometry(nodeId) {
|
|
|
83
84
|
const W = n.nodeWidth || defaults[0];
|
|
84
85
|
// 'circle' is always square (H = W); 'ellipse' uses its own height.
|
|
85
86
|
const H = shapeType === 'circle' ? W : (n.nodeHeight || defaults[1]);
|
|
86
|
-
|
|
87
|
+
const imageOffsetY = shapeType === CUSTOM_SHAPE_TYPE ? customShapeImageOffsetY(n, n.label) : 0;
|
|
88
|
+
return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType, customShapeId: n.customShapeId || null, imageOffsetY };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getPortKeys(nodeId) {
|
|
92
|
+
const n = st.nodes && st.nodes.get(nodeId);
|
|
93
|
+
if (n && n.shapeType === CUSTOM_SHAPE_TYPE) {
|
|
94
|
+
return getCustomShapeAnchors(n.customShapeId).map((anchor) => anchor.id);
|
|
95
|
+
}
|
|
96
|
+
return PORT_KEYS;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function customPortOffset(customShapeId, portKey) {
|
|
100
|
+
const anchor = getCustomShapeAnchors(customShapeId).find((item) => item.id === portKey);
|
|
101
|
+
if (!anchor) return null;
|
|
102
|
+
return [(anchor.x - 0.5) * 2, (anchor.y - 0.5) * 2];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalForPort(shapeType, customShapeId, portKey) {
|
|
106
|
+
if (shapeType === CUSTOM_SHAPE_TYPE) {
|
|
107
|
+
const offset = customPortOffset(customShapeId, portKey) || [0, -1];
|
|
108
|
+
const len = Math.hypot(offset[0], offset[1]) || 1;
|
|
109
|
+
return [offset[0] / len, offset[1] / len];
|
|
110
|
+
}
|
|
111
|
+
return PORT_NORMALS[portKey] || [0, -1];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function controlNormalForEdge(nodeId, portKey) {
|
|
115
|
+
const n = st.nodes && st.nodes.get(nodeId);
|
|
116
|
+
return normalForPort(n && n.shapeType, n && n.customShapeId, portKey);
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
/** Returns the world-space {x, y} of a port on a node. */
|
|
90
120
|
export function getPortPosition(nodeId, portKey) {
|
|
91
121
|
const geo = nodeGeometry(nodeId);
|
|
92
122
|
if (!geo) return null;
|
|
93
|
-
const { cx, cy, W, H, rotation, shapeType } = geo;
|
|
94
|
-
const offsets = shapeType ===
|
|
123
|
+
const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetY } = geo;
|
|
124
|
+
const offsets = shapeType === CUSTOM_SHAPE_TYPE ? null
|
|
125
|
+
: shapeType === 'database' ? PORT_OFFSETS_DATABASE
|
|
95
126
|
: CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
|
|
96
127
|
: PORT_OFFSETS_RECT;
|
|
97
|
-
const [ox, oy] =
|
|
128
|
+
const [ox, oy] = shapeType === CUSTOM_SHAPE_TYPE
|
|
129
|
+
? customPortOffset(customShapeId, portKey) || [0, 0]
|
|
130
|
+
: offsets[portKey] || [0, 0];
|
|
98
131
|
let dx = ox * W / 2;
|
|
99
|
-
let dy = oy * H / 2;
|
|
132
|
+
let dy = oy * H / 2 + imageOffsetY;
|
|
100
133
|
if (rotation) {
|
|
101
134
|
const cos = Math.cos(rotation), sin = Math.sin(rotation);
|
|
102
135
|
[dx, dy] = [dx * cos - dy * sin, dx * sin + dy * cos];
|
|
@@ -107,7 +140,7 @@ export function getPortPosition(nodeId, portKey) {
|
|
|
107
140
|
/** Returns the port key whose position is closest to `canvasPos` (world coords). */
|
|
108
141
|
export function getNearestPort(nodeId, canvasPos) {
|
|
109
142
|
let minD2 = Infinity, nearest = 'N';
|
|
110
|
-
for (const key of
|
|
143
|
+
for (const key of getPortKeys(nodeId)) {
|
|
111
144
|
const p = getPortPosition(nodeId, key);
|
|
112
145
|
if (!p) continue;
|
|
113
146
|
const d2 = (canvasPos.x - p.x) ** 2 + (canvasPos.y - p.y) ** 2;
|
|
@@ -119,12 +152,12 @@ export function getNearestPort(nodeId, canvasPos) {
|
|
|
119
152
|
// ── Port dot visualisation ────────────────────────────────────────────────────
|
|
120
153
|
|
|
121
154
|
/**
|
|
122
|
-
* Draws
|
|
155
|
+
* Draws port hint dots for `nodeId` in the vis-network afterDrawing context.
|
|
123
156
|
* `highlightedPort` (if any) is rendered larger and fully orange.
|
|
124
157
|
*/
|
|
125
158
|
export function drawPortDots(ctx, nodeId, highlightedPort) {
|
|
126
159
|
if (st.exportingPng) return;
|
|
127
|
-
for (const key of
|
|
160
|
+
for (const key of getPortKeys(nodeId)) {
|
|
128
161
|
const p = getPortPosition(nodeId, key);
|
|
129
162
|
if (!p) continue;
|
|
130
163
|
const hl = key === highlightedPort;
|
|
@@ -219,8 +252,8 @@ export function drawPortEdge(ctx, edgeData) {
|
|
|
219
252
|
if (useBezier) {
|
|
220
253
|
const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
|
|
221
254
|
const tension = Math.max(60, dist * 0.4);
|
|
222
|
-
const fn =
|
|
223
|
-
const tn =
|
|
255
|
+
const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
|
|
256
|
+
const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
|
|
224
257
|
cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
|
|
225
258
|
cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
|
|
226
259
|
}
|
|
@@ -358,8 +391,8 @@ export function distanceToPortEdge(edgeData, p) {
|
|
|
358
391
|
if (!st.edgesStraight && edgeData.fromPort && !fromIsAnch && edgeData.toPort && !toIsAnch) {
|
|
359
392
|
const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
|
|
360
393
|
const tension = Math.max(60, dist * 0.4);
|
|
361
|
-
const fn =
|
|
362
|
-
const tn =
|
|
394
|
+
const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
|
|
395
|
+
const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
|
|
363
396
|
const cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
|
|
364
397
|
const cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
|
|
365
398
|
return _distToBezier(p, fromPos, cp1, cp2, toPos);
|
|
@@ -28,6 +28,8 @@ export const st = {
|
|
|
28
28
|
edgesStraight: false, // when true, all edges use smooth: disabled (straight lines)
|
|
29
29
|
resizeSymmetric: false, // when true, center is fixed during resize; when false, opposite corner is fixed
|
|
30
30
|
nodeColorOverrides: {}, // colorKey → {bg, border, font, hbg, hborder} — set from config at boot
|
|
31
|
+
customShapeLibraries: [], // loaded from /api/shape-libraries
|
|
32
|
+
customShapeDefs: new Map(), // shape id → custom shape definition
|
|
31
33
|
edgeLabelCanvasPos: {}, // edgeId → {x, y} canvas coords of last drawn label, used by label editor
|
|
32
34
|
edgeLabelBBox: {}, // edgeId → {cx, cy, w, h, rotation} canvas world coords, used by label resize
|
|
33
35
|
freeArrowFirstPoint: null, // addEdge two-click flow: {x, y} canvas coords of first click, or null
|
|
@@ -286,6 +286,57 @@
|
|
|
286
286
|
background: #082f49;
|
|
287
287
|
color: #e0f2fe;
|
|
288
288
|
}
|
|
289
|
+
#customShapeBar {
|
|
290
|
+
position: absolute;
|
|
291
|
+
left: 0.75rem;
|
|
292
|
+
bottom: 0.75rem;
|
|
293
|
+
z-index: 9;
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
max-width: min(42rem, calc(100% - 1.5rem));
|
|
297
|
+
padding: 0.35rem;
|
|
298
|
+
gap: 0.25rem;
|
|
299
|
+
border: 1px solid #e5e7eb;
|
|
300
|
+
border-radius: 0.5rem;
|
|
301
|
+
background: rgba(255, 255, 255, 0.96);
|
|
302
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
|
|
303
|
+
}
|
|
304
|
+
.dark #customShapeBar {
|
|
305
|
+
border-color: #374151;
|
|
306
|
+
background: rgba(31, 41, 55, 0.96);
|
|
307
|
+
}
|
|
308
|
+
#customShapeBar.hidden {
|
|
309
|
+
display: none;
|
|
310
|
+
}
|
|
311
|
+
#customShapeBarBody {
|
|
312
|
+
display: flex;
|
|
313
|
+
gap: 0.25rem;
|
|
314
|
+
overflow-x: auto;
|
|
315
|
+
scrollbar-width: thin;
|
|
316
|
+
}
|
|
317
|
+
.custom-shape-btn {
|
|
318
|
+
display: flex;
|
|
319
|
+
align-items: center;
|
|
320
|
+
justify-content: center;
|
|
321
|
+
width: 2.25rem;
|
|
322
|
+
height: 2.25rem;
|
|
323
|
+
border-radius: 0.375rem;
|
|
324
|
+
border: 1px solid transparent;
|
|
325
|
+
background: transparent;
|
|
326
|
+
flex: 0 0 auto;
|
|
327
|
+
}
|
|
328
|
+
.custom-shape-btn:hover {
|
|
329
|
+
background: #f3f4f6;
|
|
330
|
+
}
|
|
331
|
+
.dark .custom-shape-btn:hover {
|
|
332
|
+
background: #111827;
|
|
333
|
+
}
|
|
334
|
+
.custom-shape-btn img {
|
|
335
|
+
max-width: 1.7rem;
|
|
336
|
+
max-height: 1.7rem;
|
|
337
|
+
object-fit: contain;
|
|
338
|
+
pointer-events: none;
|
|
339
|
+
}
|
|
289
340
|
</style>
|
|
290
341
|
</head>
|
|
291
342
|
<body
|
|
@@ -445,6 +496,13 @@
|
|
|
445
496
|
<path d="M1 9.5 L4 6.5 L6.5 9 L9 7 L13 10.5"/>
|
|
446
497
|
</svg>
|
|
447
498
|
</button>
|
|
499
|
+
<a
|
|
500
|
+
href="/shape-editor"
|
|
501
|
+
class="tool-btn"
|
|
502
|
+
data-i18n-title="diagram.toolbar.shape_editor"
|
|
503
|
+
>
|
|
504
|
+
✦
|
|
505
|
+
</a>
|
|
448
506
|
<div class="w-px h-6 bg-gray-200 dark:bg-gray-700 mx-0.5"></div>
|
|
449
507
|
|
|
450
508
|
<button
|
|
@@ -641,6 +699,10 @@
|
|
|
641
699
|
<div class="relative flex-1 overflow-hidden bg-gray-50 dark:bg-gray-950">
|
|
642
700
|
<div id="vis-canvas" class="w-full h-full"></div>
|
|
643
701
|
|
|
702
|
+
<div id="customShapeBar" class="hidden">
|
|
703
|
+
<div id="customShapeBarBody"></div>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
644
706
|
<!-- Debug overlay layer -->
|
|
645
707
|
<div id="debugLayer"></div>
|
|
646
708
|
|
|
@@ -1308,6 +1370,7 @@
|
|
|
1308
1370
|
import { NODE_COLORS, NODE_L_RATIOS, DEFAULT_NODE_PALETTE, DEFAULT_EDGE_PALETTE, deriveNodeColors } from '/diagram/constants.js';
|
|
1309
1371
|
import { st } from '/diagram/state.js';
|
|
1310
1372
|
import { loadDiagramList } from '/diagram/persistence.js';
|
|
1373
|
+
import { loadCustomShapeLibraries, renderCustomShapeBar } from '/diagram/custom-shapes.js';
|
|
1311
1374
|
(async () => {
|
|
1312
1375
|
// diagramNodePalette: array of 15 bg hex strings (positional, matching DEFAULT_NODE_PALETTE)
|
|
1313
1376
|
// diagramEdgePalette: array of hex strings
|
|
@@ -1355,6 +1418,8 @@
|
|
|
1355
1418
|
btn.title = hex;
|
|
1356
1419
|
ec.appendChild(btn);
|
|
1357
1420
|
});
|
|
1421
|
+
await loadCustomShapeLibraries();
|
|
1422
|
+
renderCustomShapeBar();
|
|
1358
1423
|
window.applyI18n();
|
|
1359
1424
|
loadDiagramList();
|
|
1360
1425
|
})();
|
|
@@ -409,6 +409,7 @@
|
|
|
409
409
|
"diagram.toolbar.postit": "Post-it (P)",
|
|
410
410
|
"diagram.toolbar.text_free": "Free text (T)",
|
|
411
411
|
"diagram.toolbar.image": "Image — double-click on canvas to select a file, or paste (⌘V) from clipboard",
|
|
412
|
+
"diagram.toolbar.shape_editor": "Custom shape editor",
|
|
412
413
|
"diagram.toolbar.arrow": "Arrow (F)",
|
|
413
414
|
"diagram.toolbar.delete": "Delete (Del)",
|
|
414
415
|
"diagram.toolbar.align_guides": "Alignment guides — highlights same-type shape alignment",
|