living-documentation 7.42.0 → 7.44.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 +104 -0
- package/dist/src/frontend/diagram/main.js +10 -1
- package/dist/src/frontend/diagram/network.js +1042 -531
- package/dist/src/frontend/diagram/node-panel.js +47 -0
- package/dist/src/frontend/diagram/node-rendering.js +190 -0
- package/dist/src/frontend/diagram/persistence.js +2 -0
- package/dist/src/frontend/diagram/ports.js +49 -14
- package/dist/src/frontend/diagram/selection-overlay.js +64 -13
- package/dist/src/frontend/diagram/state.js +2 -0
- package/dist/src/frontend/diagram.html +99 -0
- package/dist/src/frontend/i18n/en.json +15 -1
- package/dist/src/frontend/i18n/fr.json +15 -1
- package/dist/src/frontend/shape-editor.html +685 -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 +116 -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
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
import { st, markDirty } from './state.js';
|
|
5
5
|
import { SHAPE_DEFAULTS } from './node-rendering.js';
|
|
6
|
+
import { CUSTOM_SHAPE_TYPE, getCustomShapeLabelPlacement } from './custom-shapes.js';
|
|
6
7
|
import { pushSnapshot } from './history.js';
|
|
7
8
|
import { t } from './t.js';
|
|
8
9
|
|
|
10
|
+
const CUSTOM_LABEL_PLACEMENTS = ['below', 'above', 'right', 'left', 'center'];
|
|
11
|
+
|
|
9
12
|
// ── Last-used style persistence (per shape type) ──────────────────────────────
|
|
10
13
|
// Saves colorKey/fontSize/textAlign/textValign per shapeType to localStorage so
|
|
11
14
|
// the next shape of that type is created with the same style.
|
|
@@ -90,6 +93,36 @@ function syncNodeFontSizeValue() {
|
|
|
90
93
|
el.textContent = sizes.every((size) => size === first) ? String(first) : '–';
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
function selectedCustomShapeIds() {
|
|
97
|
+
return (st.selectedNodeIds || []).filter((id) => {
|
|
98
|
+
const n = st.nodes && st.nodes.get(id);
|
|
99
|
+
return n && n.shapeType === CUSTOM_SHAPE_TYPE;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function effectiveCustomShapeLabelPlacement(node) {
|
|
104
|
+
return CUSTOM_LABEL_PLACEMENTS.includes(node && node.labelPlacement)
|
|
105
|
+
? node.labelPlacement
|
|
106
|
+
: getCustomShapeLabelPlacement(node && node.customShapeId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function syncCustomShapeLabelPlacementControls() {
|
|
110
|
+
const controls = document.getElementById('customShapeLabelPlacementControls');
|
|
111
|
+
if (!controls) return;
|
|
112
|
+
const customIds = selectedCustomShapeIds();
|
|
113
|
+
controls.classList.toggle('hidden', customIds.length === 0);
|
|
114
|
+
if (!customIds.length) return;
|
|
115
|
+
|
|
116
|
+
const placements = customIds
|
|
117
|
+
.map((id) => effectiveCustomShapeLabelPlacement(st.nodes.get(id)))
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
const first = placements[0];
|
|
120
|
+
const shared = placements.length && placements.every((placement) => placement === first) ? first : null;
|
|
121
|
+
controls.querySelectorAll('[data-label-placement]').forEach((btn) => {
|
|
122
|
+
btn.classList.toggle('tool-active', !!shared && btn.dataset.labelPlacement === shared);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
function setEdgeLocked(edge, locked) {
|
|
94
127
|
if (!edge) return;
|
|
95
128
|
const fromN = st.nodes && st.nodes.get(edge.from);
|
|
@@ -111,6 +144,7 @@ export function showNodePanel() {
|
|
|
111
144
|
document.getElementById('nodePanelControls').classList.remove('hidden');
|
|
112
145
|
syncNodeLockButton();
|
|
113
146
|
syncNodeFontSizeValue();
|
|
147
|
+
syncCustomShapeLabelPlacementControls();
|
|
114
148
|
// Sync the opacity slider with the first selected node's current value so the
|
|
115
149
|
// slider reflects the live state rather than whatever position it was left at.
|
|
116
150
|
const slider = document.getElementById('nodeBgOpacity');
|
|
@@ -219,6 +253,19 @@ export function setTextValign(valign) {
|
|
|
219
253
|
markDirty();
|
|
220
254
|
}
|
|
221
255
|
|
|
256
|
+
export function setCustomShapeLabelPlacement(placement) {
|
|
257
|
+
if (!CUSTOM_LABEL_PLACEMENTS.includes(placement)) return;
|
|
258
|
+
const ids = selectedCustomShapeIds();
|
|
259
|
+
if (!ids.length) return;
|
|
260
|
+
pushSnapshot();
|
|
261
|
+
ids.forEach((id) => {
|
|
262
|
+
st.nodes.update({ id, labelPlacement: placement });
|
|
263
|
+
});
|
|
264
|
+
syncCustomShapeLabelPlacementControls();
|
|
265
|
+
forceRedraw();
|
|
266
|
+
markDirty();
|
|
267
|
+
}
|
|
268
|
+
|
|
222
269
|
// ── Stamp (format painter) ────────────────────────────────────────────────────
|
|
223
270
|
// Uses a transparent DOM overlay (#stampOverlay) that intercepts canvas clicks
|
|
224
271
|
// during stamp mode. This bypasses vis-network's event system entirely, avoiding
|
|
@@ -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, CUSTOM_SHAPE_DEFAULT_SIZE } 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,93 @@ 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
|
+
function normalizeCustomLabelPlacement(value) {
|
|
191
|
+
return ["center", "below", "above", "right", "left"].includes(value) ? value : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isExternalCustomLabelPlacement(placement) {
|
|
195
|
+
return ["above", "below", "right", "left"].includes(placement);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function measureTextWidth(line, fontSize, ctx) {
|
|
199
|
+
const font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
200
|
+
if (ctx) {
|
|
201
|
+
const prevFont = ctx.font;
|
|
202
|
+
ctx.font = font;
|
|
203
|
+
const width = ctx.measureText(line).width;
|
|
204
|
+
ctx.font = prevFont;
|
|
205
|
+
return width;
|
|
206
|
+
}
|
|
207
|
+
if (typeof document === "undefined") return String(line).length * fontSize * 0.58;
|
|
208
|
+
if (!measureTextWidth._ctx) {
|
|
209
|
+
measureTextWidth._ctx = document.createElement("canvas").getContext("2d");
|
|
210
|
+
}
|
|
211
|
+
measureTextWidth._ctx.font = font;
|
|
212
|
+
return measureTextWidth._ctx.measureText(line).width;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function customShapeLayout(node, label, ctx) {
|
|
216
|
+
const def = getCustomShapeDefinition(node && node.customShapeId);
|
|
217
|
+
const W =
|
|
218
|
+
(node && node.nodeWidth) || (def && def.width) || CUSTOM_SHAPE_DEFAULT_SIZE;
|
|
219
|
+
const H =
|
|
220
|
+
(node && node.nodeHeight) ||
|
|
221
|
+
(def && def.height) ||
|
|
222
|
+
CUSTOM_SHAPE_DEFAULT_SIZE;
|
|
223
|
+
const placement = normalizeCustomLabelPlacement(node && node.labelPlacement)
|
|
224
|
+
|| normalizeCustomLabelPlacement(def && def.labelPlacement)
|
|
225
|
+
|| "below";
|
|
226
|
+
const fontSize = (node && node.fontSize) || 13;
|
|
227
|
+
const lines = textLines(label);
|
|
228
|
+
const lineH = fontSize * 1.3;
|
|
229
|
+
if (!lines.length || !isExternalCustomLabelPlacement(placement)) {
|
|
230
|
+
return {
|
|
231
|
+
placement,
|
|
232
|
+
lines,
|
|
233
|
+
lineH,
|
|
234
|
+
imageOffsetX: 0,
|
|
235
|
+
imageOffsetY: 0,
|
|
236
|
+
labelBlockW: 0,
|
|
237
|
+
labelBlockH: 0,
|
|
238
|
+
totalW: W,
|
|
239
|
+
totalH: H,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const maxTextW = Math.max(...lines.map((line) => measureTextWidth(line, fontSize, ctx)));
|
|
244
|
+
const labelBlockW = maxTextW + 16;
|
|
245
|
+
const labelBlockH = lines.length * lineH + 8;
|
|
246
|
+
const horizontal = placement === "left" || placement === "right";
|
|
247
|
+
const totalW = horizontal ? W + labelBlockW : Math.max(W, labelBlockW);
|
|
248
|
+
const totalH = horizontal ? Math.max(H, labelBlockH) : H + labelBlockH;
|
|
249
|
+
const imageOffsetX = placement === "right" ? -labelBlockW / 2
|
|
250
|
+
: placement === "left" ? labelBlockW / 2
|
|
251
|
+
: 0;
|
|
252
|
+
const imageOffsetY = placement === "below" ? -labelBlockH / 2
|
|
253
|
+
: placement === "above" ? labelBlockH / 2
|
|
254
|
+
: 0;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
placement,
|
|
258
|
+
lines,
|
|
259
|
+
lineH,
|
|
260
|
+
imageOffsetX,
|
|
261
|
+
imageOffsetY,
|
|
262
|
+
labelBlockW,
|
|
263
|
+
labelBlockH,
|
|
264
|
+
totalW,
|
|
265
|
+
totalH,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function customShapeImageOffsetY(node, label) {
|
|
270
|
+
return customShapeLayout(node, label).imageOffsetY;
|
|
271
|
+
}
|
|
272
|
+
|
|
185
273
|
// ── Shape renderers ───────────────────────────────────────────────────────────
|
|
186
274
|
|
|
187
275
|
export function makeBoxRenderer(colorKey) {
|
|
@@ -653,6 +741,106 @@ export function makeImageRenderer(colorKey) {
|
|
|
653
741
|
};
|
|
654
742
|
}
|
|
655
743
|
|
|
744
|
+
export function makeCustomShapeRenderer(colorKey) {
|
|
745
|
+
return function ({ ctx, x, y, id, state: visState, label }) {
|
|
746
|
+
const n = st.nodes && st.nodes.get(id);
|
|
747
|
+
const def = getCustomShapeDefinition(n && n.customShapeId);
|
|
748
|
+
const defaultW = (def && def.width) || CUSTOM_SHAPE_DEFAULT_SIZE;
|
|
749
|
+
const defaultH = (def && def.height) || CUSTOM_SHAPE_DEFAULT_SIZE;
|
|
750
|
+
const {
|
|
751
|
+
W,
|
|
752
|
+
H,
|
|
753
|
+
rotation,
|
|
754
|
+
labelRotation,
|
|
755
|
+
textAlign,
|
|
756
|
+
textValign,
|
|
757
|
+
fontSize,
|
|
758
|
+
c,
|
|
759
|
+
} = nodeData(id, defaultW, defaultH, colorKey || "c-gray");
|
|
760
|
+
const img = getCachedImage(def && def.imageSrc, () => st.network && st.network.redraw());
|
|
761
|
+
const layout = customShapeLayout({ ...(n || {}), nodeWidth: W, nodeHeight: H, fontSize }, label, ctx);
|
|
762
|
+
const {
|
|
763
|
+
placement,
|
|
764
|
+
lines,
|
|
765
|
+
lineH,
|
|
766
|
+
imageOffsetX,
|
|
767
|
+
imageOffsetY,
|
|
768
|
+
totalW,
|
|
769
|
+
totalH,
|
|
770
|
+
} = layout;
|
|
771
|
+
const externalLabel = isExternalCustomLabelPlacement(placement) && lines.length;
|
|
772
|
+
return {
|
|
773
|
+
drawNode() {
|
|
774
|
+
ctx.save();
|
|
775
|
+
ctx.translate(x, y);
|
|
776
|
+
ctx.rotate(rotation);
|
|
777
|
+
|
|
778
|
+
if (img) {
|
|
779
|
+
ctx.drawImage(img, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H);
|
|
780
|
+
} else {
|
|
781
|
+
ctx.fillStyle = visState.selected ? c.hbg : c.bg;
|
|
782
|
+
ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
|
|
783
|
+
ctx.lineWidth = 1.5;
|
|
784
|
+
roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
|
|
785
|
+
ctx.fill();
|
|
786
|
+
ctx.stroke();
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (visState.selected || visState.hover) {
|
|
790
|
+
ctx.strokeStyle = visState.selected ? "#f97316" : c.border;
|
|
791
|
+
ctx.lineWidth = visState.selected ? 2 : 1;
|
|
792
|
+
ctx.setLineDash([4, 3]);
|
|
793
|
+
roundRect(ctx, imageOffsetX - W / 2, imageOffsetY - H / 2, W, H, 4);
|
|
794
|
+
ctx.stroke();
|
|
795
|
+
ctx.setLineDash([]);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (externalLabel) {
|
|
799
|
+
ctx.save();
|
|
800
|
+
if (labelRotation) ctx.rotate(labelRotation);
|
|
801
|
+
ctx.font = `${fontSize}px system-ui,-apple-system,sans-serif`;
|
|
802
|
+
ctx.fillStyle = c.font;
|
|
803
|
+
if (placement === "right" || placement === "left") {
|
|
804
|
+
const textX = placement === "right"
|
|
805
|
+
? imageOffsetX + W / 2 + 8
|
|
806
|
+
: imageOffsetX - W / 2 - 8;
|
|
807
|
+
const startY = -((lines.length - 1) * lineH) / 2;
|
|
808
|
+
ctx.textAlign = placement === "right" ? "left" : "right";
|
|
809
|
+
ctx.textBaseline = "middle";
|
|
810
|
+
lines.forEach((line, i) => ctx.fillText(line, textX, startY + i * lineH));
|
|
811
|
+
} else {
|
|
812
|
+
ctx.textAlign = "center";
|
|
813
|
+
ctx.textBaseline = "top";
|
|
814
|
+
const startY = placement === "below"
|
|
815
|
+
? imageOffsetY + H / 2 + 4
|
|
816
|
+
: -totalH / 2 + 4;
|
|
817
|
+
lines.forEach((line, i) => ctx.fillText(line, 0, startY + i * lineH));
|
|
818
|
+
}
|
|
819
|
+
ctx.restore();
|
|
820
|
+
} else {
|
|
821
|
+
drawLabel(
|
|
822
|
+
ctx,
|
|
823
|
+
label,
|
|
824
|
+
fontSize,
|
|
825
|
+
c.font,
|
|
826
|
+
textAlign,
|
|
827
|
+
textValign,
|
|
828
|
+
W,
|
|
829
|
+
H,
|
|
830
|
+
labelRotation,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
ctx.save();
|
|
834
|
+
ctx.translate(imageOffsetX, imageOffsetY);
|
|
835
|
+
drawLinkIndicator(ctx, id, W, H);
|
|
836
|
+
ctx.restore();
|
|
837
|
+
ctx.restore();
|
|
838
|
+
},
|
|
839
|
+
nodeDimensions: { width: totalW, height: totalH },
|
|
840
|
+
};
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
656
844
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
657
845
|
|
|
658
846
|
// Returns the rendered height from vis-network internals (used by node-panel
|
|
@@ -703,6 +891,7 @@ const RENDERER_MAP = {
|
|
|
703
891
|
"text-free": makeTextFreeRenderer,
|
|
704
892
|
actor: makeActorRenderer,
|
|
705
893
|
image: makeImageRenderer,
|
|
894
|
+
[CUSTOM_SHAPE_TYPE]: makeCustomShapeRenderer,
|
|
706
895
|
anchor: makeAnchorRenderer,
|
|
707
896
|
};
|
|
708
897
|
|
|
@@ -716,6 +905,7 @@ export const SHAPE_DEFAULTS = {
|
|
|
716
905
|
"post-it": [120, 100],
|
|
717
906
|
"text-free": [80, 30],
|
|
718
907
|
image: [160, 120],
|
|
908
|
+
[CUSTOM_SHAPE_TYPE]: [CUSTOM_SHAPE_DEFAULT_SIZE, CUSTOM_SHAPE_DEFAULT_SIZE],
|
|
719
909
|
anchor: [8, 8],
|
|
720
910
|
};
|
|
721
911
|
|
|
@@ -126,6 +126,8 @@ 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,
|
|
130
|
+
labelPlacement: n.labelPlacement || null,
|
|
129
131
|
kind: n.kind || null, renderAs: n.renderAs || null,
|
|
130
132
|
description: n.description || null,
|
|
131
133
|
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 { customShapeLayout, 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,54 @@ 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 customLayout = shapeType === CUSTOM_SHAPE_TYPE ? customShapeLayout(n, n.label) : null;
|
|
88
|
+
const imageOffsetX = customLayout ? customLayout.imageOffsetX : 0;
|
|
89
|
+
const imageOffsetY = customLayout ? customLayout.imageOffsetY : 0;
|
|
90
|
+
return { cx: pos.x, cy: pos.y, W, H, rotation: n.rotation || 0, shapeType, customShapeId: n.customShapeId || null, imageOffsetX, imageOffsetY };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getPortKeys(nodeId) {
|
|
94
|
+
const n = st.nodes && st.nodes.get(nodeId);
|
|
95
|
+
if (n && n.shapeType === CUSTOM_SHAPE_TYPE) {
|
|
96
|
+
return getCustomShapeAnchors(n.customShapeId).map((anchor) => anchor.id);
|
|
97
|
+
}
|
|
98
|
+
return PORT_KEYS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function customPortOffset(customShapeId, portKey) {
|
|
102
|
+
const anchor = getCustomShapeAnchors(customShapeId).find((item) => item.id === portKey);
|
|
103
|
+
if (!anchor) return null;
|
|
104
|
+
return [(anchor.x - 0.5) * 2, (anchor.y - 0.5) * 2];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalForPort(shapeType, customShapeId, portKey) {
|
|
108
|
+
if (shapeType === CUSTOM_SHAPE_TYPE) {
|
|
109
|
+
const offset = customPortOffset(customShapeId, portKey) || [0, -1];
|
|
110
|
+
const len = Math.hypot(offset[0], offset[1]) || 1;
|
|
111
|
+
return [offset[0] / len, offset[1] / len];
|
|
112
|
+
}
|
|
113
|
+
return PORT_NORMALS[portKey] || [0, -1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function controlNormalForEdge(nodeId, portKey) {
|
|
117
|
+
const n = st.nodes && st.nodes.get(nodeId);
|
|
118
|
+
return normalForPort(n && n.shapeType, n && n.customShapeId, portKey);
|
|
87
119
|
}
|
|
88
120
|
|
|
89
121
|
/** Returns the world-space {x, y} of a port on a node. */
|
|
90
122
|
export function getPortPosition(nodeId, portKey) {
|
|
91
123
|
const geo = nodeGeometry(nodeId);
|
|
92
124
|
if (!geo) return null;
|
|
93
|
-
const { cx, cy, W, H, rotation, shapeType } = geo;
|
|
94
|
-
const offsets = shapeType ===
|
|
125
|
+
const { cx, cy, W, H, rotation, shapeType, customShapeId, imageOffsetX, imageOffsetY } = geo;
|
|
126
|
+
const offsets = shapeType === CUSTOM_SHAPE_TYPE ? null
|
|
127
|
+
: shapeType === 'database' ? PORT_OFFSETS_DATABASE
|
|
95
128
|
: CIRCULAR_SHAPES.has(shapeType) ? PORT_OFFSETS_CIRC
|
|
96
129
|
: PORT_OFFSETS_RECT;
|
|
97
|
-
const [ox, oy] =
|
|
98
|
-
|
|
99
|
-
|
|
130
|
+
const [ox, oy] = shapeType === CUSTOM_SHAPE_TYPE
|
|
131
|
+
? customPortOffset(customShapeId, portKey) || [0, 0]
|
|
132
|
+
: offsets[portKey] || [0, 0];
|
|
133
|
+
let dx = ox * W / 2 + imageOffsetX;
|
|
134
|
+
let dy = oy * H / 2 + imageOffsetY;
|
|
100
135
|
if (rotation) {
|
|
101
136
|
const cos = Math.cos(rotation), sin = Math.sin(rotation);
|
|
102
137
|
[dx, dy] = [dx * cos - dy * sin, dx * sin + dy * cos];
|
|
@@ -107,7 +142,7 @@ export function getPortPosition(nodeId, portKey) {
|
|
|
107
142
|
/** Returns the port key whose position is closest to `canvasPos` (world coords). */
|
|
108
143
|
export function getNearestPort(nodeId, canvasPos) {
|
|
109
144
|
let minD2 = Infinity, nearest = 'N';
|
|
110
|
-
for (const key of
|
|
145
|
+
for (const key of getPortKeys(nodeId)) {
|
|
111
146
|
const p = getPortPosition(nodeId, key);
|
|
112
147
|
if (!p) continue;
|
|
113
148
|
const d2 = (canvasPos.x - p.x) ** 2 + (canvasPos.y - p.y) ** 2;
|
|
@@ -119,12 +154,12 @@ export function getNearestPort(nodeId, canvasPos) {
|
|
|
119
154
|
// ── Port dot visualisation ────────────────────────────────────────────────────
|
|
120
155
|
|
|
121
156
|
/**
|
|
122
|
-
* Draws
|
|
157
|
+
* Draws port hint dots for `nodeId` in the vis-network afterDrawing context.
|
|
123
158
|
* `highlightedPort` (if any) is rendered larger and fully orange.
|
|
124
159
|
*/
|
|
125
160
|
export function drawPortDots(ctx, nodeId, highlightedPort) {
|
|
126
161
|
if (st.exportingPng) return;
|
|
127
|
-
for (const key of
|
|
162
|
+
for (const key of getPortKeys(nodeId)) {
|
|
128
163
|
const p = getPortPosition(nodeId, key);
|
|
129
164
|
if (!p) continue;
|
|
130
165
|
const hl = key === highlightedPort;
|
|
@@ -219,8 +254,8 @@ export function drawPortEdge(ctx, edgeData) {
|
|
|
219
254
|
if (useBezier) {
|
|
220
255
|
const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
|
|
221
256
|
const tension = Math.max(60, dist * 0.4);
|
|
222
|
-
const fn =
|
|
223
|
-
const tn =
|
|
257
|
+
const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
|
|
258
|
+
const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
|
|
224
259
|
cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
|
|
225
260
|
cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
|
|
226
261
|
}
|
|
@@ -358,8 +393,8 @@ export function distanceToPortEdge(edgeData, p) {
|
|
|
358
393
|
if (!st.edgesStraight && edgeData.fromPort && !fromIsAnch && edgeData.toPort && !toIsAnch) {
|
|
359
394
|
const dist = Math.hypot(toPos.x - fromPos.x, toPos.y - fromPos.y);
|
|
360
395
|
const tension = Math.max(60, dist * 0.4);
|
|
361
|
-
const fn =
|
|
362
|
-
const tn =
|
|
396
|
+
const fn = controlNormalForEdge(edgeData.from, edgeData.fromPort);
|
|
397
|
+
const tn = controlNormalForEdge(edgeData.to, edgeData.toPort);
|
|
363
398
|
const cp1 = { x: fromPos.x + fn[0] * tension, y: fromPos.y + fn[1] * tension };
|
|
364
399
|
const cp2 = { x: toPos.x + tn[0] * tension, y: toPos.y + tn[1] * tension };
|
|
365
400
|
return _distToBezier(p, fromPos, cp1, cp2, toPos);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Dashed selection box, corner resize handles, and top-centre rotation handle.
|
|
3
3
|
|
|
4
4
|
import { st, markDirty } from './state.js';
|
|
5
|
-
import { visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
5
|
+
import { customShapeLayout, visNodeProps, SHAPE_DEFAULTS } from './node-rendering.js';
|
|
6
|
+
import { CUSTOM_SHAPE_TYPE } from './custom-shapes.js';
|
|
6
7
|
import { t } from './t.js';
|
|
7
8
|
import { pushSnapshot } from './history.js';
|
|
8
9
|
import { snapToGrid } from './grid.js';
|
|
@@ -31,12 +32,71 @@ function nodeBounds(id) {
|
|
|
31
32
|
return { minX: cx - hw, minY: cy - hh - headExtra, maxX: cx + hw, maxY: cy + hh };
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
function customShapeImageFrame(id) {
|
|
36
|
+
const n = st.nodes.get(id);
|
|
37
|
+
const bodyNode = st.network.body.nodes[id];
|
|
38
|
+
if (!n || !bodyNode || n.shapeType !== CUSTOM_SHAPE_TYPE) return null;
|
|
39
|
+
const defaults = SHAPE_DEFAULTS[CUSTOM_SHAPE_TYPE] || [96, 96];
|
|
40
|
+
const W = n.nodeWidth || defaults[0];
|
|
41
|
+
const H = n.nodeHeight || defaults[1];
|
|
42
|
+
const rotation = n.rotation || 0;
|
|
43
|
+
const layout = customShapeLayout(n, n.label);
|
|
44
|
+
const cos = Math.cos(rotation);
|
|
45
|
+
const sin = Math.sin(rotation);
|
|
46
|
+
const cx = bodyNode.x + layout.imageOffsetX * cos - layout.imageOffsetY * sin;
|
|
47
|
+
const cy = bodyNode.y + layout.imageOffsetX * sin + layout.imageOffsetY * cos;
|
|
48
|
+
return { cx, cy, W, H, rotation };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function syncOverlayHandles(width, height, pad) {
|
|
52
|
+
const rh = document.getElementById('rh-rotate');
|
|
53
|
+
rh.style.left = width / 2 + pad - 8 + 'px';
|
|
54
|
+
rh.style.top = '-28px';
|
|
55
|
+
|
|
56
|
+
const lrh = document.getElementById('rh-label-rotate');
|
|
57
|
+
lrh.style.left = width / 2 + pad - 8 - 24 + 'px';
|
|
58
|
+
lrh.style.top = '-28px';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function updateSingleCustomShapeOverlay(id) {
|
|
62
|
+
const frame = customShapeImageFrame(id);
|
|
63
|
+
if (!frame) return false;
|
|
64
|
+
|
|
65
|
+
const PAD = 10;
|
|
66
|
+
const scale = st.network.getScale();
|
|
67
|
+
const center = st.network.canvasToDOM({ x: frame.cx, y: frame.cy });
|
|
68
|
+
const width = frame.W * scale;
|
|
69
|
+
const height = frame.H * scale;
|
|
70
|
+
const ov = document.getElementById('selectionOverlay');
|
|
71
|
+
ov.style.display = 'block';
|
|
72
|
+
ov.style.left = center.x - width / 2 - PAD + 'px';
|
|
73
|
+
ov.style.top = center.y - height / 2 - PAD + 'px';
|
|
74
|
+
ov.style.width = width + PAD * 2 + 'px';
|
|
75
|
+
ov.style.height = height + PAD * 2 + 'px';
|
|
76
|
+
ov.style.transformOrigin = '50% 50%';
|
|
77
|
+
ov.style.transform = `rotate(${frame.rotation}rad)`;
|
|
78
|
+
syncOverlayHandles(width, height, PAD);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
34
82
|
// ── Overlay position ──────────────────────────────────────────────────────────
|
|
35
83
|
export function updateSelectionOverlay() {
|
|
36
84
|
if (!st.network || !st.selectedNodeIds.length) { hideSelectionOverlay(); return; }
|
|
37
85
|
// Anchor-only selections (free arrow drag) have no meaningful bounding box.
|
|
38
|
-
const
|
|
39
|
-
if (!
|
|
86
|
+
const nonAnchorIds = st.selectedNodeIds.filter((id) => { const n = st.nodes && st.nodes.get(id); return !(n && n.shapeType === 'anchor'); });
|
|
87
|
+
if (!nonAnchorIds.length) { hideSelectionOverlay(); return; }
|
|
88
|
+
|
|
89
|
+
const ov = document.getElementById('selectionOverlay');
|
|
90
|
+
ov.style.transform = '';
|
|
91
|
+
ov.style.transformOrigin = '';
|
|
92
|
+
|
|
93
|
+
if (nonAnchorIds.length === 1 && updateSingleCustomShapeOverlay(nonAnchorIds[0])) {
|
|
94
|
+
const anyLocked = st.selectedNodeIds.some((id) => { const n = st.nodes && st.nodes.get(id); return n && n.locked; });
|
|
95
|
+
['rh-tl','rh-tr','rh-bl','rh-br','rh-rotate','rh-label-rotate'].forEach((handleId) => {
|
|
96
|
+
document.getElementById(handleId).style.display = anyLocked ? 'none' : '';
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
40
100
|
|
|
41
101
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
42
102
|
for (const id of st.selectedNodeIds) {
|
|
@@ -52,7 +112,6 @@ export function updateSelectionOverlay() {
|
|
|
52
112
|
const PAD = 10;
|
|
53
113
|
const tl = st.network.canvasToDOM({ x: minX, y: minY });
|
|
54
114
|
const br = st.network.canvasToDOM({ x: maxX, y: maxY });
|
|
55
|
-
const ov = document.getElementById('selectionOverlay');
|
|
56
115
|
ov.style.display = 'block';
|
|
57
116
|
ov.style.left = tl.x - PAD + 'px';
|
|
58
117
|
ov.style.top = tl.y - PAD + 'px';
|
|
@@ -65,15 +124,7 @@ export function updateSelectionOverlay() {
|
|
|
65
124
|
document.getElementById(id).style.display = anyLocked ? 'none' : '';
|
|
66
125
|
});
|
|
67
126
|
|
|
68
|
-
|
|
69
|
-
const rh = document.getElementById('rh-rotate');
|
|
70
|
-
rh.style.left = (br.x - tl.x) / 2 + PAD - 8 + 'px';
|
|
71
|
-
rh.style.top = '-28px';
|
|
72
|
-
|
|
73
|
-
// Position label rotation handle: top-centre offset left by 24px to avoid overlap.
|
|
74
|
-
const lrh = document.getElementById('rh-label-rotate');
|
|
75
|
-
lrh.style.left = (br.x - tl.x) / 2 + PAD - 8 - 24 + 'px';
|
|
76
|
-
lrh.style.top = '-28px';
|
|
127
|
+
syncOverlayHandles(br.x - tl.x, br.y - tl.y, PAD);
|
|
77
128
|
}
|
|
78
129
|
|
|
79
130
|
export function hideSelectionOverlay() {
|
|
@@ -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
|