collabmd 0.1.20 → 0.1.21

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 (43) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/public/assets/css/style.css +1 -1
  4. package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
  5. package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
  6. package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
  7. package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
  8. package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
  9. package/public/assets/js/excalidraw-editor.js +1 -1
  10. package/public/assets/js/main.js +68 -68
  11. package/public/assets/js/preview-render-worker.js +15 -15
  12. package/src/client/application/app-shell/git-feature.js +57 -18
  13. package/src/client/application/app-shell/ui-feature.js +4 -0
  14. package/src/client/bootstrap/collabmd-app-shell.js +12 -0
  15. package/src/client/domain/vault-utils.js +2 -2
  16. package/src/client/excalidraw-editor.js +2 -1
  17. package/src/client/infrastructure/editor-session.js +4 -0
  18. package/src/client/infrastructure/editor-view-adapter.js +181 -1
  19. package/src/client/infrastructure/workspace-sync-client.js +324 -0
  20. package/src/client/presentation/comment-ui-controller.js +192 -5
  21. package/src/client/presentation/file-explorer-controller.js +15 -3
  22. package/src/client/presentation/file-explorer-view.js +152 -11
  23. package/src/client/styles/style.css +46 -2
  24. package/src/domain/wiki-link-resolver.js +16 -5
  25. package/src/domain/workspace-change.js +68 -0
  26. package/src/domain/workspace-room.js +3 -0
  27. package/src/server/create-app-server.js +41 -10
  28. package/src/server/domain/backlink-index.js +94 -1
  29. package/src/server/domain/collaboration/collaboration-room.js +191 -22
  30. package/src/server/domain/collaboration/room-registry.js +64 -10
  31. package/src/server/infrastructure/git/responses.js +12 -19
  32. package/src/server/infrastructure/http/create-git-api-command-handler.js +53 -43
  33. package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
  34. package/src/server/infrastructure/http/create-request-handler.js +7 -0
  35. package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
  36. package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
  37. package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
  38. package/src/server/infrastructure/http/http-response.js +65 -28
  39. package/src/server/infrastructure/persistence/vault-file-store.js +163 -52
  40. package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
  41. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
  42. package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
  43. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +0 -22
