@world-engines/authoring-ui 0.1.0-alpha.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.
Files changed (89) hide show
  1. package/LICENSE +46 -0
  2. package/dist/DesktopAuthoringShell.d.ts +178 -0
  3. package/dist/DesktopAuthoringShell.js +235 -0
  4. package/dist/index.d.ts +23 -0
  5. package/dist/index.js +12 -0
  6. package/dist/ladybug/BrowserGraphCanvasAdapter.d.ts +12 -0
  7. package/dist/ladybug/BrowserGraphCanvasAdapter.js +8 -0
  8. package/dist/ladybug/GraphCanvas.d.ts +121 -0
  9. package/dist/ladybug/GraphCanvas.js +1439 -0
  10. package/dist/ladybug/GraphEdge.d.ts +26 -0
  11. package/dist/ladybug/GraphEdge.js +41 -0
  12. package/dist/ladybug/LadybugExplorer.d.ts +58 -0
  13. package/dist/ladybug/LadybugExplorer.js +83 -0
  14. package/dist/ladybug/LadybugSurfaces.d.ts +25 -0
  15. package/dist/ladybug/LadybugSurfaces.js +9 -0
  16. package/dist/ladybug/LadybugTable.d.ts +15 -0
  17. package/dist/ladybug/LadybugTable.js +26 -0
  18. package/dist/ladybug/entity-props.d.ts +98 -0
  19. package/dist/ladybug/entity-props.js +363 -0
  20. package/dist/ladybug/graph-edge-geometry.d.ts +19 -0
  21. package/dist/ladybug/graph-edge-geometry.js +78 -0
  22. package/dist/ladybug/graph-edge-mask.d.ts +21 -0
  23. package/dist/ladybug/graph-edge-mask.js +52 -0
  24. package/dist/ladybug/graph-groups.d.ts +47 -0
  25. package/dist/ladybug/graph-groups.js +245 -0
  26. package/dist/ladybug/graph-layout-contract.d.ts +34 -0
  27. package/dist/ladybug/graph-layout-contract.js +55 -0
  28. package/dist/ladybug/graph-layout-solver.d.ts +16 -0
  29. package/dist/ladybug/graph-layout-solver.js +96 -0
  30. package/dist/ladybug/graph-layout-wasm.d.ts +9 -0
  31. package/dist/ladybug/graph-layout-wasm.js +88 -0
  32. package/dist/ladybug/graph-layout-worker-protocol.d.ts +18 -0
  33. package/dist/ladybug/graph-layout-worker-protocol.js +1 -0
  34. package/dist/ladybug/graph-layout-worker.d.ts +1 -0
  35. package/dist/ladybug/graph-layout-worker.js +66 -0
  36. package/dist/ladybug/graph-layout.d.ts +2 -0
  37. package/dist/ladybug/graph-layout.js +1 -0
  38. package/dist/ladybug/graph-layout.wasm +0 -0
  39. package/dist/ladybug/graph-node-metrics.d.ts +33 -0
  40. package/dist/ladybug/graph-node-metrics.js +173 -0
  41. package/dist/ladybug/graph-physics.d.ts +49 -0
  42. package/dist/ladybug/graph-physics.js +266 -0
  43. package/dist/ladybug/graph-placement.d.ts +33 -0
  44. package/dist/ladybug/graph-placement.js +253 -0
  45. package/dist/ladybug/graph.css +530 -0
  46. package/dist/ladybug/graph.d.ts +18 -0
  47. package/dist/ladybug/graph.js +9 -0
  48. package/dist/ladybug/score-tags.d.ts +11 -0
  49. package/dist/ladybug/score-tags.js +1 -0
  50. package/dist/map/MapWorkspace.d.ts +14 -0
  51. package/dist/map/MapWorkspace.js +9 -0
  52. package/dist/score/ScoreRadar.d.ts +10 -0
  53. package/dist/score/ScoreRadar.js +66 -0
  54. package/dist/score/index.d.ts +3 -0
  55. package/dist/score/index.js +3 -0
  56. package/dist/score/radar-index.d.ts +3 -0
  57. package/dist/score/radar-index.js +2 -0
  58. package/dist/score/scenario-scoring-features.d.ts +90 -0
  59. package/dist/score/scenario-scoring-features.js +380 -0
  60. package/dist/score/scenario-structural-score.d.ts +76 -0
  61. package/dist/score/scenario-structural-score.js +211 -0
  62. package/dist/score/score-presentation.d.ts +51 -0
  63. package/dist/score/score-presentation.js +44 -0
  64. package/dist/score/score-radar.css +1 -0
  65. package/dist/sync/DownloadPanel.d.ts +42 -0
  66. package/dist/sync/DownloadPanel.js +22 -0
  67. package/dist/sync/SyncSidebar.d.ts +47 -0
  68. package/dist/sync/SyncSidebar.js +31 -0
  69. package/dist/sync/UploadPanel.d.ts +64 -0
  70. package/dist/sync/UploadPanel.js +36 -0
  71. package/dist/trigger/TriggerBlueprint.d.ts +37 -0
  72. package/dist/trigger/TriggerBlueprint.js +26 -0
  73. package/dist/trigger/tmw/TmwBlueprint.d.ts +71 -0
  74. package/dist/trigger/tmw/TmwBlueprint.js +206 -0
  75. package/dist/trigger/tmw/TmwFlowLane.d.ts +22 -0
  76. package/dist/trigger/tmw/TmwFlowLane.js +44 -0
  77. package/dist/trigger/tmw/TmwSourceLane.d.ts +17 -0
  78. package/dist/trigger/tmw/TmwSourceLane.js +38 -0
  79. package/dist/trigger/tmw/TmwTabs.d.ts +20 -0
  80. package/dist/trigger/tmw/TmwTabs.js +8 -0
  81. package/dist/trigger/tmw/TmwTraceLane.d.ts +9 -0
  82. package/dist/trigger/tmw/TmwTraceLane.js +6 -0
  83. package/dist/trigger/tmw/TriggerDebugTab.d.ts +9 -0
  84. package/dist/trigger/tmw/TriggerDebugTab.js +25 -0
  85. package/dist/trigger/tmw/translate.d.ts +3 -0
  86. package/dist/trigger/tmw/translate.js +2 -0
  87. package/dist/view/ViewWorkspace.d.ts +19 -0
  88. package/dist/view/ViewWorkspace.js +8 -0
  89. package/package.json +89 -0
