@abraca/nuxt 0.2.0 → 0.3.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 (152) hide show
  1. package/dist/module.d.mts +46 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +95 -2
  4. package/dist/runtime/assets/editor.css +1 -0
  5. package/dist/runtime/components/ACommandPalette.vue +4 -1
  6. package/dist/runtime/components/ADocRenderer.d.vue.ts +29 -0
  7. package/dist/runtime/components/ADocRenderer.vue +99 -0
  8. package/dist/runtime/components/ADocRenderer.vue.d.ts +29 -0
  9. package/dist/runtime/components/ADocTypeSelect.vue +4 -1
  10. package/dist/runtime/components/ADocumentTree.vue +78 -19
  11. package/dist/runtime/components/AEditor.d.vue.ts +9 -4
  12. package/dist/runtime/components/AEditor.vue +102 -7
  13. package/dist/runtime/components/AEditor.vue.d.ts +9 -4
  14. package/dist/runtime/components/AIconPicker.vue +8 -2
  15. package/dist/runtime/components/ANodePanel.vue +100 -61
  16. package/dist/runtime/components/ANotifications.vue +35 -8
  17. package/dist/runtime/components/APermissionGuard.vue +3 -1
  18. package/dist/runtime/components/APresence.vue +14 -3
  19. package/dist/runtime/components/AProvider.vue +7 -1
  20. package/dist/runtime/components/AVoiceBar.vue +57 -15
  21. package/dist/runtime/components/AVoiceTile.vue +4 -1
  22. package/dist/runtime/components/aware/AArea.vue +1 -1
  23. package/dist/runtime/components/aware/AAvatar.vue +85 -16
  24. package/dist/runtime/components/aware/AButton.vue +5 -1
  25. package/dist/runtime/components/aware/ACursorLabel.vue +5 -1
  26. package/dist/runtime/components/aware/ADocBadge.vue +4 -1
  27. package/dist/runtime/components/aware/AFacepile.vue +13 -3
  28. package/dist/runtime/components/aware/AInput.vue +5 -1
  29. package/dist/runtime/components/aware/ATextarea.vue +5 -1
  30. package/dist/runtime/components/aware/AUserList.vue +8 -2
  31. package/dist/runtime/components/renderers/ACalendarRenderer.d.vue.ts +12 -1
  32. package/dist/runtime/components/renderers/ACalendarRenderer.vue +388 -114
  33. package/dist/runtime/components/renderers/ACalendarRenderer.vue.d.ts +12 -1
  34. package/dist/runtime/components/renderers/ACallRenderer.d.vue.ts +13 -0
  35. package/dist/runtime/components/renderers/ACallRenderer.vue +169 -0
  36. package/dist/runtime/components/renderers/ACallRenderer.vue.d.ts +13 -0
  37. package/dist/runtime/components/renderers/AChecklistRenderer.d.vue.ts +19 -0
  38. package/dist/runtime/components/renderers/AChecklistRenderer.vue +581 -0
  39. package/dist/runtime/components/renderers/AChecklistRenderer.vue.d.ts +19 -0
  40. package/dist/runtime/components/renderers/ADashboardRenderer.d.vue.ts +19 -0
  41. package/dist/runtime/components/renderers/ADashboardRenderer.vue +1372 -0
  42. package/dist/runtime/components/renderers/ADashboardRenderer.vue.d.ts +19 -0
  43. package/dist/runtime/components/renderers/AGalleryCoverImage.d.vue.ts +8 -0
  44. package/dist/runtime/components/renderers/AGalleryCoverImage.vue +60 -0
  45. package/dist/runtime/components/renderers/AGalleryCoverImage.vue.d.ts +8 -0
  46. package/dist/runtime/components/renderers/AGalleryRenderer.d.vue.ts +12 -1
  47. package/dist/runtime/components/renderers/AGalleryRenderer.vue +221 -55
  48. package/dist/runtime/components/renderers/AGalleryRenderer.vue.d.ts +12 -1
  49. package/dist/runtime/components/renderers/AGraphRenderer.d.vue.ts +19 -0
  50. package/dist/runtime/components/renderers/AGraphRenderer.vue +1027 -0
  51. package/dist/runtime/components/renderers/AGraphRenderer.vue.d.ts +19 -0
  52. package/dist/runtime/components/renderers/AKanbanRenderer.d.vue.ts +13 -1
  53. package/dist/runtime/components/renderers/AKanbanRenderer.vue +474 -140
  54. package/dist/runtime/components/renderers/AKanbanRenderer.vue.d.ts +13 -1
  55. package/dist/runtime/components/renderers/AMapRenderer.d.vue.ts +19 -0
  56. package/dist/runtime/components/renderers/AMapRenderer.vue +1622 -0
  57. package/dist/runtime/components/renderers/AMapRenderer.vue.d.ts +19 -0
  58. package/dist/runtime/components/renderers/AOutlineRenderer.d.vue.ts +12 -1
  59. package/dist/runtime/components/renderers/AOutlineRenderer.vue +294 -134
  60. package/dist/runtime/components/renderers/AOutlineRenderer.vue.d.ts +12 -1
  61. package/dist/runtime/components/renderers/ATableRenderer.d.vue.ts +12 -1
  62. package/dist/runtime/components/renderers/ATableRenderer.vue +437 -145
  63. package/dist/runtime/components/renderers/ATableRenderer.vue.d.ts +12 -1
  64. package/dist/runtime/components/renderers/ATimelineRenderer.d.vue.ts +19 -0
  65. package/dist/runtime/components/renderers/ATimelineRenderer.vue +446 -0
  66. package/dist/runtime/components/renderers/ATimelineRenderer.vue.d.ts +19 -0
  67. package/dist/runtime/composables/useAwareness.js +5 -0
  68. package/dist/runtime/composables/useBroadcastSync.d.ts +18 -0
  69. package/dist/runtime/composables/useBroadcastSync.js +26 -0
  70. package/dist/runtime/composables/useChat.js +4 -2
  71. package/dist/runtime/composables/useChatUsers.js +2 -1
  72. package/dist/runtime/composables/useCommandPalette.js +62 -3
  73. package/dist/runtime/composables/useConnectionStatus.js +7 -0
  74. package/dist/runtime/composables/useDevicePairing.d.ts +58 -0
  75. package/dist/runtime/composables/useDevicePairing.js +108 -0
  76. package/dist/runtime/composables/useDocExport.d.ts +5 -0
  77. package/dist/runtime/composables/useDocExport.js +2 -2
  78. package/dist/runtime/composables/useDocImport.js +4 -3
  79. package/dist/runtime/composables/useDocSeo.d.ts +20 -0
  80. package/dist/runtime/composables/useDocSeo.js +44 -0
  81. package/dist/runtime/composables/useDocSlugs.d.ts +7 -0
  82. package/dist/runtime/composables/useDocSlugs.js +20 -0
  83. package/dist/runtime/composables/useDocTree.d.ts +34 -0
  84. package/dist/runtime/composables/useDocTree.js +35 -0
  85. package/dist/runtime/composables/useEditorDragHandle.js +2 -1
  86. package/dist/runtime/composables/useEditorMentions.js +4 -2
  87. package/dist/runtime/composables/useEditorSuggestions.d.ts +1 -0
  88. package/dist/runtime/composables/useEditorSuggestions.js +9 -2
  89. package/dist/runtime/composables/useEditorToolbar.js +2 -1
  90. package/dist/runtime/composables/useFileIndex.js +2 -1
  91. package/dist/runtime/composables/useFileTransfer.d.ts +112 -0
  92. package/dist/runtime/composables/useFileTransfer.js +171 -0
  93. package/dist/runtime/composables/useFollowUser.js +2 -1
  94. package/dist/runtime/composables/useInvites.d.ts +56 -0
  95. package/dist/runtime/composables/useInvites.js +77 -0
  96. package/dist/runtime/composables/useNodePanel.d.ts +14 -0
  97. package/dist/runtime/composables/useNodePanel.js +52 -0
  98. package/dist/runtime/composables/useNotifications.js +4 -2
  99. package/dist/runtime/composables/usePasskeyAccounts.js +4 -2
  100. package/dist/runtime/composables/useSearchIndex.d.ts +1 -0
  101. package/dist/runtime/composables/useSearchIndex.js +13 -5
  102. package/dist/runtime/composables/useServerInfo.d.ts +31 -0
  103. package/dist/runtime/composables/useServerInfo.js +80 -0
  104. package/dist/runtime/composables/useSlugRoute.d.ts +6 -0
  105. package/dist/runtime/composables/useSlugRoute.js +19 -0
  106. package/dist/runtime/composables/useSpaces.d.ts +37 -0
  107. package/dist/runtime/composables/useSpaces.js +83 -0
  108. package/dist/runtime/composables/useTouchDrag.d.ts +34 -0
  109. package/dist/runtime/composables/useTouchDrag.js +191 -0
  110. package/dist/runtime/composables/useTrash.d.ts +1 -1
  111. package/dist/runtime/composables/useTrash.js +6 -3
  112. package/dist/runtime/composables/useWebRTC.d.ts +50 -0
  113. package/dist/runtime/composables/useWebRTC.js +177 -0
  114. package/dist/runtime/extensions/meta-field.d.ts +4 -1
  115. package/dist/runtime/extensions/steps.js +1 -1
  116. package/dist/runtime/extensions/views/AccordionItemView.vue +13 -3
  117. package/dist/runtime/extensions/views/AccordionView.vue +4 -1
  118. package/dist/runtime/extensions/views/BadgeView.vue +11 -2
  119. package/dist/runtime/extensions/views/CalloutView.vue +4 -1
  120. package/dist/runtime/extensions/views/CardGroupView.vue +4 -1
  121. package/dist/runtime/extensions/views/CardView.vue +17 -3
  122. package/dist/runtime/extensions/views/CodeGroupView.vue +4 -1
  123. package/dist/runtime/extensions/views/CollapsibleView.vue +8 -2
  124. package/dist/runtime/extensions/views/FileNodeView.vue +32 -8
  125. package/dist/runtime/extensions/views/KbdView.vue +8 -2
  126. package/dist/runtime/extensions/views/MetaFieldView.vue +208 -46
  127. package/dist/runtime/extensions/views/ProseIconView.vue +8 -2
  128. package/dist/runtime/extensions/views/TabsView.vue +17 -4
  129. package/dist/runtime/locale.d.ts +71 -0
  130. package/dist/runtime/locale.js +71 -0
  131. package/dist/runtime/plugin-abracadabra.client.js +29 -3
  132. package/dist/runtime/plugin-abracadabra.server.js +2 -0
  133. package/dist/runtime/server/api/_abracadabra/render/[docId].get.d.ts +1 -1
  134. package/dist/runtime/server/api/_abracadabra/render/[docId].get.js +29 -4
  135. package/dist/runtime/server/api/_abracadabra/resolve/[...slug].get.d.ts +2 -0
  136. package/dist/runtime/server/api/_abracadabra/resolve/[...slug].get.js +43 -0
  137. package/dist/runtime/server/api/_abracadabra/slugs.get.d.ts +2 -0
  138. package/dist/runtime/server/api/_abracadabra/slugs.get.js +7 -0
  139. package/dist/runtime/server/plugins/abracadabra-service.js +10 -5
  140. package/dist/runtime/server/runners/doc-tree-cache.js +4 -0
  141. package/dist/runtime/server/utils/slugMap.d.ts +32 -0
  142. package/dist/runtime/server/utils/slugMap.js +58 -0
  143. package/dist/runtime/types.d.ts +1 -0
  144. package/dist/runtime/utils/docTypes.d.ts +29 -1
  145. package/dist/runtime/utils/docTypes.js +129 -1
  146. package/dist/runtime/utils/markdownToYjs.js +2 -2
  147. package/dist/runtime/utils/sdkRef.d.ts +2 -0
  148. package/dist/runtime/utils/sdkRef.js +7 -0
  149. package/dist/runtime/utils/slugify.d.ts +40 -0
  150. package/dist/runtime/utils/slugify.js +36 -0
  151. package/dist/types.d.mts +6 -0
  152. package/package.json +32 -19