@@ -0,0 +1,324 @@
1
+ import { WebsocketProvider } from 'y-websocket';
2
+ import * as Y from 'yjs';
3
+
4
+ import { normalizeWorkspaceEvent } from '../../domain/workspace-change.js';
5
+ import { WORKSPACE_ROOM_NAME } from '../../domain/workspace-room.js';
6
+ import { resolveWsBaseUrl } from '../domain/runtime-paths.js';
7
+ import { stopReconnectOnControlledClose } from './yjs-provider-reset-guard.js';
8
+
9
+ function createNode(entry) {
10
+ if (!entry?.path || !entry?.type) {
11
+ return null;
12
+ }
13
+
14
+ if (entry.nodeType === 'directory' || entry.type === 'directory') {
15
+ return {
16
+ children: [],
17
+ name: entry.name,
18
+ path: entry.path,
19
+ type: 'directory',
20
+ };
21
+ }
22
+
23
+ return {
24
+ name: entry.name,
25
+ path: entry.path,
26
+ type: entry.type,
27
+ };
28
+ }
29
+
30
+ function sortNodes(nodes = []) {
31
+ nodes.sort((left, right) => {
32
+ if (left.type === 'directory' && right.type !== 'directory') return -1;
33
+ if (left.type !== 'directory' && right.type === 'directory') return 1;
34
+ return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' });
35
+ });
36
+
37
+ nodes.forEach((node) => {
38
+ if (Array.isArray(node.children)) {
39
+ sortNodes(node.children);
40
+ }
41
+ });
42
+
43
+ return nodes;
44
+ }
45
+
46
+ function toEntryMap(value) {
47
+ if (value instanceof Map) {
48
+ return value;
49
+ }
50
+
51
+ return new Map(Object.entries(value ?? {}));
52
+ }
53
+
54
+ function sortPathsByDepth(values = [], direction = 'asc') {
55
+ const factor = direction === 'desc' ? -1 : 1;
56
+ return [...values].sort((left, right) => {
57
+ const depthDelta = left.split('/').length - right.split('/').length;
58
+ if (depthDelta !== 0) {
59
+ return depthDelta * factor;
60
+ }
61
+
62
+ return left.localeCompare(right, undefined, { sensitivity: 'base' }) * factor;
63
+ });
64
+ }
65
+
66
+ class WorkspaceTreeModel {
67
+ constructor() {
68
+ this.entriesByPath = new Map();
69
+ this.nodesByPath = new Map();
70
+ this.nodeParentPathByPath = new Map();
71
+ this.roots = [];
72
+ }
73
+
74
+ reset(rawEntries) {
75
+ this.entriesByPath = toEntryMap(rawEntries);
76
+ this.nodesByPath = new Map();
77
+ this.nodeParentPathByPath = new Map();
78
+ this.roots = [];
79
+
80
+ this.entriesByPath.forEach((entry) => {
81
+ const node = createNode(entry);
82
+ if (node) {
83
+ this.nodesByPath.set(entry.path, node);
84
+ }
85
+ });
86
+
87
+ sortPathsByDepth(Array.from(this.nodesByPath.keys())).forEach((pathValue) => {
88
+ const node = this.nodesByPath.get(pathValue);
89
+ if (node) {
90
+ this.attachNode(pathValue, node, this.entriesByPath.get(pathValue)?.parentPath || '');
91
+ }
92
+ });
93
+
94
+ sortNodes(this.roots);
95
+ return this.roots;
96
+ }
97
+
98
+ getTree() {
99
+ return this.roots;
100
+ }
101
+
102
+ applyMapChanges(changes, entriesMap) {
103
+ const deletePaths = [];
104
+ const upsertPaths = [];
105
+
106
+ changes.forEach((change, pathValue) => {
107
+ if (change.action === 'delete') {
108
+ deletePaths.push(pathValue);
109
+ } else {
110
+ upsertPaths.push(pathValue);
111
+ }
112
+ });
113
+
114
+ sortPathsByDepth(deletePaths, 'desc').forEach((pathValue) => {
115
+ this.removeEntry(pathValue);
116
+ });
117
+
118
+ sortPathsByDepth(upsertPaths).forEach((pathValue) => {
119
+ this.upsertEntry(pathValue, entriesMap.get(pathValue));
120
+ });
121
+
122
+ sortNodes(this.roots);
123
+ return this.roots;
124
+ }
125
+
126
+ removeEntry(pathValue) {
127
+ const node = this.nodesByPath.get(pathValue);
128
+ if (!node) {
129
+ this.entriesByPath.delete(pathValue);
130
+ this.nodeParentPathByPath.delete(pathValue);
131
+ return;
132
+ }
133
+
134
+ this.detachNode(pathValue, node);
135
+ this.nodesByPath.delete(pathValue);
136
+ this.entriesByPath.delete(pathValue);
137
+ this.nodeParentPathByPath.delete(pathValue);
138
+ }
139
+
140
+ upsertEntry(pathValue, entry) {
141
+ if (!entry?.path || !entry?.type) {
142
+ this.removeEntry(pathValue);
143
+ return;
144
+ }
145
+
146
+ const nextNode = createNode(entry);
147
+ if (!nextNode) {
148
+ this.removeEntry(pathValue);
149
+ return;
150
+ }
151
+
152
+ const existingNode = this.nodesByPath.get(pathValue);
153
+ let node = existingNode;
154
+ if (!node || (node.type === 'directory') !== (nextNode.type === 'directory')) {
155
+ if (node) {
156
+ this.detachNode(pathValue, node);
157
+ }
158
+ node = nextNode;
159
+ this.nodesByPath.set(pathValue, node);
160
+ } else {
161
+ node.name = nextNode.name;
162
+ node.path = nextNode.path;
163
+ node.type = nextNode.type;
164
+ if (node.type === 'directory' && !Array.isArray(node.children)) {
165
+ node.children = [];
166
+ }
167
+ if (node.type !== 'directory' && Array.isArray(node.children)) {
168
+ delete node.children;
169
+ }
170
+ }
171
+
172
+ this.entriesByPath.set(pathValue, entry);
173
+ this.attachNode(pathValue, node, entry.parentPath || '');
174
+
175
+ if (node.type === 'directory') {
176
+ this.rehomeChildren(pathValue);
177
+ }
178
+ }
179
+
180
+ detachNode(pathValue, node) {
181
+ const currentParentPath = this.nodeParentPathByPath.get(pathValue) || '';
182
+ const siblings = currentParentPath
183
+ ? this.nodesByPath.get(currentParentPath)?.children
184
+ : this.roots;
185
+ const index = siblings?.indexOf?.(node) ?? -1;
186
+ if (index >= 0) {
187
+ siblings.splice(index, 1);
188
+ }
189
+ this.nodeParentPathByPath.delete(pathValue);
190
+ }
191
+
192
+ attachNode(pathValue, node, requestedParentPath = '') {
193
+ const parentPath = this.nodesByPath.get(requestedParentPath)?.type === 'directory'
194
+ ? requestedParentPath
195
+ : '';
196
+ const currentParentPath = this.nodeParentPathByPath.get(pathValue);
197
+
198
+ if (currentParentPath === parentPath) {
199
+ return;
200
+ }
201
+
202
+ if (currentParentPath !== undefined) {
203
+ this.detachNode(pathValue, node);
204
+ }
205
+
206
+ const siblings = parentPath
207
+ ? this.nodesByPath.get(parentPath)?.children
208
+ : this.roots;
209
+ if (!siblings.includes(node)) {
210
+ siblings.push(node);
211
+ }
212
+ this.nodeParentPathByPath.set(pathValue, parentPath);
213
+ }
214
+
215
+ rehomeChildren(parentPath) {
216
+ this.entriesByPath.forEach((entry, pathValue) => {
217
+ if (entry?.parentPath !== parentPath) {
218
+ return;
219
+ }
220
+
221
+ const node = this.nodesByPath.get(pathValue);
222
+ if (node) {
223
+ this.attachNode(pathValue, node, parentPath);
224
+ }
225
+ });
226
+ }
227
+ }
228
+
229
+ export class WorkspaceSyncClient {
230
+ constructor({
231
+ onTreeChange = () => {},
232
+ onWorkspaceEvent = () => {},
233
+ } = {}) {
234
+ this.onTreeChange = onTreeChange;
235
+ this.onWorkspaceEvent = onWorkspaceEvent;
236
+ this.ydoc = new Y.Doc();
237
+ this.entries = this.ydoc.getMap('entries');
238
+ this.events = this.ydoc.getArray('events');
239
+ this.provider = null;
240
+ this._didInitialSync = false;
241
+ this.seenEventIds = new Set();
242
+ this.treeModel = new WorkspaceTreeModel();
243
+
244
+ this.handleEntriesChange = (event) => {
245
+ if (!this._didInitialSync || !event) {
246
+ return;
247
+ }
248
+
249
+ this.onTreeChange(this.treeModel.applyMapChanges(event.changes.keys, this.entries), {
250
+ changedPaths: Array.from(event.changes.keys.keys()),
251
+ reset: false,
252
+ });
253
+ };
254
+ this.handleEventsChange = () => {
255
+ if (!this._didInitialSync) {
256
+ this.primeEventCache();
257
+ return;
258
+ }
259
+
260
+ this.events.toArray().forEach((event) => {
261
+ const normalized = normalizeWorkspaceEvent(event);
262
+ if (!normalized || this.seenEventIds.has(normalized.id)) {
263
+ return;
264
+ }
265
+
266
+ this.seenEventIds.add(normalized.id);
267
+ this.onWorkspaceEvent(normalized);
268
+ });
269
+ };
270
+ }
271
+
272
+ connect() {
273
+ if (this.provider) {
274
+ return;
275
+ }
276
+
277
+ this._didInitialSync = false;
278
+ this.provider = new WebsocketProvider(resolveWsBaseUrl(), WORKSPACE_ROOM_NAME, this.ydoc, {
279
+ disableBc: true,
280
+ maxBackoffTime: 5000,
281
+ });
282
+ stopReconnectOnControlledClose(this.provider);
283
+
284
+ this.entries.observe(this.handleEntriesChange);
285
+ this.events.observe(this.handleEventsChange);
286
+ this.provider.on('sync', (isSynced) => {
287
+ if (!isSynced || this._didInitialSync) {
288
+ return;
289
+ }
290
+
291
+ this._didInitialSync = true;
292
+ this.primeEventCache();
293
+ this.onTreeChange(this.treeModel.reset(this.entries.toJSON()), {
294
+ changedPaths: [],
295
+ reset: true,
296
+ });
297
+ });
298
+ }
299
+
300
+ primeEventCache() {
301
+ this.events.toArray().forEach((event) => {
302
+ const normalized = normalizeWorkspaceEvent(event);
303
+ if (normalized) {
304
+ this.seenEventIds.add(normalized.id);
305
+ }
306
+ });
307
+ }
308
+
309
+ disconnect() {
310
+ this.entries.unobserve(this.handleEntriesChange);
311
+ this.events.unobserve(this.handleEventsChange);
312
+ this.provider?.disconnect();
313
+ this.provider?.destroy();
314
+ this.provider = null;
315
+ this._didInitialSync = false;
316
+ this.seenEventIds.clear();
317
+ this.treeModel.reset(new Map());
318
+ }
319
+
320
+ destroy() {
321
+ this.disconnect();
322
+ this.ydoc.destroy();
323
+ }
324
+ }
@@ -9,6 +9,9 @@ const COMMENT_CARD_WIDTH = 520;
9
9
  const COMMENT_SELECTION_REVEAL_DELAY_MS = 150;