@@ -0,0 +1,1439 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useId, memo, useMemo, useRef, useState } from "react";
3
+ import ReactFlow, { Background, Handle, Position, ReactFlowProvider, useReactFlow, useUpdateNodeInternals } from "reactflow";
4
+ import { GraphEdge } from "./GraphEdge.js";
5
+ import { computeGraphEdgeGeometry } from "./graph-edge-geometry.js";
6
+ import { GRAPH_EDGE_OCCLUSION_HALO, buildGraphEdgeOcclusionIndex } from "./graph-edge-mask.js";
7
+ import { clampGraphGroupTranslation, GRAPH_GROUP_LABEL_MAX_LENGTH, graphGroupBoundsFromPoints, graphGroupColor, isGraphGroupBoundsValid, nodesInsideGraphGroup, normalizeGraphGroupLabel, resizeGraphGroupSide } from "./graph-groups.js";
8
+ import { constrainGraphNodeMetrics, graphNodeMetrics } from "./graph-node-metrics.js";
9
+ import { PHYSICS_GRID_SIZE, PHYSICS_SLEEP_SPEED, initBodies, snapToGrid, stepPhysics, tryFindGridSlot } from "./graph-physics.js";
10
+ import { createGraphLayoutSolver } from "./graph-layout-solver.js";
11
+ import { NODE_DIAMETER, requiredNodeCenterDistance, resolveOverlaps } from "./graph-placement.js";
12
+ const CANONICAL_NODE_ANCHOR_RADIUS = NODE_DIAMETER / 2;
13
+ const NODE_DRAG_THRESHOLD_PX = 4;
14
+ const POSITION_NOOP_EPSILON_PX = 0.5;
15
+ const DISTANT_LABEL_ZOOM_THRESHOLD = 0.28;
16
+ const CENTER_HANDLE_STYLE = Object.freeze({
17
+ // ReactFlow 11 的 Position.Top 使用 Handle 上边中点;+0.5px 抵消 1px 高度。
18
+ top: "calc(50% + 0.5px)",
19
+ right: "auto",
20
+ bottom: "auto",
21
+ left: "50%",
22
+ width: "1px",
23
+ height: "1px",
24
+ minWidth: 0,
25
+ minHeight: 0,
26
+ border: 0,
27
+ opacity: 0,
28
+ pointerEvents: "none",
29
+ transform: "translate(-50%, -50%)"
30
+ });
31
+ const emptyMetrics = Object.freeze({
32
+ contentLength: 0,
33
+ diameter: NODE_DIAMETER,
34
+ radius: NODE_DIAMETER / 2,
35
+ truncated: false,
36
+ saturated: false
37
+ });
38
+ const metricsForNode = (node, isNameAttrKey) => {
39
+ if (node.metrics !== undefined)
40
+ return constrainGraphNodeMetrics(node.metrics);
41
+ if (node.content === undefined)
42
+ return emptyMetrics;
43
+ return graphNodeMetrics(node.content, isNameAttrKey);
44
+ };
45
+ export const graphNodeContentTitle = (label, metrics) => `${label} · ${metrics.truncated ? "至少 " : ""}${metrics.contentLength} 字符${metrics.saturated ? " · 尺寸已达上限" : ""}`;
46
+ export const graphGlyphKind = (kind) => {
47
+ switch (kind.trim().toLocaleLowerCase()) {
48
+ case "character":
49
+ case "person":
50
+ case "npc":
51
+ return "person";
52
+ case "location":
53
+ case "place":
54
+ return "place";
55
+ case "faction":
56
+ return "faction";
57
+ case "item":
58
+ return "item";
59
+ case "event":
60
+ return "event";
61
+ default:
62
+ return "neutral";
63
+ }
64
+ };
65
+ function KindGlyph({ kind }) {
66
+ const glyph = graphGlyphKind(kind);
67
+ return _jsxs("svg", { "aria-hidden": "true", className: "shared-graph-node__glyph", "data-graph-glyph": glyph, viewBox: "0 0 24 24", children: [glyph === "person" ? _jsxs(_Fragment, { children: [_jsx("circle", { cx: "12", cy: "8", r: "3" }), _jsx("path", { d: "M5.5 20c.7-4 3-6 6.5-6s5.8 2 6.5 6" })] }) : null, glyph === "place" ? _jsxs(_Fragment, { children: [_jsx("path", { d: "M12 21s6-5.3 6-11a6 6 0 1 0-12 0c0 5.7 6 11 6 11Z" }), _jsx("circle", { cx: "12", cy: "10", r: "2" })] }) : null, glyph === "faction" ? _jsxs(_Fragment, { children: [_jsx("path", { d: "M12 3 19 6v5c0 4.6-2.7 7.8-7 10-4.3-2.2-7-5.4-7-10V6l7-3Z" }), _jsx("path", { d: "M9 12h6M12 9v6" })] }) : null, glyph === "item" ? _jsxs(_Fragment, { children: [_jsx("path", { d: "m12 3 7 4-7 4-7-4 7-4Z" }), _jsx("path", { d: "m5 7v9l7 5 7-5V7M12 11v10" })] }) : null, glyph === "event" ? _jsx("path", { d: "m13.5 2-7 11H12l-1.5 9 7-12H12l1.5-8Z" }) : null, glyph === "neutral" ? _jsxs(_Fragment, { children: [_jsx("circle", { cx: "12", cy: "12", r: "7" }), _jsx("path", { d: "M8.5 12h7M12 8.5v7" })] }) : null] });
68
+ }
69
+ const CanvasNode = memo(function CanvasNode({ data, selected }) {
70
+ const isSelected = selected || data.selected;
71
+ const classes = [
72
+ "shared-graph-node",
73
+ `shared-graph-node--kind-${data.kind}`,
74
+ data.vector === undefined ? "" : `shared-graph-node--vec-${data.vector}`,
75
+ isSelected ? "is-selected" : "",
76
+ data.adjacent ? "is-adjacent" : "",
77
+ data.muted ? "is-muted" : "",
78
+ data.searchMatch ? "is-search-match" : "",
79
+ data.highlighted ? "is-highlighted" : "",
80
+ data.remoteHighlighted ? "is-remote-highlighted" : "",
81
+ data.disabled ? "is-disabled" : "",
82
+ data.pending ? "is-pending" : ""
83
+ ].filter(Boolean).join(" ");
84
+ const metrics = data.resolvedMetrics;
85
+ return _jsxs("div", { "aria-disabled": data.disabled, "aria-label": `${data.label} · ${data.kind}`, className: classes, "data-graph-node-id": data.id, onClick: () => { if (!data.interactionDisabled)
86
+ data.onSelect(); }, onContextMenu: (event) => {
87
+ event.preventDefault();
88
+ if (!data.interactionDisabled)
89
+ data.onContextMenu?.(event);
90
+ }, onKeyDown: (event) => {
91
+ if (!data.interactionDisabled && (event.key === "Enter" || event.key === " ")) {
92
+ event.preventDefault();
93
+ data.onSelect();
94
+ }
95
+ }, onPointerDown: (event) => {
96
+ if (event.button === 2 && !data.interactionDisabled)
97
+ data.onRightPointerDown?.(event);
98
+ }, role: "button", style: {
99
+ "--we-graph-node-size": `${metrics.diameter}px`,
100
+ "--we-graph-node-radius": `${metrics.radius}px`
101
+ }, tabIndex: data.interactionDisabled ? -1 : 0, title: graphNodeContentTitle(data.label, metrics), children: [_jsx(Handle, { className: "shared-graph-node__handle", isConnectable: false, position: Position.Top, style: CENTER_HANDLE_STYLE, type: "target" }), _jsx("span", { className: "shared-graph-node__circle", children: _jsx(KindGlyph, { kind: data.kind }) }), _jsx("span", { className: "shared-graph-node__label", children: data.label }), _jsx(Handle, { className: "shared-graph-node__handle", isConnectable: false, position: Position.Top, style: CENTER_HANDLE_STYLE, type: "source" })] });
102
+ }, (previous, next) => {
103
+ const left = previous.data;
104
+ const right = next.data;
105
+ return previous.selected === next.selected
106
+ && left.id === right.id
107
+ && left.label === right.label
108
+ && left.kind === right.kind
109
+ && left.disabled === right.disabled
110
+ && left.interactionDisabled === right.interactionDisabled
111
+ && left.pending === right.pending
112
+ && left.vector === right.vector
113
+ && left.selected === right.selected
114
+ && left.adjacent === right.adjacent
115
+ && left.muted === right.muted
116
+ && left.searchMatch === right.searchMatch
117
+ && left.highlighted === right.highlighted
118
+ && left.remoteHighlighted === right.remoteHighlighted
119
+ && left.resolvedMetrics.contentLength === right.resolvedMetrics.contentLength
120
+ && left.resolvedMetrics.diameter === right.resolvedMetrics.diameter
121
+ && left.resolvedMetrics.truncated === right.resolvedMetrics.truncated
122
+ && left.resolvedMetrics.saturated === right.resolvedMetrics.saturated
123
+ && (left.onContextMenu === undefined) === (right.onContextMenu === undefined)
124
+ && (left.onRightPointerDown === undefined) === (right.onRightPointerDown === undefined);
125
+ });
126
+ const edgeTypes = { sharedGraphEdge: GraphEdge };
127
+ const GROUP_NODE_PREFIX = "__we_graph_group__:";
128
+ const CanvasGroup = memo(function CanvasGroup({ data }) {
129
+ const label = data.group.label?.trim() || data.group.id;
130
+ const [renaming, setRenaming] = useState(false);
131
+ const [draftLabel, setDraftLabel] = useState(label);
132
+ const renameInputRef = useRef(null);
133
+ const renamingRef = useRef(false);
134
+ useEffect(() => {
135
+ if (!renamingRef.current)
136
+ setDraftLabel(label);
137
+ }, [label]);
138
+ useEffect(() => {
139
+ if (!renaming)
140
+ return;
141
+ renameInputRef.current?.focus();
142
+ renameInputRef.current?.select();
143
+ }, [renaming]);
144
+ useEffect(() => {
145
+ if (data.renameRequest === undefined)
146
+ return;
147
+ beginRename();
148
+ // renameRequest 是一次性外部意图;相同值不重复消费。
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
+ }, [data.renameRequest]);
151
+ const beginRename = () => {
152
+ if (data.onRename === undefined || data.draft)
153
+ return;
154
+ renamingRef.current = true;
155
+ setDraftLabel(label);
156
+ setRenaming(true);
157
+ };
158
+ const finishRename = (save) => {
159
+ if (!renamingRef.current)
160
+ return;
161
+ renamingRef.current = false;
162
+ setRenaming(false);
163
+ if (!save) {
164
+ setDraftLabel(label);
165
+ return;
166
+ }
167
+ const normalized = normalizeGraphGroupLabel(draftLabel);
168
+ if (normalized === null || normalized === label) {
169
+ setDraftLabel(label);
170
+ return;
171
+ }
172
+ data.onRename?.(normalized);
173
+ };
174
+ const classes = [
175
+ "shared-graph-group",
176
+ data.selected ? "is-selected" : "",
177
+ data.draft ? "is-draft" : "",
178
+ data.blocked ? "is-blocked" : "",
179
+ data.dropTarget ? "is-drop-target" : "",
180
+ renaming ? "is-renaming" : ""
181
+ ].filter(Boolean).join(" ");
182
+ return _jsxs("div", { "aria-label": `分组:${label}`, "aria-pressed": data.selected, className: classes, "data-graph-group-height": data.group.height, "data-graph-group-id": data.group.id, "data-graph-group-width": data.group.width, "data-graph-group-x": data.group.x, "data-graph-group-y": data.group.y, onClick: (event) => {
183
+ event.stopPropagation();
184
+ data.onSelect?.();
185
+ }, onKeyDown: (event) => {
186
+ if (event.key === "Enter" || event.key === " ") {
187
+ event.preventDefault();
188
+ data.onSelect?.();
189
+ }
190
+ }, onContextMenu: (event) => {
191
+ event.preventDefault();
192
+ event.stopPropagation();
193
+ data.onContextMenu?.(event);
194
+ }, role: "button", style: { "--we-graph-group-color": data.color }, tabIndex: data.draft ? -1 : 0, children: [renaming ? _jsx("input", { "aria-label": `重命名分组${label}`, className: "shared-graph-group__name-input nodrag nopan", maxLength: GRAPH_GROUP_LABEL_MAX_LENGTH, onBlur: () => finishRename(true), onChange: (event) => setDraftLabel(event.currentTarget.value), onClick: (event) => event.stopPropagation(), onDoubleClick: (event) => event.stopPropagation(), onKeyDown: (event) => {
195
+ event.stopPropagation();
196
+ if (event.key === "Enter") {
197
+ event.preventDefault();
198
+ finishRename(true);
199
+ }
200
+ else if (event.key === "Escape") {
201
+ event.preventDefault();
202
+ finishRename(false);
203
+ }
204
+ }, onPointerDown: (event) => event.stopPropagation(), ref: renameInputRef, value: draftLabel }) : _jsx("span", { className: "shared-graph-group__label nodrag nopan", onClick: (event) => event.stopPropagation(), onDoubleClick: (event) => {
205
+ event.preventDefault();
206
+ event.stopPropagation();
207
+ beginRename();
208
+ }, children: label }), data.selected && !data.draft ? ["top", "right", "bottom", "left"].map((side) => _jsx("span", { "aria-label": `调整分组${label}${side === "top" ? "上边" : side === "right" ? "右边" : side === "bottom" ? "下边" : "左边"}`, className: `shared-graph-group__resize shared-graph-group__resize--${side} nodrag nopan`, onPointerDown: (event) => {
209
+ event.preventDefault();
210
+ event.stopPropagation();
211
+ data.onResizePointerDown?.(side, event);
212
+ }, role: "separator" }, side)) : null] });
213
+ });
214
+ const graphNodeTypes = { sharedGraphNode: CanvasNode, sharedGraphGroup: CanvasGroup };
215
+ const gridPosition = (index) => ({
216
+ x: 72 + (index % 5) * 168,
217
+ y: 64 + Math.floor(index / 5) * 144
218
+ });
219
+ const normalizeQuery = (query) => query.trim().toLocaleLowerCase();
220
+ const canonicalCenter = (position) => ({
221
+ x: position.x + CANONICAL_NODE_ANCHOR_RADIUS,
222
+ y: position.y + CANONICAL_NODE_ANCHOR_RADIUS
223
+ });
224
+ const canonicalTopLeft = (center) => ({
225
+ x: center.x - CANONICAL_NODE_ANCHOR_RADIUS,
226
+ y: center.y - CANONICAL_NODE_ANCHOR_RADIUS
227
+ });
228
+ const graphGroupNodeId = (id) => `${GROUP_NODE_PREFIX}${id}`;
229
+ const graphGroupIdFromNodeId = (id) => id.startsWith(GROUP_NODE_PREFIX) ? id.slice(GROUP_NODE_PREFIX.length) : null;
230
+ const samePosition = (left, right) => Math.abs(left.x - right.x) < POSITION_NOOP_EPSILON_PX
231
+ && Math.abs(left.y - right.y) < POSITION_NOOP_EPSILON_PX;
232
+ const nextGraphGroupId = (groups) => {
233
+ const used = new Set(groups.map((group) => group.id));
234
+ let suffix = groups.length + 1;
235
+ while (used.has(`group-${suffix}`))
236
+ suffix += 1;
237
+ return `group-${suffix}`;
238
+ };
239
+ const clampGroupMemberTranslation = (requested, memberIds, positions, metricsById) => {
240
+ if (memberIds.length === 0)
241
+ return requested;
242
+ const members = new Set(memberIds);
243
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(requested.x), Math.abs(requested.y)) / PHYSICS_GRID_SIZE));
244
+ let lastSafe = { x: 0, y: 0 };
245
+ for (let step = 1; step <= steps; step += 1) {
246
+ const raw = {
247
+ x: requested.x * step / steps,
248
+ y: requested.y * step / steps
249
+ };
250
+ const candidate = snapToGrid(raw.x, raw.y);
251
+ let safe = true;
252
+ for (const memberId of memberIds) {
253
+ const memberPosition = positions[memberId];
254
+ if (memberPosition === undefined)
255
+ continue;
256
+ const memberCenter = canonicalCenter(memberPosition);
257
+ const memberRadius = metricsById.get(memberId)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
258
+ for (const [otherId, otherPosition] of Object.entries(positions)) {
259
+ if (members.has(otherId))
260
+ continue;
261
+ const otherCenter = canonicalCenter(otherPosition);
262
+ const otherRadius = metricsById.get(otherId)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
263
+ const minimum = requiredNodeCenterDistance(memberRadius, otherRadius);
264
+ if (Math.hypot(memberCenter.x + candidate.x - otherCenter.x, memberCenter.y + candidate.y - otherCenter.y) < minimum) {
265
+ safe = false;
266
+ break;
267
+ }
268
+ }
269
+ if (!safe)
270
+ break;
271
+ }
272
+ if (!safe)
273
+ return lastSafe;
274
+ lastSafe = candidate;
275
+ }
276
+ return lastSafe;
277
+ };
278
+ export function groupGraphRelations(relations) {
279
+ const groups = new Map();
280
+ for (const relation of relations) {
281
+ const source = relation.source <= relation.target ? relation.source : relation.target;
282
+ const target = relation.source <= relation.target ? relation.target : relation.source;
283
+ const pairId = `pair:${JSON.stringify([source, target])}`;
284
+ const existing = groups.get(pairId);
285
+ if (existing === undefined) {
286
+ groups.set(pairId, { pairId, source, target, relations: [relation] });
287
+ }
288
+ else {
289
+ existing.relations.push(relation);
290
+ }
291
+ }
292
+ return [...groups.values()].map((group) => ({
293
+ pairId: group.pairId,
294
+ source: group.source,
295
+ target: group.target,
296
+ relationIds: group.relations.map((relation) => relation.id),
297
+ relations: group.relations
298
+ }));
299
+ }
300
+ const sameViewport = (left, right) => left !== null
301
+ && left.x === right.x
302
+ && left.y === right.y
303
+ && left.zoom === right.zoom;
304
+ function GraphContextMenu({ actions, clientX, clientY, onClose }) {
305
+ const menuRef = useRef(null);
306
+ useEffect(() => {
307
+ menuRef.current?.querySelector("[role='menuitem']")?.focus();
308
+ const closeOutside = (event) => {
309
+ if (menuRef.current?.contains(event.target))
310
+ return;
311
+ onClose();
312
+ };
313
+ const closeOnEscape = (event) => {
314
+ if (event.key === "Escape")
315
+ onClose();
316
+ };
317
+ window.addEventListener("pointerdown", closeOutside, true);
318
+ window.addEventListener("keydown", closeOnEscape);
319
+ window.addEventListener("resize", onClose);
320
+ return () => {
321
+ window.removeEventListener("pointerdown", closeOutside, true);
322
+ window.removeEventListener("keydown", closeOnEscape);
323
+ window.removeEventListener("resize", onClose);
324
+ };
325
+ }, [onClose]);
326
+ if (actions.length === 0)
327
+ return null;
328
+ const width = 184;
329
+ const height = actions.length * 34 + 12;
330
+ const left = Math.max(8, Math.min(clientX, window.innerWidth - width - 8));
331
+ const top = Math.max(8, Math.min(clientY, window.innerHeight - height - 8));
332
+ return _jsx("div", { "aria-label": "\u56FE\u8C31\u64CD\u4F5C\u83DC\u5355", className: "shared-graph-context-menu", "data-graph-context-menu": "true", onContextMenu: (event) => event.preventDefault(), onKeyDown: (event) => {
333
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End")
334
+ return;
335
+ event.preventDefault();
336
+ const items = [...event.currentTarget.querySelectorAll("[role='menuitem']")];
337
+ if (items.length === 0)
338
+ return;
339
+ const current = items.indexOf(document.activeElement);
340
+ const next = event.key === "Home"
341
+ ? 0
342
+ : event.key === "End"
343
+ ? items.length - 1
344
+ : event.key === "ArrowDown"
345
+ ? (current + 1 + items.length) % items.length
346
+ : (current - 1 + items.length) % items.length;
347
+ items[next]?.focus();
348
+ }, ref: menuRef, role: "menu", style: { left, top }, children: actions.map((action) => _jsx("button", { className: action.danger ? "is-danger" : undefined, onClick: () => {
349
+ onClose();
350
+ action.run();
351
+ }, role: "menuitem", type: "button", children: action.label }, action.id)) });
352
+ }
353
+ /**
354
+ * 同步执行 editor 成熟 physics tick;坐标输入输出均为 canonical 48px node 左上角。
355
+ * 只返回最终投影,调用方可在一次交互结束后单次持久化。
356
+ */
357
+ export function forceLayout(nodes, current, relations = [], isNameAttrKey) {
358
+ const metrics = new Map(nodes.map((node) => [node.id, metricsForNode(node, isNameAttrKey)]));
359
+ const physicsEdges = relations.map((relation) => ({
360
+ from: relation.source,
361
+ to: relation.target
362
+ }));
363
+ const bodies = initBodies(nodes.map((node, index) => {
364
+ const nodeMetrics = metrics.get(node.id) ?? emptyMetrics;
365
+ const position = current[node.id] ?? gridPosition(index);
366
+ return {
367
+ id: node.id,
368
+ x: position.x + CANONICAL_NODE_ANCHOR_RADIUS,
369
+ y: position.y + CANONICAL_NODE_ANCHOR_RADIUS,
370
+ radius: nodeMetrics.radius
371
+ };
372
+ }), physicsEdges);
373
+ let settledTicks = 0;
374
+ for (let tick = 0; tick < 240 && settledTicks < 12; tick += 1) {
375
+ stepPhysics(bodies, physicsEdges, 0.032);
376
+ const moving = bodies.some((body) => Math.hypot(body.vx, body.vy) >= PHYSICS_SLEEP_SPEED);
377
+ settledTicks = moving ? 0 : settledTicks + 1;
378
+ }
379
+ const spaced = resolveOverlaps(bodies.map((body) => ({
380
+ id: body.id,
381
+ position: {
382
+ x: Math.round(body.x - CANONICAL_NODE_ANCHOR_RADIUS),
383
+ y: Math.round(body.y - CANONICAL_NODE_ANCHOR_RADIUS)
384
+ },
385
+ ...(body.radius === undefined ? {} : { radius: body.radius })
386
+ })));
387
+ return Object.fromEntries(spaced.map((node) => [node.id, node.position]));
388
+ }
389
+ function LineIcon({ kind }) {
390
+ const path = kind === "plus"
391
+ ? "M12 5v14M5 12h14"
392
+ : kind === "minus"
393
+ ? "M5 12h14"
394
+ : kind === "fit"
395
+ ? "M9 5H5v4M15 5h4v4M9 19H5v-4M15 19h4v-4"
396
+ : kind === "force"
397
+ ? "M6 7l6-3 6 3v10l-6 3-6-3V7Zm0 0 6 3 6-3M12 10v10"
398
+ : "M5 7V5h4M15 5h4v4M19 15v4h-4M9 19H5v-4";
399
+ return _jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", children: _jsx("path", { d: path, fill: "none" }) });
400
+ }
401
+ function CanvasControls({ canCommit, canCreateGroup, drawingGroup, onForce, onToggleGroup }) {
402
+ const flow = useReactFlow();
403
+ return _jsxs("div", { "aria-label": "\u56FE\u8C31\u89C6\u56FE\u63A7\u5236", className: "shared-graph-controls", children: [_jsx("button", { "aria-label": "\u653E\u5927", onClick: () => { void flow.zoomIn({ duration: 160 }); }, type: "button", children: _jsx(LineIcon, { kind: "plus" }) }), _jsx("button", { "aria-label": "\u7F29\u5C0F", onClick: () => { void flow.zoomOut({ duration: 160 }); }, type: "button", children: _jsx(LineIcon, { kind: "minus" }) }), _jsx("button", { "aria-label": "\u9002\u5408\u89C6\u56FE", onClick: () => { void flow.fitView({ padding: 0.16, duration: 200 }); }, type: "button", children: _jsx(LineIcon, { kind: "fit" }) }), _jsx("button", { "aria-label": "\u81EA\u52A8\u5E03\u5C40", disabled: !canCommit, onClick: onForce, type: "button", children: _jsx(LineIcon, { kind: "force" }) }), _jsx("button", { "aria-label": drawingGroup ? "取消新建分组" : "新建分组", "aria-pressed": drawingGroup, disabled: !canCreateGroup, onClick: onToggleGroup, type: "button", children: _jsx(LineIcon, { kind: "group" }) })] });
404
+ }
405
+ function GraphCanvasInner(props) {
406
+ const flow = useReactFlow();
407
+ const updateNodeInternals = useUpdateNodeInternals();
408
+ const edgeMaskId = `we-graph-edge-mask-${useId().replaceAll(":", "")}`;
409
+ const maskCircleByNodeIdRef = useRef(new Map());
410
+ const canvasRef = useRef(null);
411
+ const initialLabelZoom = props.viewport?.zoom !== undefined
412
+ && Number.isFinite(props.viewport.zoom)
413
+ && props.viewport.zoom > 0
414
+ ? props.viewport.zoom
415
+ : 1;
416
+ const labelZoomRef = useRef(initialLabelZoom);
417
+ const [edgeOcclusionZoom, setEdgeOcclusionZoom] = useState(initialLabelZoom);
418
+ const updateLabelZoom = useCallback((zoom) => {
419
+ if (!Number.isFinite(zoom) || zoom <= 0 || canvasRef.current === null)
420
+ return;
421
+ labelZoomRef.current = zoom;
422
+ canvasRef.current.style.setProperty("--we-graph-zoom", String(zoom));
423
+ canvasRef.current.style.setProperty("--we-graph-inverse-zoom", String(1 / zoom));
424
+ canvasRef.current.dataset.weGraphDistant = String(zoom < DISTANT_LABEL_ZOOM_THRESHOLD);
425
+ }, []);
426
+ useEffect(() => updateLabelZoom(initialLabelZoom), [initialLabelZoom, updateLabelZoom]);
427
+ useEffect(() => setEdgeOcclusionZoom(initialLabelZoom), [initialLabelZoom]);
428
+ const onSelectNodeRef = useRef(props.onSelectNode);
429
+ onSelectNodeRef.current = props.onSelectNode;
430
+ const onNodeContextMenuRef = useRef(props.onNodeContextMenu);
431
+ onNodeContextMenuRef.current = props.onNodeContextMenu;
432
+ const onLayoutErrorRef = useRef(props.onLayoutError);
433
+ onLayoutErrorRef.current = props.onLayoutError;
434
+ const [layoutError, setLayoutError] = useState(null);
435
+ const reportLayoutError = useCallback((error) => {
436
+ const normalized = error instanceof Error ? error : new Error(String(error));
437
+ setLayoutError(normalized.message || "图布局失败");
438
+ onLayoutErrorRef.current?.(normalized);
439
+ }, []);
440
+ const layoutSolverRef = useRef(null);
441
+ const layoutSolverFactoryRef = useRef(props.createLayoutSolver ?? createGraphLayoutSolver);
442
+ layoutSolverFactoryRef.current = props.createLayoutSolver ?? createGraphLayoutSolver;
443
+ const getLayoutSolver = useCallback(() => {
444
+ if (layoutSolverRef.current !== null)
445
+ return layoutSolverRef.current;
446
+ if (typeof Worker === "undefined" && props.createLayoutSolver === undefined) {
447
+ if (typeof window !== "undefined") {
448
+ throw new Error("当前浏览器不支持 GraphLayout Worker,已拒绝主线程布局 fallback");
449
+ }
450
+ return null;
451
+ }
452
+ layoutSolverRef.current = layoutSolverFactoryRef.current();
453
+ return layoutSolverRef.current;
454
+ }, [props.createLayoutSolver]);
455
+ const [flowInstance, setFlowInstance] = useState(null);
456
+ const [viewportReady, setViewportReady] = useState(false);
457
+ useEffect(() => {
458
+ if (flowInstance === null)
459
+ return;
460
+ let frame = 0;
461
+ const check = () => {
462
+ if (flowInstance.viewportInitialized) {
463
+ setViewportReady(true);
464
+ return;
465
+ }
466
+ frame = window.requestAnimationFrame(check);
467
+ };
468
+ check();
469
+ return () => {
470
+ if (frame !== 0)
471
+ window.cancelAnimationFrame(frame);
472
+ };
473
+ }, [flowInstance]);
474
+ const [localPositions, setLocalPositions] = useState(() => ({ ...props.positions }));
475
+ useEffect(() => setLocalPositions({ ...props.positions }), [props.positions]);
476
+ const [localGroups, setLocalGroups] = useState(() => [...(props.groups ?? [])]);
477
+ const localGroupsRef = useRef(localGroups);
478
+ localGroupsRef.current = localGroups;
479
+ useEffect(() => {
480
+ const next = [...(props.groups ?? [])];
481
+ localGroupsRef.current = next;
482
+ setLocalGroups(next);
483
+ }, [props.groups]);
484
+ const [selectedGroupId, setSelectedGroupId] = useState(null);
485
+ const [dropTargetGroupId, setDropTargetGroupId] = useState(null);
486
+ const [blockedGroupId, setBlockedGroupId] = useState(null);
487
+ const [drawingGroup, setDrawingGroup] = useState(false);
488
+ const [draftGroup, setDraftGroup] = useState(null);
489
+ const draftGroupRef = useRef(draftGroup);
490
+ draftGroupRef.current = draftGroup;
491
+ const groupDrawSessionRef = useRef(null);
492
+ const groupDragSessionRef = useRef(null);
493
+ const groupResizeSessionRef = useRef(null);
494
+ useEffect(() => {
495
+ if (props.disabled)
496
+ setDropTargetGroupId(null);
497
+ }, [props.disabled]);
498
+ const metricsById = useMemo(() => new Map(props.nodes.map((node) => [
499
+ node.id,
500
+ metricsForNode(node, props.isNameAttrKey)
501
+ ])), [props.isNameAttrKey, props.nodes]);
502
+ const rawPositions = useMemo(() => props.nodes.reduce((all, node, index) => {
503
+ all[node.id] = localPositions[node.id] ?? gridPosition(index);
504
+ return all;
505
+ }, {}), [localPositions, props.nodes]);
506
+ const positions = useMemo(() => Object.fromEntries(resolveOverlaps(props.nodes.map((node) => ({
507
+ id: node.id,
508
+ position: rawPositions[node.id],
509
+ radius: metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS
510
+ }))).map((node) => [node.id, node.position])), [metricsById, props.nodes, rawPositions]);
511
+ const positionsRef = useRef(positions);
512
+ positionsRef.current = positions;
513
+ const nodesRef = useRef(props.nodes);
514
+ nodesRef.current = props.nodes;
515
+ const metricsByIdRef = useRef(metricsById);
516
+ metricsByIdRef.current = metricsById;
517
+ const relationsRef = useRef(props.relations);
518
+ relationsRef.current = props.relations;
519
+ const layoutGenerationRef = useRef(0);
520
+ const dragLayoutFrameRef = useRef(0);
521
+ const pendingDragRef = useRef(null);
522
+ const graphIdentity = useMemo(() => props.graphIdentity
523
+ ?? props.nodes.map((node) => node.id).join("\u0000"), [props.graphIdentity, props.nodes]);
524
+ const activeGraphIdentityRef = useRef(graphIdentity);
525
+ useEffect(() => {
526
+ if (activeGraphIdentityRef.current === graphIdentity)
527
+ return;
528
+ activeGraphIdentityRef.current = graphIdentity;
529
+ layoutGenerationRef.current += 1;
530
+ pendingDragRef.current = null;
531
+ if (dragLayoutFrameRef.current !== 0) {
532
+ window.cancelAnimationFrame(dragLayoutFrameRef.current);
533
+ dragLayoutFrameRef.current = 0;
534
+ }
535
+ layoutSolverRef.current?.dispose();
536
+ layoutSolverRef.current = null;
537
+ }, [graphIdentity]);
538
+ useEffect(() => () => {
539
+ layoutGenerationRef.current += 1;
540
+ pendingDragRef.current = null;
541
+ if (dragLayoutFrameRef.current !== 0)
542
+ window.cancelAnimationFrame(dragLayoutFrameRef.current);
543
+ layoutSolverRef.current?.dispose();
544
+ layoutSolverRef.current = null;
545
+ }, []);
546
+ const commitLayout = useCallback((nextPositions, nextGroups = localGroupsRef.current) => {
547
+ const positionsCopy = { ...nextPositions };
548
+ const groupsCopy = [...nextGroups];
549
+ setLocalPositions(positionsCopy);
550
+ localGroupsRef.current = groupsCopy;
551
+ setLocalGroups(groupsCopy);
552
+ if (props.onCommitLayout !== undefined) {
553
+ props.onCommitLayout({ positions: positionsCopy, groups: groupsCopy });
554
+ }
555
+ else {
556
+ props.onCommitPositions?.(positionsCopy);
557
+ }
558
+ }, [props.onCommitLayout, props.onCommitPositions]);
559
+ const solveDraggedLayout = useCallback((commitResult) => {
560
+ const dragged = pendingDragRef.current;
561
+ let solver;
562
+ try {
563
+ solver = getLayoutSolver();
564
+ }
565
+ catch (error) {
566
+ reportLayoutError(error);
567
+ return;
568
+ }
569
+ if (dragged === null || solver === null)
570
+ return;
571
+ setLayoutError(null);
572
+ const generation = ++layoutGenerationRef.current;
573
+ const layoutPositions = {
574
+ ...positionsRef.current,
575
+ [dragged.id]: { x: dragged.x, y: dragged.y }
576
+ };
577
+ void solver({
578
+ nodes: nodesRef.current.map((node) => {
579
+ const position = layoutPositions[node.id] ?? { x: 0, y: 0 };
580
+ return {
581
+ id: node.id,
582
+ x: position.x + CANONICAL_NODE_ANCHOR_RADIUS,
583
+ y: position.y + CANONICAL_NODE_ANCHOR_RADIUS,
584
+ radius: metricsByIdRef.current.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS
585
+ };
586
+ }),
587
+ edges: relationsRef.current.map((relation) => ({ from: relation.source, to: relation.target })),
588
+ fixedNodeId: dragged.id
589
+ }).then((centers) => {
590
+ if (generation !== layoutGenerationRef.current)
591
+ return;
592
+ const canonical = Object.fromEntries(Object.entries(centers).map(([id, center]) => [id, {
593
+ x: center.x - CANONICAL_NODE_ANCHOR_RADIUS,
594
+ y: center.y - CANONICAL_NODE_ANCHOR_RADIUS
595
+ }]));
596
+ setLocalPositions(canonical);
597
+ setLayoutError(null);
598
+ if (commitResult)
599
+ commitLayout(canonical);
600
+ }).catch((error) => {
601
+ if (generation === layoutGenerationRef.current
602
+ && error?.name !== "AbortError") {
603
+ // Worker 错误不允许用主线程同步 force 或未验证 position 掩盖。
604
+ reportLayoutError(error);
605
+ }
606
+ });
607
+ }, [commitLayout, getLayoutSolver, reportLayoutError]);
608
+ const previousDiameterByIdRef = useRef(null);
609
+ useEffect(() => {
610
+ const next = new Map();
611
+ const resized = [];
612
+ for (const node of props.nodes) {
613
+ const diameter = metricsById.get(node.id)?.diameter ?? NODE_DIAMETER;
614
+ next.set(node.id, diameter);
615
+ const previous = previousDiameterByIdRef.current?.get(node.id);
616
+ if (previous !== undefined && previous !== diameter)
617
+ resized.push(node.id);
618
+ }
619
+ previousDiameterByIdRef.current = next;
620
+ if (resized.length === 0)
621
+ return;
622
+ const frame = window.requestAnimationFrame(() => {
623
+ for (const id of resized)
624
+ updateNodeInternals(id);
625
+ });
626
+ return () => window.cancelAnimationFrame(frame);
627
+ }, [metricsById, props.nodes, updateNodeInternals]);
628
+ const handledFocusRequestIdRef = useRef(null);
629
+ const appliedViewportPropRef = useRef(null);
630
+ useEffect(() => {
631
+ if (!viewportReady || !flow.viewportInitialized)
632
+ return;
633
+ const request = props.focusRequest;
634
+ if (request != null && handledFocusRequestIdRef.current !== request.requestId) {
635
+ // controlled readback 可能与 local mirror 同一 commit 到达;聚焦优先消费最新 source position。
636
+ const position = props.positions?.[request.nodeId] ?? positions[request.nodeId];
637
+ if (position !== undefined) {
638
+ handledFocusRequestIdRef.current = request.requestId;
639
+ // 同批 viewport prop 是发起 focus 前的旧值;标记已消费,避免紧接着把 focus 覆盖回去。
640
+ appliedViewportPropRef.current = props.viewport ?? null;
641
+ void flow.setCenter(position.x + CANONICAL_NODE_ANCHOR_RADIUS, position.y + CANONICAL_NODE_ANCHOR_RADIUS, {
642
+ zoom: request.zoom ?? flow.getZoom(),
643
+ duration: request.durationMs ?? 220
644
+ });
645
+ return;
646
+ }
647
+ }
648
+ const requestedViewport = props.viewport;
649
+ if (requestedViewport === undefined || sameViewport(appliedViewportPropRef.current, requestedViewport))
650
+ return;
651
+ appliedViewportPropRef.current = requestedViewport;
652
+ if (!sameViewport(flow.getViewport(), requestedViewport)) {
653
+ void flow.setViewport(requestedViewport);
654
+ }
655
+ }, [flow, positions, props.focusRequest, props.positions, props.viewport, viewportReady]);
656
+ const lastViewportRef = useRef(props.viewport ?? null);
657
+ useEffect(() => {
658
+ if (props.viewport !== undefined)
659
+ lastViewportRef.current = props.viewport;
660
+ }, [props.viewport]);
661
+ const notifyViewportChange = useCallback((viewport) => {
662
+ if (sameViewport(lastViewportRef.current, viewport))
663
+ return;
664
+ lastViewportRef.current = viewport;
665
+ props.onViewportChange?.(viewport);
666
+ }, [props.onViewportChange]);
667
+ const canCommitPositions = !props.disabled
668
+ && (props.onCommitLayout !== undefined || props.onCommitPositions !== undefined);
669
+ const canEditGroups = !props.disabled && props.onCommitLayout !== undefined;
670
+ const canCreateRelation = !props.disabled && props.onRelationDragIntent !== undefined;
671
+ const canCreateRelationRef = useRef(canCreateRelation);
672
+ canCreateRelationRef.current = canCreateRelation;
673
+ const [rightRelationDrag, setRightRelationDrag] = useState(null);
674
+ const rightRelationDragRef = useRef(null);
675
+ const suppressContextMenuUntilRef = useRef(0);
676
+ const [contextMenu, setContextMenu] = useState(null);
677
+ const pendingNodeContextMenuRef = useRef(null);
678
+ const [groupRenameRequest, setGroupRenameRequest] = useState(null);
679
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
680
+ const openContextMenu = useCallback((event, target) => {
681
+ event.preventDefault();
682
+ event.stopPropagation();
683
+ setContextMenu({ clientX: event.clientX, clientY: event.clientY, target });
684
+ }, []);
685
+ const interactionDisabledById = useMemo(() => new Map(props.nodes.map((node) => [
686
+ node.id,
687
+ props.disabled || (node.interactionDisabled ?? node.disabled ?? false)
688
+ ])), [props.disabled, props.nodes]);
689
+ const interactionDisabledByIdRef = useRef(interactionDisabledById);
690
+ interactionDisabledByIdRef.current = interactionDisabledById;
691
+ const startRightRelationDrag = useCallback((sourceId, event) => {
692
+ if (!canCreateRelation || interactionDisabledByIdRef.current.get(sourceId) === true)
693
+ return;
694
+ pendingNodeContextMenuRef.current = null;
695
+ setContextMenu(null);
696
+ event.stopPropagation();
697
+ const next = {
698
+ sourceId,
699
+ startClientX: event.clientX,
700
+ startClientY: event.clientY,
701
+ clientX: event.clientX,
702
+ clientY: event.clientY,
703
+ moved: false
704
+ };
705
+ rightRelationDragRef.current = next;
706
+ setRightRelationDrag(next);
707
+ }, [canCreateRelation]);
708
+ useEffect(() => {
709
+ const move = (event) => {
710
+ const current = rightRelationDragRef.current;
711
+ if (current === null)
712
+ return;
713
+ const moved = current.moved
714
+ || Math.hypot(event.clientX - current.startClientX, event.clientY - current.startClientY) >= 4;
715
+ if (moved)
716
+ event.preventDefault();
717
+ const next = { ...current, clientX: event.clientX, clientY: event.clientY, moved };
718
+ rightRelationDragRef.current = next;
719
+ setRightRelationDrag(next);
720
+ };
721
+ const finish = (event) => {
722
+ const current = rightRelationDragRef.current;
723
+ if (current === null || event.button !== 2)
724
+ return;
725
+ rightRelationDragRef.current = null;
726
+ setRightRelationDrag(null);
727
+ if (!current.moved) {
728
+ const pendingMenu = pendingNodeContextMenuRef.current;
729
+ pendingNodeContextMenuRef.current = null;
730
+ if (pendingMenu?.nodeId === current.sourceId
731
+ && interactionDisabledByIdRef.current.get(current.sourceId) === false) {
732
+ setContextMenu({
733
+ clientX: pendingMenu.clientX,
734
+ clientY: pendingMenu.clientY,
735
+ target: { kind: "node", nodeId: current.sourceId }
736
+ });
737
+ }
738
+ return;
739
+ }
740
+ pendingNodeContextMenuRef.current = null;
741
+ suppressContextMenuUntilRef.current = Date.now() + 500;
742
+ if (!canCreateRelationRef.current
743
+ || interactionDisabledByIdRef.current.get(current.sourceId) !== false)
744
+ return;
745
+ const hit = typeof document.elementFromPoint === "function"
746
+ ? document.elementFromPoint(event.clientX, event.clientY)
747
+ : null;
748
+ const targetElement = hit?.closest("[data-graph-node-id]");
749
+ const targetId = targetElement?.dataset.graphNodeId;
750
+ if (targetId === undefined
751
+ || targetId === current.sourceId
752
+ || interactionDisabledByIdRef.current.get(targetId) !== false)
753
+ return;
754
+ props.onRelationDragIntent?.(current.sourceId, targetId);
755
+ };
756
+ window.addEventListener("pointermove", move, { passive: false });
757
+ window.addEventListener("pointerup", finish);
758
+ return () => {
759
+ window.removeEventListener("pointermove", move);
760
+ window.removeEventListener("pointerup", finish);
761
+ };
762
+ }, [props.onRelationDragIntent]);
763
+ const relationDragGeometry = useMemo(() => {
764
+ if (rightRelationDrag === null || !rightRelationDrag.moved || !flow.viewportInitialized)
765
+ return null;
766
+ const sourcePosition = positions[rightRelationDrag.sourceId];
767
+ if (sourcePosition === undefined)
768
+ return null;
769
+ const sourceCenter = flow.flowToScreenPosition({
770
+ x: sourcePosition.x + CANONICAL_NODE_ANCHOR_RADIUS,
771
+ y: sourcePosition.y + CANONICAL_NODE_ANCHOR_RADIUS
772
+ });
773
+ const zoom = flow.getZoom();
774
+ return computeGraphEdgeGeometry({
775
+ sourceX: sourceCenter.x,
776
+ sourceY: sourceCenter.y,
777
+ targetX: rightRelationDrag.clientX,
778
+ targetY: rightRelationDrag.clientY,
779
+ sourceRadius: (metricsById.get(rightRelationDrag.sourceId)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS) * zoom,
780
+ targetRadius: 1,
781
+ fallbackRadius: 1
782
+ });
783
+ }, [flow, metricsById, positions, rightRelationDrag]);
784
+ const showToolbar = props.showToolbar ?? true;
785
+ const normalizedQuery = normalizeQuery(props.searchQuery);
786
+ const adjacentNodeIds = useMemo(() => {
787
+ const result = new Set();
788
+ if (props.selectedNodeId === null)
789
+ return result;
790
+ for (const relation of props.relations) {
791
+ if (relation.source === props.selectedNodeId)
792
+ result.add(relation.target);
793
+ if (relation.target === props.selectedNodeId)
794
+ result.add(relation.source);
795
+ }
796
+ return result;
797
+ }, [props.relations, props.selectedNodeId]);
798
+ const edgeOccluders = useMemo(() => props.nodes.map((node) => ({
799
+ id: node.id,
800
+ x: (positions[node.id]?.x ?? 0) + CANONICAL_NODE_ANCHOR_RADIUS,
801
+ y: (positions[node.id]?.y ?? 0) + CANONICAL_NODE_ANCHOR_RADIUS,
802
+ radius: metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS
803
+ })), [metricsById, positions, props.nodes]);
804
+ const edgeOcclusionIndex = useMemo(() => buildGraphEdgeOcclusionIndex(edgeOccluders), [edgeOccluders]);
805
+ const edgeMaskBounds = useMemo(() => {
806
+ if (edgeOccluders.length === 0)
807
+ return { x: -1, y: -1, width: 2, height: 2 };
808
+ let minX = Number.POSITIVE_INFINITY;
809
+ let minY = Number.POSITIVE_INFINITY;
810
+ let maxX = Number.NEGATIVE_INFINITY;
811
+ let maxY = Number.NEGATIVE_INFINITY;
812
+ for (const occluder of edgeOccluders) {
813
+ const radius = occluder.radius + GRAPH_EDGE_OCCLUSION_HALO;
814
+ minX = Math.min(minX, occluder.x - radius);
815
+ minY = Math.min(minY, occluder.y - radius);
816
+ maxX = Math.max(maxX, occluder.x + radius);
817
+ maxY = Math.max(maxY, occluder.y + radius);
818
+ }
819
+ const margin = 256;
820
+ return {
821
+ x: minX - margin,
822
+ y: minY - margin,
823
+ width: maxX - minX + margin * 2,
824
+ height: maxY - minY + margin * 2
825
+ };
826
+ }, [edgeOccluders]);
827
+ const startGroupResize = useCallback((group, side, event) => {
828
+ if (!canEditGroups)
829
+ return;
830
+ groupResizeSessionRef.current = {
831
+ groupId: group.id,
832
+ side,
833
+ source: group,
834
+ groups: localGroupsRef.current,
835
+ changed: false
836
+ };
837
+ event.currentTarget.setPointerCapture?.(event.pointerId);
838
+ }, [canEditGroups]);
839
+ const renameGroup = useCallback((groupId, label) => {
840
+ if (!canEditGroups)
841
+ return;
842
+ const normalized = normalizeGraphGroupLabel(label);
843
+ if (normalized === null)
844
+ return;
845
+ const current = localGroupsRef.current;
846
+ const group = current.find((candidate) => candidate.id === groupId);
847
+ if (group === undefined || group.label === normalized)
848
+ return;
849
+ const nextGroups = current.map((candidate) => candidate.id === groupId
850
+ ? { ...candidate, label: normalized }
851
+ : candidate);
852
+ commitLayout(positionsRef.current, nextGroups);
853
+ }, [canEditGroups, commitLayout]);
854
+ const graphEntityNodes = useMemo(() => props.nodes.map((node, index) => {
855
+ const resolvedMetrics = metricsById.get(node.id) ?? emptyMetrics;
856
+ const selected = node.id === props.selectedNodeId;
857
+ const adjacent = adjacentNodeIds.has(node.id);
858
+ const searchMatch = node.searchMatch
859
+ ?? (normalizedQuery !== "" && `${node.label} ${node.kind}`.toLocaleLowerCase().includes(normalizedQuery));
860
+ const interactionDisabled = props.disabled
861
+ || (node.interactionDisabled ?? node.disabled ?? false);
862
+ return {
863
+ id: node.id,
864
+ type: "sharedGraphNode",
865
+ position: {
866
+ x: (positions[node.id] ?? gridPosition(index)).x + CANONICAL_NODE_ANCHOR_RADIUS - resolvedMetrics.radius,
867
+ y: (positions[node.id] ?? gridPosition(index)).y + CANONICAL_NODE_ANCHOR_RADIUS - resolvedMetrics.radius
868
+ },
869
+ data: {
870
+ ...node,
871
+ disabled: Boolean(props.disabled || node.disabled),
872
+ interactionDisabled,
873
+ selected,
874
+ adjacent,
875
+ muted: props.selectedNodeId !== null && !selected && !adjacent,
876
+ searchMatch,
877
+ remoteHighlighted: node.id === props.highlightNodeId,
878
+ resolvedMetrics,
879
+ onSelect: () => {
880
+ if (!interactionDisabled)
881
+ onSelectNodeRef.current(node.id);
882
+ },
883
+ onContextMenu: (event) => {
884
+ if (Date.now() <= suppressContextMenuUntilRef.current
885
+ || rightRelationDragRef.current?.moved === true) {
886
+ suppressContextMenuUntilRef.current = 0;
887
+ return;
888
+ }
889
+ if (interactionDisabled)
890
+ return;
891
+ onNodeContextMenuRef.current?.(node.id, event);
892
+ if (rightRelationDragRef.current !== null) {
893
+ pendingNodeContextMenuRef.current = {
894
+ nodeId: node.id,
895
+ clientX: event.clientX,
896
+ clientY: event.clientY
897
+ };
898
+ return;
899
+ }
900
+ openContextMenu(event, { kind: "node", nodeId: node.id });
901
+ },
902
+ ...(canCreateRelation && !interactionDisabled ? {
903
+ onRightPointerDown: (event) => {
904
+ startRightRelationDrag(node.id, event);
905
+ }
906
+ } : {})
907
+ },
908
+ draggable: canCommitPositions && !interactionDisabled,
909
+ style: { width: resolvedMetrics.diameter, height: resolvedMetrics.diameter }
910
+ };
911
+ }), [adjacentNodeIds, canCommitPositions, canCreateRelation, metricsById, normalizedQuery, openContextMenu, positions, props.disabled, props.highlightNodeId, props.nodes, props.onNodeContextMenu, props.onSelectNode, props.selectedNodeId, startRightRelationDrag]);
912
+ const graphGroupNodes = useMemo(() => [...localGroups, ...(draftGroup === null ? [] : [draftGroup])].map((group) => ({
913
+ id: graphGroupNodeId(group.id),
914
+ type: "sharedGraphGroup",
915
+ position: { x: group.x, y: group.y },
916
+ data: {
917
+ group,
918
+ color: graphGroupColor(group.id),
919
+ selected: !group.id.startsWith("__draft__") && group.id === selectedGroupId,
920
+ draft: group.id.startsWith("__draft__"),
921
+ blocked: group.id === blockedGroupId || ("blocked" in group && group.blocked === true),
922
+ dropTarget: !group.id.startsWith("__draft__") && group.id === dropTargetGroupId,
923
+ ...(groupRenameRequest?.groupId === group.id ? { renameRequest: groupRenameRequest.serial } : {}),
924
+ ...(!group.id.startsWith("__draft__") ? {
925
+ onSelect: () => setSelectedGroupId(group.id),
926
+ onRename: (label) => renameGroup(group.id, label),
927
+ onContextMenu: (event) => {
928
+ if (canEditGroups || props.onRequestUngroup !== undefined) {
929
+ openContextMenu(event, { kind: "group", groupId: group.id });
930
+ }
931
+ },
932
+ onResizePointerDown: (side, event) => {
933
+ startGroupResize(group, side, event);
934
+ }
935
+ } : {})
936
+ },
937
+ draggable: canEditGroups && !group.id.startsWith("__draft__"),
938
+ selectable: false,
939
+ zIndex: -1,
940
+ style: { width: group.width, height: group.height }
941
+ })), [blockedGroupId, canEditGroups, draftGroup, dropTargetGroupId, groupRenameRequest, localGroups, openContextMenu, props.onRequestUngroup, renameGroup, selectedGroupId, startGroupResize]);
942
+ const flowNodes = useMemo(() => [...graphGroupNodes, ...graphEntityNodes], [graphEntityNodes, graphGroupNodes]);
943
+ const flowEdges = useMemo(() => {
944
+ return groupGraphRelations(props.relations).map((group) => {
945
+ const relationIds = group.relationIds;
946
+ const selected = props.selectedRelationId !== undefined
947
+ && props.selectedRelationId !== null
948
+ && relationIds.includes(props.selectedRelationId);
949
+ const touchesSelected = props.selectedNodeId !== null
950
+ && (group.source === props.selectedNodeId || group.target === props.selectedNodeId);
951
+ const emphasized = selected || touchesSelected;
952
+ const searchMatch = group.relations.some((relation) => relation.searchMatch
953
+ ?? (normalizedQuery !== ""
954
+ && `${relation.label} ${relation.id}`.toLocaleLowerCase().includes(normalizedQuery)));
955
+ const allDisabled = group.relations.every((relation) => relation.disabled === true);
956
+ const pending = group.relations.some((relation) => relation.pending === true);
957
+ const first = group.relations[0];
958
+ const unanimousDirection = group.relations.every((relation) => relation.source === first.source && relation.target === first.target);
959
+ const edgeSource = unanimousDirection ? first.source : group.source;
960
+ const edgeTarget = unanimousDirection ? first.target : group.target;
961
+ const canSelectGroup = props.onSelectRelationGroup !== undefined
962
+ || props.onSelectRelation !== undefined;
963
+ return {
964
+ id: group.pairId,
965
+ source: edgeSource,
966
+ target: edgeTarget,
967
+ type: "sharedGraphEdge",
968
+ selected: emphasized,
969
+ zIndex: emphasized ? 1000 : 0,
970
+ data: {
971
+ label: String(relationIds.length),
972
+ relationIds,
973
+ state: allDisabled ? "disabled" : pending ? "pending" : searchMatch ? "search" : "committed",
974
+ sourceRadius: metricsById.get(edgeSource)?.radius ?? NODE_DIAMETER / 2,
975
+ targetRadius: metricsById.get(edgeTarget)?.radius ?? NODE_DIAMETER / 2,
976
+ emphasized,
977
+ maskId: edgeMaskId,
978
+ occlusionIndex: edgeOcclusionIndex,
979
+ occlusionZoom: edgeOcclusionZoom,
980
+ muted: (props.selectedNodeId !== null || props.selectedRelationId != null) && !emphasized,
981
+ onContextMenu: (event) => {
982
+ if (!props.disabled && (canSelectGroup
983
+ || props.onEditRelationGroup !== undefined
984
+ || props.onRequestDeleteRelationGroup !== undefined)) {
985
+ openContextMenu(event, { kind: "relation", selection: group });
986
+ }
987
+ },
988
+ ...(!canSelectGroup ? {} : {
989
+ onSelect: () => {
990
+ if (props.disabled || allDisabled)
991
+ return;
992
+ if (props.onSelectRelationGroup !== undefined) {
993
+ props.onSelectRelationGroup(group);
994
+ return;
995
+ }
996
+ props.onSelectRelation?.(relationIds[0]);
997
+ }
998
+ })
999
+ }
1000
+ };
1001
+ });
1002
+ }, [edgeMaskId, edgeOcclusionIndex, edgeOcclusionZoom, metricsById, normalizedQuery, openContextMenu, props.disabled, props.onEditRelationGroup, props.onRequestDeleteRelationGroup, props.onSelectRelation, props.onSelectRelationGroup, props.relations, props.selectedNodeId, props.selectedRelationId]);
1003
+ const commit = useCallback((next) => {
1004
+ commitLayout(next);
1005
+ }, [commitLayout]);
1006
+ const force = useCallback(() => {
1007
+ if (!canCommitPositions)
1008
+ return;
1009
+ let solver;
1010
+ try {
1011
+ solver = getLayoutSolver();
1012
+ }
1013
+ catch (error) {
1014
+ reportLayoutError(error);
1015
+ return;
1016
+ }
1017
+ if (solver === null) {
1018
+ commit(forceLayout(props.nodes, positions, props.relations, props.isNameAttrKey));
1019
+ return;
1020
+ }
1021
+ setLayoutError(null);
1022
+ const generation = ++layoutGenerationRef.current;
1023
+ void solver({
1024
+ nodes: props.nodes.map((node) => {
1025
+ const position = positions[node.id] ?? { x: 0, y: 0 };
1026
+ return {
1027
+ id: node.id,
1028
+ x: position.x + CANONICAL_NODE_ANCHOR_RADIUS,
1029
+ y: position.y + CANONICAL_NODE_ANCHOR_RADIUS,
1030
+ radius: metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS
1031
+ };
1032
+ }),
1033
+ edges: props.relations.map((relation) => ({ from: relation.source, to: relation.target }))
1034
+ }).then((centers) => {
1035
+ if (generation !== layoutGenerationRef.current)
1036
+ return;
1037
+ setLayoutError(null);
1038
+ commit(Object.fromEntries(Object.entries(centers).map(([id, center]) => [id, {
1039
+ x: center.x - CANONICAL_NODE_ANCHOR_RADIUS,
1040
+ y: center.y - CANONICAL_NODE_ANCHOR_RADIUS
1041
+ }])));
1042
+ }).catch((error) => {
1043
+ if (error?.name !== "AbortError") {
1044
+ reportLayoutError(error);
1045
+ }
1046
+ });
1047
+ }, [canCommitPositions, commit, getLayoutSolver, metricsById, positions, props.isNameAttrKey, props.nodes, props.relations, reportLayoutError]);
1048
+ const beginGroupDrag = useCallback((node) => {
1049
+ const groupId = graphGroupIdFromNodeId(node.id);
1050
+ if (groupId === null || !canEditGroups)
1051
+ return;
1052
+ const source = localGroupsRef.current.find((group) => group.id === groupId);
1053
+ if (source === undefined)
1054
+ return;
1055
+ const sourcePositions = positionsRef.current;
1056
+ groupDragSessionRef.current = {
1057
+ groupId,
1058
+ source,
1059
+ groups: localGroupsRef.current,
1060
+ positions: sourcePositions,
1061
+ memberIds: nodesInsideGraphGroup(source, Object.fromEntries(Object.entries(sourcePositions).map(([id, position]) => [id, canonicalCenter(position)]))),
1062
+ changed: false
1063
+ };
1064
+ setSelectedGroupId(groupId);
1065
+ setBlockedGroupId(null);
1066
+ }, [canEditGroups]);
1067
+ const moveGroup = useCallback((node) => {
1068
+ const groupId = graphGroupIdFromNodeId(node.id);
1069
+ if (groupId === null)
1070
+ return;
1071
+ if (groupDragSessionRef.current === null)
1072
+ beginGroupDrag(node);
1073
+ const session = groupDragSessionRef.current;
1074
+ if (session === null || session.groupId !== groupId)
1075
+ return;
1076
+ const requested = {
1077
+ x: node.position.x - session.source.x,
1078
+ y: node.position.y - session.source.y
1079
+ };
1080
+ const groupSafe = clampGraphGroupTranslation(session.source, requested, session.groups, groupId);
1081
+ const nodeSafe = clampGroupMemberTranslation(groupSafe.delta, session.memberIds, session.positions, metricsByIdRef.current);
1082
+ const nextGroup = {
1083
+ ...session.source,
1084
+ x: session.source.x + nodeSafe.x,
1085
+ y: session.source.y + nodeSafe.y
1086
+ };
1087
+ const nextGroups = session.groups.map((group) => group.id === groupId ? nextGroup : group);
1088
+ const memberSet = new Set(session.memberIds);
1089
+ const nextPositions = Object.fromEntries(Object.entries(session.positions).map(([id, position]) => [
1090
+ id,
1091
+ memberSet.has(id)
1092
+ ? { x: position.x + nodeSafe.x, y: position.y + nodeSafe.y }
1093
+ : position
1094
+ ]));
1095
+ session.changed = !samePosition(nodeSafe, { x: 0, y: 0 });
1096
+ localGroupsRef.current = nextGroups;
1097
+ positionsRef.current = nextPositions;
1098
+ setLocalGroups(nextGroups);
1099
+ setLocalPositions(nextPositions);
1100
+ setBlockedGroupId(samePosition(nodeSafe, requested) ? null : groupId);
1101
+ }, [beginGroupDrag]);
1102
+ const finishGroupDrag = useCallback((node) => {
1103
+ const groupId = graphGroupIdFromNodeId(node.id);
1104
+ if (groupId === null)
1105
+ return;
1106
+ moveGroup(node);
1107
+ const session = groupDragSessionRef.current;
1108
+ groupDragSessionRef.current = null;
1109
+ setBlockedGroupId(null);
1110
+ if (session?.groupId === groupId && session.changed) {
1111
+ commitLayout(positionsRef.current, localGroupsRef.current);
1112
+ }
1113
+ }, [commitLayout, moveGroup]);
1114
+ useEffect(() => {
1115
+ const move = (event) => {
1116
+ const draw = groupDrawSessionRef.current;
1117
+ if (draw !== null && event.pointerId === draw.pointerId) {
1118
+ event.preventDefault();
1119
+ const end = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
1120
+ const bounds = graphGroupBoundsFromPoints(draw.start, end);
1121
+ const blocked = !isGraphGroupBoundsValid(bounds, localGroupsRef.current);
1122
+ const nextDraft = {
1123
+ id: "__draft__group",
1124
+ label: "新分组",
1125
+ ...bounds,
1126
+ blocked
1127
+ };
1128
+ draftGroupRef.current = nextDraft;
1129
+ setDraftGroup(nextDraft);
1130
+ }
1131
+ const resize = groupResizeSessionRef.current;
1132
+ if (resize === null)
1133
+ return;
1134
+ event.preventDefault();
1135
+ const point = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
1136
+ const coordinate = resize.side === "left" || resize.side === "right" ? point.x : point.y;
1137
+ const unconstrained = resizeGraphGroupSide(resize.source, resize.side, coordinate, [], resize.groupId);
1138
+ const nextBounds = resizeGraphGroupSide(resize.source, resize.side, coordinate, resize.groups, resize.groupId);
1139
+ const nextGroup = { ...resize.source, ...nextBounds };
1140
+ const nextGroups = resize.groups.map((group) => group.id === resize.groupId ? nextGroup : group);
1141
+ resize.changed = nextBounds.x !== resize.source.x
1142
+ || nextBounds.y !== resize.source.y
1143
+ || nextBounds.width !== resize.source.width
1144
+ || nextBounds.height !== resize.source.height;
1145
+ localGroupsRef.current = nextGroups;
1146
+ setLocalGroups(nextGroups);
1147
+ setBlockedGroupId(nextBounds.x === unconstrained.x
1148
+ && nextBounds.y === unconstrained.y
1149
+ && nextBounds.width === unconstrained.width
1150
+ && nextBounds.height === unconstrained.height
1151
+ ? null
1152
+ : resize.groupId);
1153
+ };
1154
+ const finish = (event) => {
1155
+ const draw = groupDrawSessionRef.current;
1156
+ if (draw !== null && event.pointerId === draw.pointerId) {
1157
+ groupDrawSessionRef.current = null;
1158
+ const candidate = draftGroupRef.current;
1159
+ draftGroupRef.current = null;
1160
+ setDraftGroup(null);
1161
+ setDrawingGroup(false);
1162
+ if (candidate !== null && !candidate.blocked
1163
+ && isGraphGroupBoundsValid(candidate, localGroupsRef.current)) {
1164
+ const created = {
1165
+ id: nextGraphGroupId(localGroupsRef.current),
1166
+ label: `组 ${localGroupsRef.current.length + 1}`,
1167
+ x: candidate.x,
1168
+ y: candidate.y,
1169
+ width: candidate.width,
1170
+ height: candidate.height
1171
+ };
1172
+ const nextGroups = [...localGroupsRef.current, created];
1173
+ localGroupsRef.current = nextGroups;
1174
+ setLocalGroups(nextGroups);
1175
+ setSelectedGroupId(created.id);
1176
+ commitLayout(positionsRef.current, nextGroups);
1177
+ }
1178
+ }
1179
+ const resize = groupResizeSessionRef.current;
1180
+ if (resize === null)
1181
+ return;
1182
+ groupResizeSessionRef.current = null;
1183
+ setBlockedGroupId(null);
1184
+ if (resize.changed)
1185
+ commitLayout(positionsRef.current, localGroupsRef.current);
1186
+ };
1187
+ window.addEventListener("pointermove", move, { passive: false });
1188
+ window.addEventListener("pointerup", finish);
1189
+ return () => {
1190
+ window.removeEventListener("pointermove", move);
1191
+ window.removeEventListener("pointerup", finish);
1192
+ };
1193
+ }, [commitLayout, flow]);
1194
+ const startGroupDrawing = useCallback((event) => {
1195
+ if (!drawingGroup || !canEditGroups || event.button !== 0)
1196
+ return;
1197
+ const target = event.target;
1198
+ if (target.closest(".react-flow__pane") === null)
1199
+ return;
1200
+ event.preventDefault();
1201
+ event.stopPropagation();
1202
+ const start = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
1203
+ groupDrawSessionRef.current = { pointerId: event.pointerId, start };
1204
+ const nextDraft = {
1205
+ id: "__draft__group",
1206
+ label: "新分组",
1207
+ x: start.x,
1208
+ y: start.y,
1209
+ width: 0,
1210
+ height: 0,
1211
+ blocked: true
1212
+ };
1213
+ draftGroupRef.current = nextDraft;
1214
+ setDraftGroup(nextDraft);
1215
+ }, [canEditGroups, drawingGroup, flow]);
1216
+ const updateNodeDropTarget = useCallback((node) => {
1217
+ const source = nodesRef.current.find((candidate) => candidate.id === node.id);
1218
+ if (props.disabled || source?.disabled === true
1219
+ || interactionDisabledByIdRef.current.get(node.id) !== false) {
1220
+ setDropTargetGroupId(null);
1221
+ return;
1222
+ }
1223
+ const radius = metricsByIdRef.current.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
1224
+ const center = { x: node.position.x + radius, y: node.position.y + radius };
1225
+ const target = localGroupsRef.current.find((group) => center.x >= group.x
1226
+ && center.x < group.x + group.width
1227
+ && center.y >= group.y
1228
+ && center.y < group.y + group.height);
1229
+ setDropTargetGroupId((current) => current === target?.id ? current : target?.id ?? null);
1230
+ }, [props.disabled]);
1231
+ const contextMenuActions = useMemo(() => {
1232
+ if (contextMenu === null)
1233
+ return [];
1234
+ const target = contextMenu.target;
1235
+ if (target.kind === "pane") {
1236
+ return [
1237
+ ...(!props.disabled && props.onCreateNodeAt !== undefined ? [{
1238
+ id: "create-node",
1239
+ label: "新建节点",
1240
+ run: () => props.onCreateNodeAt?.(target.position)
1241
+ }] : []),
1242
+ {
1243
+ id: "fit-view",
1244
+ label: "适合视图",
1245
+ run: () => { void flow.fitView({ padding: 0.16, duration: 200 }); }
1246
+ },
1247
+ ...(canCommitPositions ? [{ id: "force-layout", label: "自动布局", run: force }] : [])
1248
+ ];
1249
+ }
1250
+ if (props.disabled)
1251
+ return [];
1252
+ if (target.kind === "node") {
1253
+ return [
1254
+ { id: "view-node", label: "查看节点", run: () => props.onSelectNode(target.nodeId) },
1255
+ ...(props.onEditNode === undefined ? [] : [{ id: "edit-node", label: "编辑节点", run: () => props.onEditNode?.(target.nodeId) }]),
1256
+ ...(props.onCreateRelationFromNode === undefined ? [] : [{ id: "create-relation", label: "新建关系", run: () => props.onCreateRelationFromNode?.(target.nodeId) }]),
1257
+ ...(props.onRequestDeleteNode === undefined ? [] : [{
1258
+ id: "delete-node",
1259
+ label: "删除节点",
1260
+ danger: true,
1261
+ run: () => props.onRequestDeleteNode?.(target.nodeId)
1262
+ }])
1263
+ ];
1264
+ }
1265
+ if (target.kind === "relation") {
1266
+ const view = props.onSelectRelationGroup !== undefined
1267
+ ? () => props.onSelectRelationGroup?.(target.selection)
1268
+ : props.onSelectRelation !== undefined
1269
+ ? () => props.onSelectRelation?.(target.selection.relationIds[0])
1270
+ : null;
1271
+ return [
1272
+ ...(view === null ? [] : [{ id: "view-relation", label: "查看关系", run: view }]),
1273
+ ...(props.onEditRelationGroup === undefined ? [] : [{ id: "edit-relation", label: "编辑关系", run: () => props.onEditRelationGroup?.(target.selection) }]),
1274
+ ...(props.onRequestDeleteRelationGroup === undefined ? [] : [{
1275
+ id: "delete-relation",
1276
+ label: "删除关系",
1277
+ danger: true,
1278
+ run: () => props.onRequestDeleteRelationGroup?.(target.selection)
1279
+ }])
1280
+ ];
1281
+ }
1282
+ const group = localGroups.find((candidate) => candidate.id === target.groupId);
1283
+ if (group === undefined)
1284
+ return [];
1285
+ return [
1286
+ ...(canEditGroups ? [{
1287
+ id: "rename-group",
1288
+ label: "重命名分组",
1289
+ run: () => {
1290
+ setGroupRenameRequest((current) => ({
1291
+ groupId: group.id,
1292
+ serial: (current?.serial ?? 0) + 1
1293
+ }));
1294
+ }
1295
+ }] : []),
1296
+ ...(props.onRequestUngroup === undefined ? [] : [{
1297
+ id: "ungroup",
1298
+ label: "解除分组",
1299
+ danger: true,
1300
+ run: () => props.onRequestUngroup?.(group)
1301
+ }])
1302
+ ];
1303
+ }, [canCommitPositions, canEditGroups, contextMenu, flow, force, localGroups, props.disabled, props.onCreateNodeAt, props.onCreateRelationFromNode, props.onEditNode, props.onEditRelationGroup, props.onRequestDeleteNode, props.onRequestDeleteRelationGroup, props.onRequestUngroup, props.onSelectNode, props.onSelectRelation, props.onSelectRelationGroup]);
1304
+ return _jsxs("section", { "aria-label": "\u5171\u4EAB GraphCanvas", className: "shared-graph-canvas", "data-testid": "shared-graph-canvas", "data-we-graph-group-drawing": String(drawingGroup), "data-we-graph-distant": String(labelZoomRef.current < DISTANT_LABEL_ZOOM_THRESHOLD), ref: canvasRef, onPointerDownCapture: startGroupDrawing, style: {
1305
+ "--we-graph-zoom": labelZoomRef.current,
1306
+ "--we-graph-inverse-zoom": 1 / labelZoomRef.current
1307
+ }, children: [showToolbar ? _jsxs("div", { className: "shared-graph-canvas__toolbar", children: [_jsx("span", { children: props.searchQuery ? `搜索:${props.searchQuery}` : `${props.nodes.length} 节点 · ${props.relations.length} 关系` }), _jsx("span", { "aria-live": "polite", "data-relation-capability": canCreateRelation ? "enabled" : "unavailable", children: canCreateRelation ? "关系拖拽已启用" : "关系编辑未配置" })] }) : null, layoutError === null ? null : _jsxs("div", { className: "shared-graph-canvas__error", role: "alert", children: ["\u56FE\u5E03\u5C40\u5931\u8D25\uFF1A", layoutError] }), _jsx("div", { className: "shared-graph-canvas__flow", children: _jsxs(ReactFlow, { edges: flowEdges, edgeTypes: edgeTypes, elevateEdgesOnSelect: true, fitView: props.viewport === undefined, maxZoom: 4, minZoom: 0.15, nodes: flowNodes, nodeDragThreshold: NODE_DRAG_THRESHOLD_PX, nodeTypes: graphNodeTypes, onInit: setFlowInstance, onlyRenderVisibleElements: props.onlyRenderVisibleElements ?? false, onConnect: (connection) => {
1308
+ if (canCreateRelation && connection.source && connection.target) {
1309
+ props.onRelationDragIntent?.(connection.source, connection.target);
1310
+ }
1311
+ }, onNodeDragStart: (_, node) => {
1312
+ closeContextMenu();
1313
+ if (graphGroupIdFromNodeId(node.id) !== null)
1314
+ beginGroupDrag(node);
1315
+ }, onNodeDrag: (_, node) => {
1316
+ if (graphGroupIdFromNodeId(node.id) !== null) {
1317
+ setDropTargetGroupId(null);
1318
+ moveGroup(node);
1319
+ return;
1320
+ }
1321
+ updateNodeDropTarget(node);
1322
+ const radius = metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
1323
+ const maskCircle = maskCircleByNodeIdRef.current.get(node.id);
1324
+ if (maskCircle !== undefined) {
1325
+ maskCircle.setAttribute("cx", String(node.position.x + radius));
1326
+ maskCircle.setAttribute("cy", String(node.position.y + radius));
1327
+ }
1328
+ pendingDragRef.current = {
1329
+ id: node.id,
1330
+ ...canonicalTopLeft(snapToGrid(node.position.x + radius, node.position.y + radius))
1331
+ };
1332
+ if (dragLayoutFrameRef.current !== 0)
1333
+ return;
1334
+ dragLayoutFrameRef.current = window.requestAnimationFrame(() => {
1335
+ dragLayoutFrameRef.current = 0;
1336
+ solveDraggedLayout(false);
1337
+ });
1338
+ }, onNodeDragStop: (_, node) => {
1339
+ setDropTargetGroupId(null);
1340
+ if (graphGroupIdFromNodeId(node.id) !== null) {
1341
+ finishGroupDrag(node);
1342
+ return;
1343
+ }
1344
+ if (!canCommitPositions)
1345
+ return;
1346
+ if (dragLayoutFrameRef.current !== 0) {
1347
+ window.cancelAnimationFrame(dragLayoutFrameRef.current);
1348
+ dragLayoutFrameRef.current = 0;
1349
+ }
1350
+ const radius = metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
1351
+ const previous = positions[node.id];
1352
+ const rawCanonical = {
1353
+ x: node.position.x + radius - CANONICAL_NODE_ANCHOR_RADIUS,
1354
+ y: node.position.y + radius - CANONICAL_NODE_ANCHOR_RADIUS
1355
+ };
1356
+ if (previous !== undefined && samePosition(rawCanonical, previous)) {
1357
+ pendingDragRef.current = null;
1358
+ layoutGenerationRef.current += 1;
1359
+ return;
1360
+ }
1361
+ const snappedCenter = snapToGrid(node.position.x + radius, node.position.y + radius);
1362
+ const otherFootprints = props.nodes.flatMap((candidate) => {
1363
+ if (candidate.id === node.id)
1364
+ return [];
1365
+ const position = positions[candidate.id];
1366
+ if (position === undefined)
1367
+ return [];
1368
+ return [{
1369
+ ...canonicalCenter(position),
1370
+ radius: metricsById.get(candidate.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS
1371
+ }];
1372
+ });
1373
+ const safeCenter = tryFindGridSlot({ ...snappedCenter, radius }, otherFootprints) ?? snappedCenter;
1374
+ const safePosition = canonicalTopLeft(safeCenter);
1375
+ if (previous !== undefined
1376
+ && Math.abs(safePosition.x - previous.x) < POSITION_NOOP_EPSILON_PX
1377
+ && Math.abs(safePosition.y - previous.y) < POSITION_NOOP_EPSILON_PX) {
1378
+ pendingDragRef.current = null;
1379
+ layoutGenerationRef.current += 1;
1380
+ return;
1381
+ }
1382
+ const immediate = {
1383
+ ...positions,
1384
+ [node.id]: {
1385
+ x: Math.round(safePosition.x),
1386
+ y: Math.round(safePosition.y)
1387
+ }
1388
+ };
1389
+ setLocalPositions(immediate);
1390
+ pendingDragRef.current = {
1391
+ id: node.id,
1392
+ x: immediate[node.id].x,
1393
+ y: immediate[node.id].y
1394
+ };
1395
+ let solverAvailable;
1396
+ try {
1397
+ solverAvailable = getLayoutSolver();
1398
+ }
1399
+ catch (error) {
1400
+ reportLayoutError(error);
1401
+ return;
1402
+ }
1403
+ if (solverAvailable === null) {
1404
+ commitLayout(immediate);
1405
+ return;
1406
+ }
1407
+ solveDraggedLayout(true);
1408
+ }, onNodeDoubleClick: (_, node) => {
1409
+ const radius = metricsById.get(node.id)?.radius ?? CANONICAL_NODE_ANCHOR_RADIUS;
1410
+ void flow.setCenter(node.position.x + radius, node.position.y + radius, { zoom: Math.max(flow.getZoom(), 1), duration: 220 });
1411
+ props.onNodeDoubleClick?.(node.id);
1412
+ }, onMove: (_, viewport) => updateLabelZoom(viewport.zoom), onMoveEnd: (_, viewport) => {
1413
+ updateLabelZoom(viewport.zoom);
1414
+ setEdgeOcclusionZoom((current) => current === viewport.zoom ? current : viewport.zoom);
1415
+ notifyViewportChange(viewport);
1416
+ }, onPaneClick: () => {
1417
+ closeContextMenu();
1418
+ if (!props.disabled)
1419
+ props.onClearSelection?.();
1420
+ }, onPaneContextMenu: (event) => {
1421
+ openContextMenu(event, {
1422
+ kind: "pane",
1423
+ position: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY })
1424
+ });
1425
+ }, nodesConnectable: canCreateRelation, nodesDraggable: canCommitPositions, panOnDrag: !drawingGroup, ...(props.viewport === undefined ? {} : { defaultViewport: props.viewport }), children: [_jsx("svg", { "aria-hidden": "true", className: "shared-graph-mask-defs", children: _jsx("defs", { children: _jsxs("mask", { id: edgeMaskId, maskUnits: "userSpaceOnUse", ...edgeMaskBounds, children: [_jsx("rect", { ...edgeMaskBounds, fill: "white" }), edgeOccluders.map((occluder) => _jsx("circle", { cx: occluder.x, cy: occluder.y, fill: "black", r: occluder.radius + GRAPH_EDGE_OCCLUSION_HALO, ref: (element) => {
1426
+ if (element === null)
1427
+ maskCircleByNodeIdRef.current.delete(occluder.id);
1428
+ else
1429
+ maskCircleByNodeIdRef.current.set(occluder.id, element);
1430
+ } }, occluder.id))] }) }) }), _jsx(Background, { gap: 18, size: 1 }), _jsx(CanvasControls, { canCommit: canCommitPositions, canCreateGroup: canEditGroups, drawingGroup: drawingGroup, onForce: force, onToggleGroup: () => {
1431
+ groupDrawSessionRef.current = null;
1432
+ draftGroupRef.current = null;
1433
+ setDraftGroup(null);
1434
+ setDrawingGroup((current) => !current);
1435
+ } })] }) }), relationDragGeometry === null ? null : _jsx("svg", { "aria-hidden": "true", className: "shared-graph-relation-drag", children: _jsx("path", { d: relationDragGeometry.path, fill: "none" }) }), contextMenu === null ? null : _jsx(GraphContextMenu, { actions: contextMenuActions, clientX: contextMenu.clientX, clientY: contextMenu.clientY, onClose: closeContextMenu })] });
1436
+ }
1437
+ export function SharedGraphCanvas(props) {
1438
+ return _jsx(ReactFlowProvider, { children: _jsx(GraphCanvasInner, { ...props }) });
1439
+ }