@@ -0,0 +1,1027 @@
1
+ <script setup>
2
+ import { ref, computed, watch, nextTick, onBeforeUnmount } from "vue";
3
+ import { useRafFn, useEventListener } from "@vueuse/core";
4
+ import { useRendererBase } from "../../composables/useRendererBase";
5
+ import { useNodePanel } from "../../composables/useNodePanel";
6
+ import { DEFAULT_LOCALE } from "../../locale";
7
+ const props = defineProps({
8
+ docId: { type: String, required: true },
9
+ childProvider: { type: null, required: true },
10
+ docLabel: { type: String, required: true },
11
+ pageTypes: { type: Array, required: false },
12
+ labels: { type: Object, required: false },
13
+ editable: { type: Boolean, required: false, default: true }
14
+ });
15
+ const config = useRuntimeConfig();
16
+ const locale = computed(() => ({
17
+ ...DEFAULT_LOCALE.renderers.graph,
18
+ ...config.public?.abracadabra?.locale?.renderers?.graph ?? {},
19
+ ...props.labels ?? {}
20
+ }));
21
+ const { tree, childDoc, childProviderRef, cursors, states, setLocalState, connectedUsers } = useRendererBase(props);
22
+ const {
23
+ openNodeId,
24
+ openNodeLabel,
25
+ openNodeProvider,
26
+ openNode,
27
+ closePanel
28
+ } = useNodePanel(childProviderRef);
29
+ const d3 = ref(null);
30
+ const d3Error = ref(false);
31
+ onMounted(async () => {
32
+ try {
33
+ d3.value = await import("d3-force");
34
+ } catch {
35
+ d3Error.value = true;
36
+ }
37
+ });
38
+ watch(childDoc, (doc) => {
39
+ if (!doc) return;
40
+ const legacyMap = doc.getMap("graph-positions");
41
+ legacyMap.forEach((val, id) => {
42
+ const entry = tree.treeMap.get(id);
43
+ if (entry && entry.meta?.graphX === void 0) {
44
+ tree.updateMeta(id, {
45
+ graphX: val.x ?? 0,
46
+ graphY: val.y ?? 0,
47
+ graphPinned: val.pinned ?? false
48
+ });
49
+ }
50
+ });
51
+ }, { immediate: true });
52
+ function getGraphPos(id) {
53
+ const entry = tree.entries.value.find((e) => e.id === id);
54
+ if (entry?.meta?.graphX !== void 0 && entry?.meta?.graphY !== void 0) {
55
+ return {
56
+ x: entry.meta.graphX,
57
+ y: entry.meta.graphY,
58
+ pinned: entry.meta.graphPinned ?? false
59
+ };
60
+ }
61
+ return void 0;
62
+ }
63
+ const svgRef = ref(null);
64
+ const vb = ref({ x: -500, y: -350, w: 1e3, h: 700 });
65
+ const targetVb = ref({ x: -500, y: -350, w: 1e3, h: 700 });
66
+ let zoomRafId = null;
67
+ function animateZoom() {
68
+ const f = 0.15;
69
+ const nw = vb.value.w + (targetVb.value.w - vb.value.w) * f;
70
+ const nh = vb.value.h + (targetVb.value.h - vb.value.h) * f;
71
+ const nx = vb.value.x + (targetVb.value.x - vb.value.x) * f;
72
+ const ny = vb.value.y + (targetVb.value.y - vb.value.y) * f;
73
+ if (Math.abs(nw - targetVb.value.w) < 0.5) {
74
+ vb.value = { ...targetVb.value };
75
+ zoomRafId = null;
76
+ return;
77
+ }
78
+ vb.value = { x: nx, y: ny, w: nw, h: nh };
79
+ zoomRafId = requestAnimationFrame(animateZoom);
80
+ }
81
+ function svgCoord(clientX, clientY) {
82
+ if (!svgRef.value) return { x: 0, y: 0 };
83
+ const rect = svgRef.value.getBoundingClientRect();
84
+ return {
85
+ x: (clientX - rect.left) / rect.width * vb.value.w + vb.value.x,
86
+ y: (clientY - rect.top) / rect.height * vb.value.h + vb.value.y
87
+ };
88
+ }
89
+ function onWheel(e) {
90
+ e.preventDefault();
91
+ const sensitivity = e.ctrlKey ? 8e-3 : 25e-4;
92
+ const delta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
93
+ const scale = 1 + delta * sensitivity;
94
+ const rect = svgRef.value.getBoundingClientRect();
95
+ const tv = targetVb.value;
96
+ const px = (e.clientX - rect.left) / rect.width * tv.w + tv.x;
97
+ const py = (e.clientY - rect.top) / rect.height * tv.h + tv.y;
98
+ targetVb.value = {
99
+ x: px - (px - tv.x) * scale,
100
+ y: py - (py - tv.y) * scale,
101
+ w: Math.max(100, Math.min(8e3, tv.w * scale)),
102
+ h: Math.max(100, Math.min(8e3, tv.h * scale))
103
+ };
104
+ if (!zoomRafId) zoomRafId = requestAnimationFrame(animateZoom);
105
+ }
106
+ const activePointers = /* @__PURE__ */ new Map();
107
+ let isPinching = false;
108
+ let pinchStart = null;
109
+ const isPanning = ref(false);
110
+ const panStart = ref({ ex: 0, ey: 0, vx: 0, vy: 0 });
111
+ function onSvgPointerDown(e) {
112
+ if (e.pointerType === "mouse" && e.button !== 0) return;
113
+ activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
114
+ e.currentTarget.setPointerCapture(e.pointerId);
115
+ if (activePointers.size === 1) {
116
+ isPanning.value = true;
117
+ isPinching = false;
118
+ panStart.value = { ex: e.clientX, ey: e.clientY, vx: targetVb.value.x, vy: targetVb.value.y };
119
+ } else if (activePointers.size === 2) {
120
+ isPanning.value = false;
121
+ isPinching = true;
122
+ const pts = [...activePointers.values()];
123
+ const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
124
+ pinchStart = {
125
+ dist: Math.max(dist, 1),
126
+ vb: { ...vb.value },
127
+ mid: { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 }
128
+ };
129
+ }
130
+ }
131
+ function onSvgPointerMove(e) {
132
+ if (!activePointers.has(e.pointerId)) return;
133
+ activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
134
+ if (!draggingId.value && activePointers.size === 1) {
135
+ setLocalState({ pos: svgCoord(e.clientX, e.clientY) });
136
+ }
137
+ if (isPinching && activePointers.size >= 2 && pinchStart) {
138
+ const pts = [...activePointers.values()];
139
+ const currentDist = Math.max(Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), 1);
140
+ const scale = currentDist / pinchStart.dist;
141
+ const newW = Math.max(100, Math.min(8e3, pinchStart.vb.w / scale));
142
+ const newH = Math.max(100, Math.min(8e3, pinchStart.vb.h / scale));
143
+ const rect2 = svgRef.value.getBoundingClientRect();
144
+ const startMidSvgX = (pinchStart.mid.x - rect2.left) / rect2.width * pinchStart.vb.w + pinchStart.vb.x;
145
+ const startMidSvgY = (pinchStart.mid.y - rect2.top) / rect2.height * pinchStart.vb.h + pinchStart.vb.y;
146
+ const curMidX = (pts[0].x + pts[1].x) / 2;
147
+ const curMidY = (pts[0].y + pts[1].y) / 2;
148
+ const newX = startMidSvgX - (curMidX - rect2.left) / rect2.width * newW;
149
+ const newY = startMidSvgY - (curMidY - rect2.top) / rect2.height * newH;
150
+ vb.value = { x: newX, y: newY, w: newW, h: newH };
151
+ targetVb.value = { ...vb.value };
152
+ return;
153
+ }
154
+ if (!isPanning.value || activePointers.size !== 1) return;
155
+ const rect = svgRef.value.getBoundingClientRect();
156
+ const sx = vb.value.w / rect.width;
157
+ const sy = vb.value.h / rect.height;
158
+ const nx = panStart.value.vx - (e.clientX - panStart.value.ex) * sx;
159
+ const ny = panStart.value.vy - (e.clientY - panStart.value.ey) * sy;
160
+ vb.value = { ...vb.value, x: nx, y: ny };
161
+ targetVb.value = { ...targetVb.value, x: nx, y: ny };
162
+ }
163
+ function onSvgPointerUp(e) {
164
+ activePointers.delete(e.pointerId);
165
+ if (activePointers.size === 0) {
166
+ isPanning.value = false;
167
+ isPinching = false;
168
+ pinchStart = null;
169
+ } else if (activePointers.size === 1 && isPinching) {
170
+ isPinching = false;
171
+ pinchStart = null;
172
+ const [[, pt]] = activePointers.entries();
173
+ isPanning.value = true;
174
+ panStart.value = { ex: pt.x, ey: pt.y, vx: vb.value.x, vy: vb.value.y };
175
+ }
176
+ }
177
+ function onSvgPointerLeave(e) {
178
+ activePointers.delete(e.pointerId);
179
+ if (activePointers.size === 0) {
180
+ isPanning.value = false;
181
+ isPinching = false;
182
+ pinchStart = null;
183
+ setLocalState({ pos: null });
184
+ }
185
+ }
186
+ function fitView() {
187
+ if (simNodes.value.length === 0) return;
188
+ const xs = simNodes.value.map((n) => n.x);
189
+ const ys = simNodes.value.map((n) => n.y);
190
+ const minX = Math.min(...xs);
191
+ const maxX = Math.max(...xs);
192
+ const minY = Math.min(...ys);
193
+ const maxY = Math.max(...ys);
194
+ const cx = (minX + maxX) / 2;
195
+ const cy = (minY + maxY) / 2;
196
+ const pad = 120;
197
+ let w = Math.max(300, maxX - minX + pad * 2);
198
+ let h = Math.max(300, maxY - minY + pad * 2);
199
+ const rect = svgRef.value?.getBoundingClientRect();
200
+ if (rect && rect.width > 0 && rect.height > 0) {
201
+ const aspect = rect.width / rect.height;
202
+ if (w / h < aspect) w = h * aspect;
203
+ else h = w / aspect;
204
+ }
205
+ vb.value = { x: cx - w / 2, y: cy - h / 2, w, h };
206
+ targetVb.value = { ...vb.value };
207
+ }
208
+ function resetView() {
209
+ fitView();
210
+ }
211
+ function zoomIn() {
212
+ targetVb.value.w = Math.max(100, targetVb.value.w * 0.77);
213
+ targetVb.value.h = Math.max(100, targetVb.value.h * 0.77);
214
+ if (!zoomRafId) zoomRafId = requestAnimationFrame(animateZoom);
215
+ }
216
+ function zoomOut() {
217
+ targetVb.value.w = Math.min(8e3, targetVb.value.w * 1.3);
218
+ targetVb.value.h = Math.min(8e3, targetVb.value.h * 1.3);
219
+ if (!zoomRafId) zoomRafId = requestAnimationFrame(animateZoom);
220
+ }
221
+ const viewBoxStr = computed(
222
+ () => `${vb.value.x} ${vb.value.y} ${vb.value.w} ${vb.value.h}`
223
+ );
224
+ const labelScale = computed(() => {
225
+ const w = svgRef.value?.clientWidth ?? 1e3;
226
+ return vb.value.w / w;
227
+ });
228
+ const labelOpacity = computed(() => {
229
+ const pxPerUnit = (svgRef.value?.clientWidth ?? 1e3) / vb.value.w;
230
+ const apparentR = 16 * pxPerUnit;
231
+ return Math.max(0, Math.min(1, (apparentR - 8) / 8));
232
+ });
233
+ const simNodes = ref([]);
234
+ const renderTick = ref(0);
235
+ let sim = null;
236
+ const renderedNodes = computed(() => {
237
+ void renderTick.value;
238
+ return simNodes.value.map((n) => ({ ...n }));
239
+ });
240
+ const renderedLinks = computed(() => {
241
+ void renderTick.value;
242
+ if (!sim || !d3.value) return [];
243
+ const linkForce = sim.force("link");
244
+ if (!linkForce) return [];
245
+ return linkForce.links().filter((l) => l.source?.x !== void 0 && l.target?.x !== void 0);
246
+ });
247
+ let savePosTimer = null;
248
+ function savePositionsDebounced() {
249
+ if (savePosTimer) clearTimeout(savePosTimer);
250
+ savePosTimer = setTimeout(() => {
251
+ savePosTimer = null;
252
+ for (const node of simNodes.value) {
253
+ if (remoteDragByNode.value.has(node.id)) continue;
254
+ if (draggingId.value === node.id) continue;
255
+ const existing = getGraphPos(node.id);
256
+ const pinned = existing?.pinned ?? false;
257
+ if (existing && Math.abs(existing.x - node.x) < 1 && Math.abs(existing.y - node.y) < 1 && existing.pinned === pinned) continue;
258
+ tree.updateMeta(node.id, { graphX: node.x, graphY: node.y, graphPinned: pinned });
259
+ }
260
+ }, 500);
261
+ }
262
+ const { pause: pauseSim, resume: resumeSim } = useRafFn(() => {
263
+ if (!sim || sim.alpha() < sim.alphaMin()) {
264
+ pauseSim();
265
+ if (simNodes.value.length > 0) savePositionsDebounced();
266
+ return;
267
+ }
268
+ sim.tick();
269
+ for (const [nodeId, drag] of remoteDragByNode.value) {
270
+ const n = simNodes.value.find((nd) => nd.id === nodeId);
271
+ if (n) {
272
+ n.fx = drag.x;
273
+ n.fy = drag.y;
274
+ }
275
+ }
276
+ renderTick.value++;
277
+ }, { immediate: false });
278
+ function restartSim() {
279
+ if (!sim) return;
280
+ sim.alpha(Math.max(sim.alpha(), 0.12));
281
+ resumeSim();
282
+ }
283
+ function nodeRadius(n) {
284
+ return Math.max(12, Math.min(36, 12 + n.neighborCount * 3.5));
285
+ }
286
+ function seedPos(id) {
287
+ let h = 0;
288
+ for (const c of id) h = Math.imul(31, h) + c.charCodeAt(0) | 0;
289
+ const angle = (h >>> 0) / 4294967295 * 2 * Math.PI;
290
+ const r = 40 + (Math.imul(h, 1664525) >>> 0) / 4294967295 * 100;
291
+ return { x: Math.cos(angle) * r, y: Math.sin(angle) * r };
292
+ }
293
+ const allEntries = computed(() => {
294
+ const result = [];
295
+ function collect(parentId) {
296
+ for (const e of tree.childrenOf(parentId)) {
297
+ result.push(e);
298
+ collect(e.id);
299
+ }
300
+ }
301
+ collect(null);
302
+ return result;
303
+ });
304
+ const allEdges = computed(() => {
305
+ const nodeIds = new Set(allEntries.value.map((e) => e.id));
306
+ return allEntries.value.filter((e) => e.parentId !== null && nodeIds.has(e.parentId)).map((e) => ({ source: e.parentId, target: e.id }));
307
+ });
308
+ const graphKey = computed(() => {
309
+ const ids = [...allEntries.value].map((e) => e.id).sort().join(",");
310
+ const edges = [...allEdges.value].map((e) => `${e.source}>${e.target}`).sort().join(",");
311
+ return ids + "|" + edges;
312
+ });
313
+ function buildSimulation(oldPos = /* @__PURE__ */ new Map()) {
314
+ if (!d3.value) return;
315
+ const { forceSimulation, forceManyBody, forceCollide, forceLink, forceX, forceY } = d3.value;
316
+ const entries = allEntries.value;
317
+ const nodes = entries.map((entry) => {
318
+ const saved = getGraphPos(entry.id);
319
+ const old = oldPos.get(entry.id);
320
+ const seed = seedPos(entry.id);
321
+ const incoming = allEdges.value.filter((e) => e.target === entry.id).length;
322
+ const outgoing = allEdges.value.filter((e) => e.source === entry.id).length;
323
+ return {
324
+ id: entry.id,
325
+ label: entry.label,
326
+ type: entry.type,
327
+ color: entry.meta?.color,
328
+ icon: entry.meta?.icon,
329
+ childCount: tree.childrenOf(entry.id).length,
330
+ neighborCount: incoming + outgoing,
331
+ x: saved?.x ?? old?.x ?? seed.x,
332
+ y: saved?.y ?? old?.y ?? seed.y,
333
+ vx: 0,
334
+ vy: 0,
335
+ fx: saved?.pinned ? saved.x : null,
336
+ fy: saved?.pinned ? saved.y : null
337
+ };
338
+ });
339
+ const nodeIds = new Set(nodes.map((n) => n.id));
340
+ const edges = allEdges.value.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target)).map((e) => ({ source: e.source, target: e.target }));
341
+ pauseSim();
342
+ sim?.stop();
343
+ sim = forceSimulation(nodes).force("charge", forceManyBody().strength(-120)).force("x", forceX(0).strength(0.06)).force("y", forceY(0).strength(0.06)).force("link", forceLink(edges).id((dd) => dd.id).distance(80).strength(0.5)).force("collide", forceCollide().radius((n) => nodeRadius(n) + 6).strength(0.85)).alphaDecay(0.025).velocityDecay(0.55).stop();
344
+ simNodes.value = nodes;
345
+ const hasUnpositioned = nodes.some((n) => !getGraphPos(n.id) && !oldPos.has(n.id));
346
+ if (hasUnpositioned) {
347
+ resumeSim();
348
+ } else {
349
+ renderTick.value++;
350
+ }
351
+ }
352
+ let hasFitOnce = false;
353
+ watch(graphKey, () => {
354
+ const oldPos = new Map(simNodes.value.map((n) => [n.id, { x: n.x, y: n.y }]));
355
+ buildSimulation(oldPos);
356
+ if (!hasFitOnce && simNodes.value.length > 0) {
357
+ hasFitOnce = true;
358
+ nextTick(fitView);
359
+ }
360
+ });
361
+ watch(allEntries, () => {
362
+ let changed = false;
363
+ for (const node of simNodes.value) {
364
+ const entry = allEntries.value.find((e) => e.id === node.id);
365
+ if (entry && (entry.label !== node.label || entry.type !== node.type || entry.meta?.color !== node.color || entry.meta?.icon !== node.icon)) {
366
+ node.label = entry.label;
367
+ node.type = entry.type;
368
+ node.color = entry.meta?.color;
369
+ node.icon = entry.meta?.icon;
370
+ changed = true;
371
+ }
372
+ }
373
+ if (changed) renderTick.value++;
374
+ });
375
+ watch(childDoc, (doc) => {
376
+ if (doc && d3.value) {
377
+ hasFitOnce = false;
378
+ buildSimulation();
379
+ if (simNodes.value.length > 0) {
380
+ hasFitOnce = true;
381
+ nextTick(fitView);
382
+ }
383
+ }
384
+ }, { immediate: true });
385
+ watch(d3, (val) => {
386
+ if (val && childDoc.value) {
387
+ hasFitOnce = false;
388
+ buildSimulation();
389
+ if (simNodes.value.length > 0) {
390
+ hasFitOnce = true;
391
+ nextTick(fitView);
392
+ }
393
+ }
394
+ });
395
+ watch(tree.entries, () => {
396
+ for (const node of simNodes.value) {
397
+ const s = getGraphPos(node.id);
398
+ if (s?.pinned && draggingId.value !== node.id) {
399
+ node.fx = s.x;
400
+ node.fy = s.y;
401
+ } else if (!s?.pinned && draggingId.value !== node.id && !remoteDragByNode.value.has(node.id)) {
402
+ node.fx = null;
403
+ node.fy = null;
404
+ }
405
+ }
406
+ });
407
+ onBeforeUnmount(() => {
408
+ pauseSim();
409
+ sim?.stop();
410
+ if (savePosTimer) clearTimeout(savePosTimer);
411
+ if (zoomRafId) cancelAnimationFrame(zoomRafId);
412
+ setLocalState({ "pos": null, "graph:dragging": null, "graph:selection": [] });
413
+ });
414
+ const draggingId = ref(null);
415
+ const dragStart = ref({ ex: 0, ey: 0 });
416
+ const wasDragged = ref(false);
417
+ function onNodePointerDown(e, id) {
418
+ if (!props.editable) return;
419
+ e.stopPropagation();
420
+ const node = simNodes.value.find((n) => n.id === id);
421
+ if (!node) return;
422
+ e.currentTarget.setPointerCapture(e.pointerId);
423
+ draggingId.value = id;
424
+ wasDragged.value = false;
425
+ dragStart.value = { ex: e.clientX, ey: e.clientY };
426
+ node.fx = node.x;
427
+ node.fy = node.y;
428
+ restartSim();
429
+ setLocalState({ "graph:dragging": { nodeId: id, x: node.x, y: node.y } });
430
+ }
431
+ function onNodePointerMove(e, id) {
432
+ if (draggingId.value !== id) return;
433
+ const dist = Math.hypot(e.clientX - dragStart.value.ex, e.clientY - dragStart.value.ey);
434
+ if (dist > 4) wasDragged.value = true;
435
+ const p = svgCoord(e.clientX, e.clientY);
436
+ const node = simNodes.value.find((n) => n.id === id);
437
+ if (node) {
438
+ node.fx = p.x;
439
+ node.fy = p.y;
440
+ renderTick.value++;
441
+ setLocalState({ "graph:dragging": { nodeId: id, x: p.x, y: p.y } });
442
+ }
443
+ }
444
+ function onNodePointerUp(_e, id) {
445
+ onDragEnd(id);
446
+ }
447
+ function onDragEnd(id) {
448
+ const node = simNodes.value.find((n) => n.id === id);
449
+ if (node) {
450
+ const isPinned = getGraphPos(id)?.pinned ?? false;
451
+ tree.updateMeta(id, { graphX: node.x, graphY: node.y, graphPinned: isPinned });
452
+ if (!isPinned) {
453
+ node.fx = null;
454
+ node.fy = null;
455
+ }
456
+ }
457
+ setLocalState({ "graph:dragging": null });
458
+ draggingId.value = null;
459
+ restartSim();
460
+ }
461
+ const selectedNodeId = ref(null);
462
+ function selectNode(id) {
463
+ selectedNodeId.value = id;
464
+ setLocalState({ "graph:selection": id ? [id] : [] });
465
+ }
466
+ function onSvgClick() {
467
+ selectNode(null);
468
+ }
469
+ function onNodeClick(e, id, label) {
470
+ e.stopPropagation();
471
+ if (wasDragged.value) return;
472
+ selectNode(id);
473
+ openNode(id, label);
474
+ }
475
+ function onSvgDblClick(e) {
476
+ if (!props.editable) return;
477
+ if (e.target.closest("[data-node]")) return;
478
+ const p = svgCoord(e.clientX, e.clientY);
479
+ const id = tree.createChild(null, locale.value.untitled);
480
+ tree.updateMeta(id, { graphX: p.x, graphY: p.y, graphPinned: true });
481
+ }
482
+ function onNodeDblClick(e, id, label) {
483
+ if (!props.editable) return;
484
+ e.stopPropagation();
485
+ editId.value = id;
486
+ editValue.value = label;
487
+ }
488
+ const editId = ref(null);
489
+ const editValue = ref("");
490
+ function commitEdit() {
491
+ if (!props.editable) return;
492
+ if (editId.value && editValue.value.trim()) {
493
+ tree.renameEntry(editId.value, editValue.value.trim());
494
+ }
495
+ editId.value = null;
496
+ }
497
+ function addNodeAtCenter() {
498
+ if (!props.editable) return;
499
+ const id = tree.createChild(null, locale.value.untitled);
500
+ tree.updateMeta(id, { graphX: 0, graphY: 0, graphPinned: false });
501
+ }
502
+ const ctxNode = ref(null);
503
+ const ctxCanvas = ref(null);
504
+ const ctxPos = ref({ x: 0, y: 0 });
505
+ const showCtx = ref(false);
506
+ function onNodeContextMenu(e, id, label) {
507
+ if (!props.editable) return;
508
+ e.preventDefault();
509
+ e.stopPropagation();
510
+ ctxNode.value = { id, label };
511
+ ctxCanvas.value = null;
512
+ ctxPos.value = { x: e.clientX, y: e.clientY };
513
+ showCtx.value = true;
514
+ }
515
+ function onSvgContextMenu(e) {
516
+ if (!props.editable) return;
517
+ e.preventDefault();
518
+ ctxNode.value = null;
519
+ ctxCanvas.value = svgCoord(e.clientX, e.clientY);
520
+ ctxPos.value = { x: e.clientX, y: e.clientY };
521
+ showCtx.value = true;
522
+ }
523
+ useEventListener(window, "pointerdown", () => {
524
+ showCtx.value = false;
525
+ });
526
+ const ctxItems = computed(() => {
527
+ if (ctxNode.value) {
528
+ const isPinned = getGraphPos(ctxNode.value.id)?.pinned;
529
+ return [
530
+ [
531
+ {
532
+ label: "Open",
533
+ icon: "i-lucide-external-link",
534
+ onSelect: () => openNode(ctxNode.value.id, ctxNode.value.label)
535
+ },
536
+ {
537
+ label: "Rename",
538
+ icon: "i-lucide-pencil",
539
+ onSelect: () => {
540
+ editId.value = ctxNode.value.id;
541
+ editValue.value = ctxNode.value.label;
542
+ }
543
+ },
544
+ {
545
+ label: isPinned ? locale.value.unpin : locale.value.pin,
546
+ icon: isPinned ? "i-lucide-pin-off" : "i-lucide-pin",
547
+ onSelect: () => togglePin(ctxNode.value.id)
548
+ }
549
+ ],
550
+ [
551
+ {
552
+ label: "Delete",
553
+ icon: "i-lucide-trash-2",
554
+ color: "error",
555
+ onSelect: () => tree.deleteEntry(ctxNode.value.id)
556
+ }
557
+ ]
558
+ ];
559
+ }
560
+ if (ctxCanvas.value) {
561
+ return [[
562
+ {
563
+ label: locale.value.addNode,
564
+ icon: "i-lucide-plus-circle",
565
+ onSelect: () => {
566
+ const id = tree.createChild(null, locale.value.untitled);
567
+ tree.updateMeta(id, { graphX: ctxCanvas.value.x, graphY: ctxCanvas.value.y, graphPinned: true });
568
+ }
569
+ }
570
+ ]];
571
+ }
572
+ return [];
573
+ });
574
+ function togglePin(id) {
575
+ if (!props.editable) return;
576
+ const node = simNodes.value.find((n) => n.id === id);
577
+ if (!node) return;
578
+ const isPinned = getGraphPos(id)?.pinned;
579
+ tree.updateMeta(id, { graphX: node.x, graphY: node.y, graphPinned: !isPinned });
580
+ node.fx = isPinned ? null : node.x;
581
+ node.fy = isPinned ? null : node.y;
582
+ if (isPinned) restartSim();
583
+ }
584
+ const remoteDragByNode = computed(() => {
585
+ const myId = childProviderRef.value?.awareness?.clientID;
586
+ const map = /* @__PURE__ */ new Map();
587
+ for (const s of states.value) {
588
+ if (s.clientId === myId || !s["graph:dragging"]) continue;
589
+ const nid = s["graph:dragging"].nodeId;
590
+ if (!map.has(nid)) {
591
+ map.set(nid, {
592
+ x: s["graph:dragging"].x,
593
+ y: s["graph:dragging"].y,
594
+ color: s.user?.color ?? "#888"
595
+ });
596
+ }
597
+ }
598
+ return map;
599
+ });
600
+ const remoteSelections = computed(() => {
601
+ const myId = childProviderRef.value?.awareness?.clientID;
602
+ const map = /* @__PURE__ */ new Map();
603
+ for (const s of states.value) {
604
+ if (s.clientId === myId) continue;
605
+ for (const nid of s["graph:selection"] ?? []) {
606
+ if (!map.has(nid)) map.set(nid, []);
607
+ map.get(nid).push({ color: s.user?.color ?? "#888", name: s.user?.name ?? "Anon" });
608
+ }
609
+ }
610
+ return map;
611
+ });
612
+ const TYPE_COLORS = {
613
+ doc: "var(--color-blue-500)",
614
+ kanban: "var(--color-violet-500)",
615
+ gallery: "var(--color-pink-500)",
616
+ table: "var(--color-teal-500)",
617
+ outline: "var(--color-slate-500)",
618
+ timeline: "var(--color-amber-500)",
619
+ calendar: "var(--color-red-500)",
620
+ map: "var(--color-emerald-500)",
621
+ graph: "var(--color-indigo-500)",
622
+ dashboard: "var(--color-orange-500)",
623
+ checklist: "var(--color-green-500)"
624
+ };
625
+ function nodeColor(n) {
626
+ if (n.color) return n.color;
627
+ return TYPE_COLORS[n.type ?? "doc"] ?? "var(--ui-color-neutral-500)";
628
+ }
629
+ const DOC_TYPE_INITIALS = {
630
+ doc: "D",
631
+ kanban: "K",
632
+ gallery: "G",
633
+ table: "T",
634
+ outline: "O",
635
+ timeline: "TL",
636
+ calendar: "C",
637
+ map: "MP",
638
+ graph: "GR",
639
+ dashboard: "DK",
640
+ call: "CA",
641
+ checklist: "CL"
642
+ };
643
+ function docTypeInitial(type) {
644
+ return DOC_TYPE_INITIALS[type ?? "doc"] ?? "?";
645
+ }
646
+ defineExpose({ connectedUsers });
647
+ </script>
648
+
649
+ <template>
650
+ <div class="flex-1 min-h-0 relative overflow-hidden bg-(--ui-bg-elevated)">
651
+ <!-- d3-force not installed fallback -->
652
+ <div
653
+ v-if="d3Error"
654
+ class="flex flex-col items-center justify-center h-full gap-3 text-center"
655
+ >
656
+ <UIcon
657
+ name="i-lucide-git-fork"
658
+ class="size-10 text-(--ui-text-dimmed)"
659
+ />
660
+ <p class="text-sm text-(--ui-text-muted)">
661
+ Graph renderer requires <code>d3-force</code>
662
+ </p>
663
+ <p class="text-xs text-(--ui-text-dimmed)">
664
+ Install it with: <code>pnpm add d3-force</code>
665
+ </p>
666
+ </div>
667
+
668
+ <template v-else-if="d3">
669
+ <!-- Toolbar -->
670
+ <div class="absolute top-3 left-3 z-10 flex gap-1">
671
+ <UButton
672
+ v-if="editable"
673
+ icon="i-lucide-plus"
674
+ size="xs"
675
+ variant="soft"
676
+ color="neutral"
677
+ :label="locale.addNode"
678
+ @click="addNodeAtCenter"
679
+ />
680
+ <UTooltip
681
+ text="Zoom out"
682
+ :content="{ side: 'bottom' }"
683
+ >
684
+ <UButton
685
+ icon="i-lucide-minus"
686
+ size="xs"
687
+ variant="soft"
688
+ color="neutral"
689
+ @click="zoomOut"
690
+ />
691
+ </UTooltip>
692
+ <UTooltip
693
+ text="Zoom in"
694
+ :content="{ side: 'bottom' }"
695
+ >
696
+ <UButton
697
+ icon="i-lucide-zoom-in"
698
+ size="xs"
699
+ variant="soft"
700
+ color="neutral"
701
+ @click="zoomIn"
702
+ />
703
+ </UTooltip>
704
+ <UTooltip
705
+ text="Fit view"
706
+ :content="{ side: 'bottom' }"
707
+ >
708
+ <UButton
709
+ icon="i-lucide-focus"
710
+ size="xs"
711
+ variant="soft"
712
+ color="neutral"
713
+ @click="resetView"
714
+ />
715
+ </UTooltip>
716
+ <UButton
717
+ icon="i-lucide-wind"
718
+ size="xs"
719
+ variant="soft"
720
+ color="neutral"
721
+ label="Shake"
722
+ @click="restartSim"
723
+ />
724
+ </div>
725
+
726
+ <!-- Empty state -->
727
+ <div
728
+ v-if="renderedNodes.length === 0"
729
+ class="flex flex-col items-center justify-center h-full gap-3 text-center"
730
+ >
731
+ <UIcon
732
+ name="i-lucide-git-fork"
733
+ class="size-10 text-(--ui-text-dimmed)"
734
+ />
735
+ <p class="text-sm text-(--ui-text-muted)">
736
+ {{ locale.empty }}
737
+ </p>
738
+ <UButton
739
+ v-if="editable"
740
+ icon="i-lucide-plus"
741
+ :label="locale.addNode"
742
+ size="sm"
743
+ @click="addNodeAtCenter"
744
+ />
745
+ </div>
746
+
747
+ <svg
748
+ v-else
749
+ ref="svgRef"
750
+ class="w-full h-full select-none"
751
+ :class="isPanning ? 'cursor-grabbing' : 'cursor-default'"
752
+ style="touch-action: none"
753
+ :viewBox="viewBoxStr"
754
+ @wheel.prevent="onWheel"
755
+ @pointerdown="onSvgPointerDown"
756
+ @pointermove="onSvgPointerMove"
757
+ @pointerup="onSvgPointerUp"
758
+ @pointerleave="onSvgPointerLeave"
759
+ @click="onSvgClick"
760
+ @dblclick="onSvgDblClick"
761
+ @contextmenu.prevent="onSvgContextMenu"
762
+ >
763
+ <defs>
764
+ <pattern
765
+ id="gdots"
766
+ width="30"
767
+ height="30"
768
+ patternUnits="userSpaceOnUse"
769
+ >
770
+ <circle
771
+ cx="15"
772
+ cy="15"
773
+ r="0.8"
774
+ fill="var(--ui-border)"
775
+ opacity="0.5"
776
+ />
777
+ </pattern>
778
+ <filter
779
+ id="glow"
780
+ x="-50%"
781
+ y="-50%"
782
+ width="200%"
783
+ height="200%"
784
+ >
785
+ <feGaussianBlur
786
+ stdDeviation="4"
787
+ result="blur"
788
+ />
789
+ <feMerge>
790
+ <feMergeNode in="blur" />
791
+ <feMergeNode in="SourceGraphic" />
792
+ </feMerge>
793
+ </filter>
794
+ </defs>
795
+
796
+ <!-- Background dots -->
797
+ <rect
798
+ x="-5000"
799
+ y="-5000"
800
+ width="10000"
801
+ height="10000"
802
+ fill="url(#gdots)"
803
+ />
804
+
805
+ <!-- Edges layer -->
806
+ <g>
807
+ <line
808
+ v-for="(link, i) in renderedLinks"
809
+ :key="i"
810
+ :x1="link.source.x"
811
+ :y1="link.source.y"
812
+ :x2="link.target.x"
813
+ :y2="link.target.y"
814
+ stroke="var(--ui-border)"
815
+ stroke-width="1.2"
816
+ opacity="0.6"
817
+ />
818
+ </g>
819
+
820
+ <!-- Nodes layer -->
821
+ <g
822
+ v-for="node in renderedNodes"
823
+ :key="node.id"
824
+ data-node
825
+ :transform="`translate(${node.x}, ${node.y})`"
826
+ style="cursor: grab"
827
+ @pointerdown.stop="onNodePointerDown($event, node.id)"
828
+ @pointermove.stop="onNodePointerMove($event, node.id)"
829
+ @pointerup.stop="onNodePointerUp($event, node.id)"
830
+ @click.stop="onNodeClick($event, node.id, node.label)"
831
+ @dblclick.stop="onNodeDblClick($event, node.id, node.label)"
832
+ @contextmenu.stop.prevent="onNodeContextMenu($event, node.id, node.label)"
833
+ >
834
+ <!-- Remote selection halos -->
835
+ <circle
836
+ v-for="(sel, si) in remoteSelections.get(node.id) ?? []"
837
+ :key="sel.color + si"
838
+ :r="nodeRadius(node) + 9 + si * 5"
839
+ fill="none"
840
+ :stroke="sel.color"
841
+ stroke-width="1.5"
842
+ opacity="0.4"
843
+ />
844
+
845
+ <!-- Local selection glow -->
846
+ <circle
847
+ v-if="selectedNodeId === node.id"
848
+ :r="nodeRadius(node) + 7"
849
+ fill="none"
850
+ stroke="var(--ui-primary)"
851
+ stroke-width="2.5"
852
+ filter="url(#glow)"
853
+ opacity="0.9"
854
+ />
855
+
856
+ <!-- Remote drag ring -->
857
+ <circle
858
+ v-if="remoteDragByNode.get(node.id)"
859
+ :r="nodeRadius(node) + 3"
860
+ fill="none"
861
+ :stroke="remoteDragByNode.get(node.id).color"
862
+ stroke-width="2"
863
+ stroke-dasharray="5 3"
864
+ opacity="0.8"
865
+ />
866
+
867
+ <!-- Main circle -->
868
+ <circle
869
+ :r="nodeRadius(node)"
870
+ :fill="nodeColor(node)"
871
+ stroke="color-mix(in srgb, var(--ui-bg) 20%, var(--ui-border))"
872
+ stroke-width="1"
873
+ />
874
+
875
+ <!-- Pinned indicator -->
876
+ <circle
877
+ v-if="getGraphPos(node.id)?.pinned"
878
+ :cx="nodeRadius(node) - 5"
879
+ :cy="-(nodeRadius(node) - 5)"
880
+ r="4"
881
+ fill="var(--ui-bg-muted)"
882
+ stroke="var(--ui-text-muted)"
883
+ stroke-width="1"
884
+ />
885
+
886
+ <!-- Custom icon -->
887
+ <foreignObject
888
+ v-if="node.icon"
889
+ :x="-nodeRadius(node) * 0.45"
890
+ :y="-nodeRadius(node) * 0.45"
891
+ :width="nodeRadius(node) * 0.9"
892
+ :height="nodeRadius(node) * 0.9"
893
+ style="pointer-events: none; overflow: visible"
894
+ >
895
+ <div
896
+ xmlns="http://www.w3.org/1999/xhtml"
897
+ style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center"
898
+ >
899
+ <UIcon
900
+ :name="`i-lucide-${node.icon}`"
901
+ style="color: white; opacity: 0.9; width: 100%; height: 100%"
902
+ />
903
+ </div>
904
+ </foreignObject>
905
+
906
+ <!-- Fallback: doc-type initial -->
907
+ <text
908
+ v-else
909
+ text-anchor="middle"
910
+ dominant-baseline="central"
911
+ :font-size="nodeRadius(node) * 0.5"
912
+ fill="white"
913
+ opacity="0.9"
914
+ style="pointer-events: none; user-select: none"
915
+ >
916
+ {{ docTypeInitial(node.type) }}
917
+ </text>
918
+
919
+ <!-- Node label -->
920
+ <text
921
+ v-if="editId !== node.id"
922
+ :y="nodeRadius(node) + 14 * labelScale"
923
+ text-anchor="middle"
924
+ :font-size="11 * labelScale"
925
+ :opacity="labelOpacity"
926
+ fill="var(--ui-text)"
927
+ style="pointer-events: none; user-select: none"
928
+ >
929
+ {{ node.label.length > 20 ? node.label.slice(0, 20) + "\u2026" : node.label }}
930
+ </text>
931
+
932
+ <!-- Inline rename input -->
933
+ <foreignObject
934
+ v-if="editId === node.id"
935
+ :x="-(nodeRadius(node) + 28)"
936
+ :y="nodeRadius(node) + 4"
937
+ :width="(nodeRadius(node) + 28) * 2"
938
+ height="20"
939
+ >
940
+ <input
941
+ class="w-full bg-transparent text-center text-xs outline-none"
942
+ style="color: var(--ui-text-highlighted)"
943
+ :value="editValue"
944
+ autofocus
945
+ @input="editValue = $event.target.value"
946
+ @blur="commitEdit"
947
+ @keydown.enter="commitEdit"
948
+ @keydown.escape="editId = null"
949
+ @pointerdown.stop
950
+ >
951
+ </foreignObject>
952
+ </g>
953
+
954
+ <!-- Cursors layer -->
955
+ <g style="pointer-events: none">
956
+ <g
957
+ v-for="c in cursors"
958
+ :key="c.clientId"
959
+ :transform="`translate(${c.x}, ${c.y})`"
960
+ >
961
+ <circle
962
+ r="5"
963
+ :fill="c.state?.user?.color || 'var(--ui-color-neutral-400)'"
964
+ opacity="0.85"
965
+ />
966
+ <text
967
+ x="8"
968
+ y="4"
969
+ :font-size="10 * labelScale"
970
+ :fill="c.state?.user?.color || 'var(--ui-color-neutral-400)'"
971
+ style="user-select: none"
972
+ >
973
+ {{ c.state?.user?.name || "Anon" }}
974
+ </text>
975
+ </g>
976
+ </g>
977
+ </svg>
978
+
979
+ <!-- Hint -->
980
+ <p class="absolute bottom-3 right-3 text-[10px] text-(--ui-text-muted) select-none pointer-events-none">
981
+ Drag to reposition · Right-click to pin/create · Scroll to zoom · Click to open · Dbl-click canvas to create
982
+ </p>
983
+
984
+ <!-- Context menu -->
985
+ <Teleport to="body">
986
+ <div
987
+ v-if="showCtx && ctxItems.length"
988
+ class="fixed z-50 min-w-40 bg-(--ui-bg) border border-(--ui-border) rounded-lg shadow-xl py-1 text-sm"
989
+ :style="{ left: ctxPos.x + 'px', top: ctxPos.y + 'px' }"
990
+ >
991
+ <template
992
+ v-for="(group, gi) in ctxItems"
993
+ :key="gi"
994
+ >
995
+ <div
996
+ v-if="gi > 0"
997
+ class="my-1 border-t border-(--ui-border)"
998
+ />
999
+ <button
1000
+ v-for="item in group"
1001
+ :key="item.label"
1002
+ class="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-(--ui-bg-elevated) text-left cursor-default"
1003
+ :class="item.color === 'error' ? 'text-(--ui-color-error-500)' : 'text-(--ui-text)'"
1004
+ @pointerdown.stop
1005
+ @click="item.onSelect();
1006
+ showCtx = false"
1007
+ >
1008
+ <UIcon
1009
+ :name="item.icon"
1010
+ class="size-3.5 shrink-0 opacity-70"
1011
+ />
1012
+ {{ item.label }}
1013
+ </button>
1014
+ </template>
1015
+ </div>
1016
+ </Teleport>
1017
+ </template>
1018
+
1019
+ <!-- Node panel -->
1020
+ <ANodePanel
1021
+ :node-id="openNodeId"
1022
+ :node-label="openNodeLabel"
1023
+ :child-provider="openNodeProvider"
1024
+ @close="closePanel"
1025
+ />
1026
+ </div>
1027
+ </template>