10
10
  const COMMENT_SELECTION_CHIP_GAP = 12;
11
11
  const COMMENT_CONTROL_SLOT_HEIGHT = 36;
12
+ const COMMENT_PREVIEW_RAIL_SLOT_HEIGHT = 30;
13
+ const COMMENT_PREVIEW_RAIL_MIN_WIDTH = 400;
14
+ const COMMENT_PREVIEW_RAIL_BREAKPOINT = 769;
12
15
  const COMMENT_REACTION_PRESET_EMOJIS = Object.freeze(['👍', '❤️', '🎉', '👀', '🚀']);
13
16
  const COMMENT_REACTION_MORE_EMOJIS = Object.freeze(['😂', '🔥', '✅', '🙏', '💡', '🤔', '👏', '😄', '🎯', '🙌']);
14
17
 
@@ -248,6 +251,25 @@ function createRectFromRects(rects = []) {
248
251
  };
249
252
  }
250
253
 
254
+ function pointIntersectsRect(x, y, rect) {
255
+ if (!rect) {
256
+ return false;
257
+ }
258
+
259
+ return x >= rect.left
260
+ && x <= rect.right
261
+ && y >= rect.top
262
+ && y <= rect.bottom;
263
+ }
264
+
265
+ function normalizeGroupKeys(keys = []) {
266
+ return [...new Set(keys.filter(Boolean))].sort();
267
+ }
268
+
269
+ function serializeGroupKeys(keys = []) {
270
+ return normalizeGroupKeys(keys).join(' ');
271
+ }
272
+
251
273
  function createCommentMarkerContent(count) {
252
274
  const fragment = document.createDocumentFragment();
253
275
 
@@ -315,6 +337,12 @@ export class CommentUiController {
315
337
  this.pointerSelecting = false;
316
338
  this.session = null;
317
339
  this.activeCard = null;
340
+ this.hoveredEditorGroupKeys = [];
341
+ this.hoveredEditorGroupKeysSignature = '';
342
+ this.hoveredPreviewGroupKeys = [];
343
+ this.hoveredPreviewGroupKeysSignature = '';
344
+ this.previewHoverRegions = [];
345
+ this.lastPreviewPointerPosition = null;
318
346
  this.editorLayer = null;
319
347
  this.previewLayer = null;
320
348
  this.previewHighlightLayer = null;
@@ -331,6 +359,20 @@ export class CommentUiController {
331
359
  this.handleEditorScroll = () => this.scheduleLayoutRefresh();
332
360
  this.handlePreviewScroll = () => this.scheduleLayoutRefresh();
333
361
  this.handleWindowResize = () => this.scheduleLayoutRefresh();
362
+ this.handlePreviewPointerMove = (event) => {
363
+ this.lastPreviewPointerPosition = { x: event.clientX, y: event.clientY };
364
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysAtPoint(event.clientX, event.clientY));
365
+ };
366
+ this.handlePreviewPointerLeave = () => {
367
+ this.lastPreviewPointerPosition = null;
368
+ this.updateHoveredPreviewGroups([]);
369
+ };
370
+ this.handlePreviewFocusIn = (event) => {
371
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.target));
372
+ };
373
+ this.handlePreviewFocusOut = (event) => {
374
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(event.relatedTarget));
375
+ };
334
376
  this.handleCommentSelectionButtonPointerDown = (event) => {
335
377
  event.preventDefault();
336
378
  };
@@ -436,6 +478,10 @@ export class CommentUiController {
436
478
  this.setDrawerOpen(!this.drawerOpen);
437
479
  });
438
480
  this.previewContainer?.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
481
+ this.previewElement?.addEventListener('pointermove', this.handlePreviewPointerMove, { passive: true });
482
+ this.previewElement?.addEventListener('pointerleave', this.handlePreviewPointerLeave);
483
+ this.previewElement?.addEventListener('focusin', this.handlePreviewFocusIn);
484
+ this.previewElement?.addEventListener('focusout', this.handlePreviewFocusOut);
439
485
  this.editorContainer?.addEventListener('pointerdown', this.handleEditorPointerDown);
440
486
  this.editorContainer?.addEventListener('focusout', this.handleEditorFocusOut);
441
487
  window.addEventListener('resize', this.handleWindowResize);
@@ -452,6 +498,10 @@ export class CommentUiController {
452
498
  }
453
499
  this.attachSession(null);
454
500
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
501
+ this.previewElement?.removeEventListener('pointermove', this.handlePreviewPointerMove);
502
+ this.previewElement?.removeEventListener('pointerleave', this.handlePreviewPointerLeave);
503
+ this.previewElement?.removeEventListener('focusin', this.handlePreviewFocusIn);
504
+ this.previewElement?.removeEventListener('focusout', this.handlePreviewFocusOut);
455
505
  this.commentSelectionButton?.removeEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
456
506
  this.editorContainer?.removeEventListener('pointerdown', this.handleEditorPointerDown);
457
507
  this.editorContainer?.removeEventListener('focusout', this.handleEditorFocusOut);
@@ -460,6 +510,7 @@ export class CommentUiController {
460
510
  document.removeEventListener('pointercancel', this.handleDocumentPointerUp);
461
511
  document.removeEventListener('pointerdown', this.handleDocumentPointerDown);
462
512
  document.removeEventListener('keydown', this.handleDocumentKeyDown);
513
+ this.previewHoverRegions = [];
463
514
  this.cardRoot?.remove();
464
515
  this.editorLayer?.remove();
465
516
  this.previewLayer?.remove();
@@ -493,6 +544,10 @@ export class CommentUiController {
493
544
  this.clearSelectionRevealTimer();
494
545
  this.pointerSelecting = false;
495
546
  this.activeCard = null;
547
+ this.updateHoveredEditorGroups([]);
548
+ this.updateHoveredPreviewGroups([]);
549
+ this.previewHoverRegions = [];
550
+ this.lastPreviewPointerPosition = null;
496
551
  this.reactionPicker = null;
497
552
  }
498
553
  if (!this.supported) {
@@ -504,6 +559,10 @@ export class CommentUiController {
504
559
  this.committedSelectionAnchor = null;
505
560
  this.clearSelectionRevealTimer();
506
561
  this.pointerSelecting = false;
562
+ this.updateHoveredEditorGroups([]);
563
+ this.updateHoveredPreviewGroups([]);
564
+ this.previewHoverRegions = [];
565
+ this.lastPreviewPointerPosition = null;
507
566
  this.reactionPicker = null;
508
567
  }
509
568
  this.render();
@@ -757,11 +816,19 @@ export class CommentUiController {
757
816
  }
758
817
 
759
818
  const relativeRect = toRelativeRect(rect, containerRect);
819
+ if (relativeRect.bottom < 0 || relativeRect.top > containerRect.height) {
820
+ return;
821
+ }
822
+
760
823
  const button = document.createElement('button');
761
824
  button.type = 'button';
762
825
  button.className = 'comment-editor-badge';
763
826
  button.dataset.count = String(group.threads.length);
764
- button.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
827
+ const isActive = this.activeCard?.groupKey === group.key;
828
+ const isHovered = this.hoveredEditorGroupKeys.includes(group.key);
829
+ button.classList.toggle('is-active', isActive);
830
+ button.classList.toggle('is-hovered', isHovered);
831
+ button.classList.toggle('is-passive', !isActive && !isHovered);
765
832
  button.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
766
833
  button.appendChild(createCommentMarkerContent(group.threads.length));
767
834
  const top = Math.max(relativeRect.top, 8);
@@ -771,6 +838,18 @@ export class CommentUiController {
771
838
  button.addEventListener('pointerdown', (event) => {
772
839
  event.preventDefault();
773
840
  });
841
+ button.addEventListener('pointerenter', () => {
842
+ this.updateHoveredEditorGroups([group.key]);
843
+ });
844
+ button.addEventListener('pointerleave', () => {
845
+ this.updateHoveredEditorGroups([]);
846
+ });
847
+ button.addEventListener('focusin', () => {
848
+ this.updateHoveredEditorGroups([group.key]);
849
+ });
850
+ button.addEventListener('focusout', () => {
851
+ this.updateHoveredEditorGroups([]);
852
+ });
774
853
  button.addEventListener('click', () => {
775
854
  this.openThreadGroup(group, {
776
855
  anchor: group.anchor,
@@ -840,20 +919,36 @@ export class CommentUiController {
840
919
  this.previewHighlightLayer?.replaceChildren();
841
920
 
842
921
  if (!this.supported || !this.previewElement) {
922
+ this.previewHoverRegions = [];
843
923
  return;
844
924
  }
845
925
 
846
926
  const previewRect = this.previewElement.getBoundingClientRect();
847
927
  const groups = this.getThreadGroups();
928
+ const occupiedTops = [];
929
+ const hoverRegions = [];
930
+ const showPassiveMarkers = this.shouldRenderPassivePreviewMarkers();
848
931
  groups.forEach((group) => {
849
932
  const target = this.resolvePreviewTarget(group.anchor);
850
933
  if (!target?.bubbleRect) {
851
934
  return;
852
935
  }
853
936
 
937
+ hoverRegions.push({
938
+ key: group.key,
939
+ rects: target.hoverRects?.length > 0 ? target.hoverRects : [target.bubbleRect],
940
+ });
941
+
942
+ const isActive = this.activeCard?.groupKey === group.key;
943
+ const isHovered = this.hoveredPreviewGroupKeys.includes(group.key);
944
+ const isEmphasized = isActive || isHovered;
945
+
854
946
  target.highlightRects?.forEach((rect) => {
855
947
  const highlight = document.createElement('div');
856
948
  highlight.className = 'comment-preview-highlight';
949
+ highlight.classList.toggle('is-active', isActive);
950
+ highlight.classList.toggle('is-hovered', isHovered);
951
+ highlight.classList.toggle('is-passive', !isEmphasized);
857
952
  highlight.style.left = `${rect.left - previewRect.left}px`;
858
953
  highlight.style.top = `${rect.top - previewRect.top}px`;
859
954
  highlight.style.width = `${rect.width}px`;
@@ -861,14 +956,32 @@ export class CommentUiController {
861
956
  this.previewHighlightLayer?.appendChild(highlight);
862
957
  });
863
958
 
959
+ if (!showPassiveMarkers && !isEmphasized) {
960
+ return;
961
+ }
962
+
864
963
  const bubble = document.createElement('button');
865
964
  bubble.type = 'button';
866
965
  bubble.className = 'comment-preview-badge';
867
- bubble.classList.toggle('is-active', this.activeCard?.groupKey === group.key);
966
+ bubble.dataset.commentPreviewGroupKeys = group.key;
967
+ bubble.classList.toggle('is-active', isActive);
968
+ bubble.classList.toggle('is-hovered', isHovered);
969
+ bubble.classList.toggle('is-passive', !isEmphasized);
868
970
  bubble.setAttribute('aria-label', `${group.threads.length} comment thread${group.threads.length === 1 ? '' : 's'}`);
869
971
  bubble.appendChild(createCommentMarkerContent(group.threads.length));
870
- bubble.style.left = `${clamp(target.bubbleRect.right - previewRect.left + 6, 6, this.previewElement.clientWidth - 34)}px`;
871
- bubble.style.top = `${Math.max(target.bubbleRect.top - previewRect.top, 6)}px`;
972
+ let bubbleTop = clamp(
973
+ target.bubbleRect.top - previewRect.top,
974
+ 6,
975
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
976
+ );
977
+ while (occupiedTops.some((top) => Math.abs(top - bubbleTop) < (COMMENT_PREVIEW_RAIL_SLOT_HEIGHT - 4))) {
978
+ bubbleTop = clamp(
979
+ bubbleTop + COMMENT_PREVIEW_RAIL_SLOT_HEIGHT,
980
+ 6,
981
+ Math.max(this.previewElement.clientHeight - COMMENT_PREVIEW_RAIL_SLOT_HEIGHT, 6),
982
+ );
983
+ }
984
+ bubble.style.top = `${bubbleTop}px`;
872
985
  bubble.title = `${group.threads.length} comment${group.threads.length === 1 ? '' : 's'}`;
873
986
  bubble.addEventListener('pointerdown', (event) => {
874
987
  event.preventDefault();
@@ -877,11 +990,19 @@ export class CommentUiController {
877
990
  this.openThreadGroup(group, {
878
991
  anchor: group.anchor,
879
992
  origin: 'preview',
880
- sourceRect: target.bubbleRect,
993
+ sourceRect: bubble.getBoundingClientRect(),
881
994
  });
882
995
  });
883
996
  this.previewLayer?.appendChild(bubble);
997
+ occupiedTops.push(bubbleTop);
884
998
  });
999
+
1000
+ this.previewHoverRegions = hoverRegions;
1001
+ if (this.lastPreviewPointerPosition) {
1002
+ this.updateHoveredPreviewGroups(
1003
+ this.getPreviewGroupKeysAtPoint(this.lastPreviewPointerPosition.x, this.lastPreviewPointerPosition.y),
1004
+ );
1005
+ }
885
1006
  }
886
1007
 
887
1008
  resolvePreviewTarget(anchor) {
@@ -895,6 +1016,7 @@ export class CommentUiController {
895
1016
  return {
896
1017
  bubbleRect: diagramShell.getBoundingClientRect(),
897
1018
  highlightRects: [],
1019
+ hoverRects: [diagramShell.getBoundingClientRect()],
898
1020
  };
899
1021
  }
900
1022
 
@@ -911,6 +1033,7 @@ export class CommentUiController {
911
1033
  return {
912
1034
  bubbleRect,
913
1035
  highlightRects: rects,
1036
+ hoverRects: rects,
914
1037
  };
915
1038
  }
916
1039
  }
@@ -923,6 +1046,7 @@ export class CommentUiController {
923
1046
  return {
924
1047
  bubbleRect: fallback.getBoundingClientRect(),
925
1048
  highlightRects: [],
1049
+ hoverRects: [fallback.getBoundingClientRect()],
926
1050
  };
927
1051
  }
928
1052
 
@@ -1115,6 +1239,69 @@ export class CommentUiController {
1115
1239
  this.scheduleLayoutRefresh();
1116
1240
  }
1117
1241
 
1242
+ getPreviewGroupKeysForTarget(target) {
1243
+ if (!(target instanceof Node)) {
1244
+ return [];
1245
+ }
1246
+
1247
+ const keyCarrier = target.closest?.('[data-comment-preview-group-keys]');
1248
+ return serializeGroupKeys(
1249
+ String(keyCarrier?.dataset?.commentPreviewGroupKeys ?? '')
1250
+ .split(/\s+/)
1251
+ .filter(Boolean),
1252
+ ).split(' ').filter(Boolean);
1253
+ }
1254
+
1255
+ getPreviewGroupKeysAtPoint(x, y) {
1256
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1257
+ return [];
1258
+ }
1259
+
1260
+ const targetAtPoint = document.elementFromPoint(x, y);
1261
+ const targetKeys = this.getPreviewGroupKeysForTarget(targetAtPoint);
1262
+ if (targetKeys.length > 0) {
1263
+ return targetKeys;
1264
+ }
1265
+
1266
+ const matchingKeys = this.previewHoverRegions
1267
+ .filter((region) => region.rects.some((rect) => pointIntersectsRect(x, y, rect)))
1268
+ .map((region) => region.key);
1269
+ return normalizeGroupKeys(matchingKeys);
1270
+ }
1271
+
1272
+ updateHoveredPreviewGroups(nextKeys = []) {
1273
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1274
+ const signature = normalizedKeys.join(' ');
1275
+ if (signature === this.hoveredPreviewGroupKeysSignature) {
1276
+ return;
1277
+ }
1278
+
1279
+ this.hoveredPreviewGroupKeys = normalizedKeys;
1280
+ this.hoveredPreviewGroupKeysSignature = signature;
1281
+ this.scheduleLayoutRefresh();
1282
+ }
1283
+
1284
+ updateHoveredEditorGroups(nextKeys = []) {
1285
+ const normalizedKeys = normalizeGroupKeys(nextKeys);
1286
+ const signature = normalizedKeys.join(' ');
1287
+ if (signature === this.hoveredEditorGroupKeysSignature) {
1288
+ return;
1289
+ }
1290
+
1291
+ this.hoveredEditorGroupKeys = normalizedKeys;
1292
+ this.hoveredEditorGroupKeysSignature = signature;
1293
+ this.scheduleLayoutRefresh();
1294
+ }
1295
+
1296
+ syncHoveredPreviewGroupsFromTarget(target) {
1297
+ this.updateHoveredPreviewGroups(this.getPreviewGroupKeysForTarget(target));
1298
+ }
1299
+
1300
+ shouldRenderPassivePreviewMarkers() {
1301
+ const previewWidth = this.previewContainer?.clientWidth ?? this.previewElement?.clientWidth ?? 0;
1302
+ return window.innerWidth >= COMMENT_PREVIEW_RAIL_BREAKPOINT && previewWidth >= COMMENT_PREVIEW_RAIL_MIN_WIDTH;
1303
+ }
1304
+
1118
1305
  updateReactionPickerPosition(card) {
1119
1306
  const bounds = getReactionPickerBounds(card);
1120
1307
  if (!bounds) {
@@ -54,13 +54,20 @@ export class FileExplorerController {
54
54
  async refresh() {
55
55
  try {
56
56
  const data = await this.vaultClient.readTree();
57
- this.state.setTree(data.tree || []);
58
- this.renderTree();
57
+ this.setTree(data.tree || []);
59
58
  } catch (error) {
60
59
  console.error('[explorer] Failed to load file tree:', error.message);
61
60
  }
62
61
  }
63
62
 
63
+ setTree(tree, {
64
+ changedPaths = null,
65
+ reset = false,
66
+ } = {}) {
67
+ this.state.setTree(tree);
68
+ this.renderTree({ changedPaths, reset });
69
+ }
70
+
64
71
  setActiveFile(filePath) {
65
72
  this.state.setActiveFile(filePath);
66
73
  this.renderTree();
@@ -74,10 +81,15 @@ export class FileExplorerController {
74
81
  return this.state.flatFiles.filter((path) => !isImageAttachmentFilePath(path));
75
82
  }
76
83
 
77
- renderTree() {
84
+ renderTree({
85
+ changedPaths = null,
86
+ reset = false,
87
+ } = {}) {
78
88
  this.view.render({
79
89
  activeFilePath: this.state.activeFilePath,
90
+ changedPaths,
80
91
  expandedDirs: this.state.expandedDirs,
92
+ reset,
81
93
  searchMatches: this.state.getSearchMatches(),
82
94
  searchQuery: this.state.searchQuery,
83
95
  tree: this.state.tree,