collabmd 0.1.18 → 0.1.20

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 (64) hide show
  1. package/README.md +107 -25
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
  6. package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
  7. package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
  8. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +22 -0
  9. package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
  10. package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
  11. package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +100 -73
  14. package/public/assets/js/preview-render-worker.js +19 -19
  15. package/public/index.html +14 -4
  16. package/src/client/application/app-shell/git-feature.js +29 -1
  17. package/src/client/application/app-shell/presence-feature.js +2 -1
  18. package/src/client/application/app-shell/ui-feature.js +113 -1
  19. package/src/client/application/app-shell/workspace-feature.js +9 -0
  20. package/src/client/application/app-shell-elements.js +1 -0
  21. package/src/client/application/preview-render-compiler.js +55 -5
  22. package/src/client/application/preview-render-executor.js +8 -0
  23. package/src/client/application/preview-render-worker.js +13 -2
  24. package/src/client/application/preview-renderer.js +5 -0
  25. package/src/client/application/workspace-chrome-controller.js +12 -1
  26. package/src/client/application/workspace-coordinator.js +16 -7
  27. package/src/client/application/workspace-preview-controller.js +45 -5
  28. package/src/client/application/workspace-route-controller.js +4 -0
  29. package/src/client/bootstrap/collabmd-app-shell.js +19 -3
  30. package/src/client/domain/room.js +37 -0
  31. package/src/client/domain/vault-utils.js +46 -0
  32. package/src/client/infrastructure/comment-thread-store.js +98 -0
  33. package/src/client/infrastructure/editor-paste-utils.js +45 -0
  34. package/src/client/infrastructure/editor-session.js +10 -0
  35. package/src/client/infrastructure/editor-view-adapter.js +98 -7
  36. package/src/client/infrastructure/vault-api-client.js +17 -0
  37. package/src/client/presentation/backlinks-panel.js +157 -101
  38. package/src/client/presentation/comment-markdown-renderer.js +68 -0
  39. package/src/client/presentation/comment-ui-controller.js +352 -20
  40. package/src/client/presentation/file-explorer-controller.js +5 -0
  41. package/src/client/presentation/file-explorer-view.js +10 -2
  42. package/src/client/presentation/file-tree-state.js +7 -1
  43. package/src/client/presentation/git-panel-controller.js +73 -0
  44. package/src/client/presentation/image-lightbox-controller.js +394 -0
  45. package/src/client/presentation/outline-controller.js +25 -0
  46. package/src/client/styles/style.css +677 -21
  47. package/src/domain/comment-threads.js +95 -13
  48. package/src/domain/file-kind.js +16 -1
  49. package/src/server/infrastructure/git/errors.js +4 -1
  50. package/src/server/infrastructure/git/git-service.js +215 -1
  51. package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
  52. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  53. package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
  54. package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
  55. package/src/server/infrastructure/http/request-body.js +11 -2
  56. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  57. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  58. package/src/server/infrastructure/persistence/vault-file-store.js +203 -2
  59. package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
  60. package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
  61. package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
  62. package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
  63. package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
  64. package/public/assets/js/chunks/quick-switcher-controller-JYDIJVAJ.js +0 -5
@@ -2,12 +2,15 @@ import {
2
2
  COMMENT_BODY_MAX_LENGTH,
3
3
  normalizeCommentQuoteForComparison,
4
4
  } from '../../domain/comment-threads.js';
5
+ import { renderCommentMarkdownToHtml } from './comment-markdown-renderer.js';
5
6
 
6
7
  const COMMENT_CARD_OFFSET = 14;
7
- const COMMENT_CARD_WIDTH = 360;
8
+ const COMMENT_CARD_WIDTH = 520;
8
9
  const COMMENT_SELECTION_REVEAL_DELAY_MS = 150;
9
10
  const COMMENT_SELECTION_CHIP_GAP = 12;
10
11
  const COMMENT_CONTROL_SLOT_HEIGHT = 36;
12
+ const COMMENT_REACTION_PRESET_EMOJIS = Object.freeze(['👍', '❤️', '🎉', '👀', '🚀']);
13
+ const COMMENT_REACTION_MORE_EMOJIS = Object.freeze(['😂', '🔥', '✅', '🙏', '💡', '🤔', '👏', '😄', '🎯', '🙌']);
11
14
 
12
15
  function clamp(value, min, max) {
13
16
  return Math.min(Math.max(value, min), max);
@@ -81,6 +84,61 @@ function parseLineNumber(value) {
81
84
  return Number.isFinite(parsed) ? parsed : null;
82
85
  }
83
86
 
87
+ function getLatestMessage(messages = []) {
88
+ return messages.reduce((latest, message) => {
89
+ if (!latest) {
90
+ return message;
91
+ }
92
+
93
+ return (message?.createdAt ?? 0) >= (latest?.createdAt ?? 0)
94
+ ? message
95
+ : latest;
96
+ }, null);
97
+ }
98
+
99
+ function getLatestGroupMessage(group) {
100
+ return group?.threads?.reduce((latest, thread) => {
101
+ const next = getLatestMessage(thread?.messages ?? []);
102
+ if (!next) {
103
+ return latest;
104
+ }
105
+
106
+ return (next.createdAt ?? 0) >= (latest?.createdAt ?? 0)
107
+ ? next
108
+ : latest;
109
+ }, null);
110
+ }
111
+
112
+ function createRenderedCommentBody(body, className = 'comment-markdown') {
113
+ const container = document.createElement('div');
114
+ container.className = className;
115
+ container.innerHTML = renderCommentMarkdownToHtml(body);
116
+ return container;
117
+ }
118
+
119
+ function hasLocalReaction(reaction, localUserId) {
120
+ return Boolean(localUserId && reaction?.users?.some((user) => user?.userId === localUserId));
121
+ }
122
+
123
+ function formatReactionCount(reaction) {
124
+ return String(Array.isArray(reaction?.users) ? reaction.users.length : 0);
125
+ }
126
+
127
+ function isReactionPickerOpen(reactionPicker, threadId, messageId) {
128
+ return reactionPicker?.threadId === threadId && reactionPicker?.messageId === messageId;
129
+ }
130
+
131
+ function getReactionPickerBounds(card) {
132
+ const picker = card?.querySelector?.('.comment-reaction-picker');
133
+ const wrap = picker?.closest?.('.comment-reaction-picker-wrap');
134
+ const scroll = card?.querySelector?.('.comment-card-scroll');
135
+ if (!(picker instanceof HTMLElement) || !(wrap instanceof HTMLElement) || !(scroll instanceof HTMLElement)) {
136
+ return null;
137
+ }
138
+
139
+ return { picker, scroll, wrap };
140
+ }
141
+
84
142
  function overlapsAnchorRange(element, anchor) {
85
143
  const startLine = parseLineNumber(element?.getAttribute?.('data-source-line'));
86
144
  const endLine = parseLineNumber(element?.getAttribute?.('data-source-line-end')) ?? startLine;
@@ -221,9 +279,11 @@ export class CommentUiController {
221
279
  commentsDrawerList,
222
280
  commentsToggleButton,
223
281
  editorContainer,
282
+ onWillOpenDrawer,
224
283
  onCreateThread,
225
284
  onNavigateToLine,
226
285
  onReplyToThread,
286
+ onToggleReaction,
227
287
  onResolveThread,
228
288
  previewContainer,
229
289
  previewElement,
@@ -234,9 +294,11 @@ export class CommentUiController {
234
294
  this.commentsDrawerList = commentsDrawerList;
235
295
  this.commentsToggleButton = commentsToggleButton;
236
296
  this.editorContainer = editorContainer;
297
+ this.onWillOpenDrawer = onWillOpenDrawer;
237
298
  this.onCreateThread = onCreateThread;
238
299
  this.onNavigateToLine = onNavigateToLine;
239
300
  this.onReplyToThread = onReplyToThread;
301
+ this.onToggleReaction = onToggleReaction;
240
302
  this.onResolveThread = onResolveThread;
241
303
  this.previewContainer = previewContainer;
242
304
  this.previewElement = previewElement;
@@ -258,6 +320,7 @@ export class CommentUiController {
258
320
  this.previewHighlightLayer = null;
259
321
  this.cardRoot = null;
260
322
  this.pendingCardFocusElement = null;
323
+ this.reactionPicker = null;
261
324
  this.layoutFrame = 0;
262
325
  this.timeFormatter = new Intl.DateTimeFormat(undefined, {
263
326
  day: 'numeric',
@@ -268,6 +331,9 @@ export class CommentUiController {
268
331
  this.handleEditorScroll = () => this.scheduleLayoutRefresh();
269
332
  this.handlePreviewScroll = () => this.scheduleLayoutRefresh();
270
333
  this.handleWindowResize = () => this.scheduleLayoutRefresh();
334
+ this.handleCommentSelectionButtonPointerDown = (event) => {
335
+ event.preventDefault();
336
+ };
271
337
  this.handleEditorPointerDown = (event) => {
272
338
  if (event.button !== 0 || !this.supported || !this.session) {
273
339
  return;
@@ -320,15 +386,37 @@ export class CommentUiController {
320
386
  this.scheduleLayoutRefresh();
321
387
  };
322
388
  this.handleDocumentPointerDown = (event) => {
323
- if (!this.activeCard || !this.cardRoot) {
389
+ const target = event.target;
390
+
391
+ if (this.activeCard && this.cardRoot) {
392
+ if (this.cardRoot.contains(target)) {
393
+ return;
394
+ }
395
+ this.closeCard();
396
+ }
397
+
398
+ if (!this.drawerOpen) {
324
399
  return;
325
400
  }
326
- if (this.cardRoot.contains(event.target)) {
401
+
402
+ if (
403
+ target instanceof Node
404
+ && (
405
+ this.commentsDrawer?.contains(target)
406
+ || this.commentsToggleButton?.contains(target)
407
+ )
408
+ ) {
327
409
  return;
328
410
  }
329
- this.closeCard();
411
+
412
+ this.closeDrawer();
330
413
  };
331
414
  this.handleDocumentKeyDown = (event) => {
415
+ if (event.key === 'Escape' && this.reactionPicker) {
416
+ this.reactionPicker = null;
417
+ this.renderCard();
418
+ return;
419
+ }
332
420
  if (event.key === 'Escape' && this.activeCard) {
333
421
  this.closeCard();
334
422
  }
@@ -340,12 +428,12 @@ export class CommentUiController {
340
428
  }
341
429
  };
342
430
 
431
+ this.commentSelectionButton?.addEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
343
432
  this.commentSelectionButton?.addEventListener('click', () => {
344
433
  this.openComposerForSelection('toolbar');
345
434
  });
346
435
  this.commentsToggleButton?.addEventListener('click', () => {
347
- this.drawerOpen = !this.drawerOpen;
348
- this.render();
436
+ this.setDrawerOpen(!this.drawerOpen);
349
437
  });
350
438
  this.previewContainer?.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
351
439
  this.editorContainer?.addEventListener('pointerdown', this.handleEditorPointerDown);
@@ -364,6 +452,7 @@ export class CommentUiController {
364
452
  }
365
453
  this.attachSession(null);
366
454
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
455
+ this.commentSelectionButton?.removeEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
367
456
  this.editorContainer?.removeEventListener('pointerdown', this.handleEditorPointerDown);
368
457
  this.editorContainer?.removeEventListener('focusout', this.handleEditorFocusOut);
369
458
  window.removeEventListener('resize', this.handleWindowResize);
@@ -383,6 +472,7 @@ export class CommentUiController {
383
472
  this.selectionAnchor = session?.getCurrentSelectionCommentAnchor?.() ?? null;
384
473
  this.pendingSelectionAnchor = null;
385
474
  this.committedSelectionAnchor = null;
475
+ this.reactionPicker = null;
386
476
  this.clearSelectionRevealTimer();
387
477
  this.pointerSelecting = false;
388
478
  session?.getScrollContainer?.()?.addEventListener('scroll', this.handleEditorScroll, { passive: true });
@@ -403,6 +493,7 @@ export class CommentUiController {
403
493
  this.clearSelectionRevealTimer();
404
494
  this.pointerSelecting = false;
405
495
  this.activeCard = null;
496
+ this.reactionPicker = null;
406
497
  }
407
498
  if (!this.supported) {
408
499
  this.drawerOpen = false;
@@ -413,6 +504,7 @@ export class CommentUiController {
413
504
  this.committedSelectionAnchor = null;
414
505
  this.clearSelectionRevealTimer();
415
506
  this.pointerSelecting = false;
507
+ this.reactionPicker = null;
416
508
  }
417
509
  this.render();
418
510
  }
@@ -483,9 +575,36 @@ export class CommentUiController {
483
575
  ) {
484
576
  this.activeCard = null;
485
577
  }
578
+ if (
579
+ this.reactionPicker
580
+ && !this.threads.some((thread) => (
581
+ thread.id === this.reactionPicker.threadId
582
+ && thread.messages?.some((message) => message.id === this.reactionPicker.messageId)
583
+ ))
584
+ ) {
585
+ this.reactionPicker = null;
586
+ }
587
+ this.render();
588
+ }
589
+
590
+ setDrawerOpen(nextState) {
591
+ const normalizedState = Boolean(nextState);
592
+ if (normalizedState === this.drawerOpen) {
593
+ return;
594
+ }
595
+
596
+ if (normalizedState) {
597
+ this.onWillOpenDrawer?.();
598
+ }
599
+
600
+ this.drawerOpen = normalizedState;
486
601
  this.render();
487
602
  }
488
603
 
604
+ closeDrawer() {
605
+ this.setDrawerOpen(false);
606
+ }
607
+
489
608
  refreshLayout() {
490
609
  this.renderEditorLayer();
491
610
  this.renderPreviewLayer();
@@ -578,11 +697,26 @@ export class CommentUiController {
578
697
  quote.className = 'comments-drawer-item-quote';
579
698
  quote.textContent = group.anchor.quote || group.anchor.excerpt || 'Source anchored comment';
580
699
 
700
+ const latestMessage = getLatestGroupMessage(group);
701
+ const preview = createRenderedCommentBody(
702
+ latestMessage?.body || '',
703
+ 'comment-markdown comments-drawer-item-preview',
704
+ );
705
+
581
706
  const footer = document.createElement('div');
582
707
  footer.className = 'comments-drawer-item-footer';
583
- footer.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
708
+ const countLabel = document.createElement('span');
709
+ countLabel.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
584
710
 
585
- button.append(header, quote, footer);
711
+ const updatedLabel = document.createElement('span');
712
+ updatedLabel.className = 'comments-drawer-item-updated';
713
+ updatedLabel.textContent = latestMessage
714
+ ? `${latestMessage.userName} • ${this.formatTimestamp(latestMessage.createdAt)}`
715
+ : '';
716
+
717
+ footer.append(countLabel, updatedLabel);
718
+
719
+ button.append(header, quote, preview, footer);
586
720
  fragment.appendChild(button);
587
721
  });
588
722
 
@@ -811,6 +945,7 @@ export class CommentUiController {
811
945
  }
812
946
 
813
947
  this.selectionAnchor = anchor;
948
+ this.reactionPicker = null;
814
949
  const nextOrigin = origin === 'editor' && sourceRect ? 'editor-chip' : origin;
815
950
  const nextSourceRect = sourceRect ?? (origin === 'toolbar'
816
951
  ? this.commentSelectionButton?.getBoundingClientRect?.()
@@ -826,6 +961,7 @@ export class CommentUiController {
826
961
  }
827
962
 
828
963
  openThreadGroup(group, { anchor, origin, sourceRect }) {
964
+ this.reactionPicker = null;
829
965
  this.activeCard = {
830
966
  anchor,
831
967
  groupKey: group.key,
@@ -841,6 +977,7 @@ export class CommentUiController {
841
977
  closeCard() {
842
978
  this.activeCard = null;
843
979
  this.pendingCardFocusElement = null;
980
+ this.reactionPicker = null;
844
981
  this.renderCard();
845
982
  this.scheduleLayoutRefresh();
846
983
  }
@@ -902,6 +1039,21 @@ export class CommentUiController {
902
1039
 
903
1040
  const card = document.createElement('section');
904
1041
  card.className = 'comment-card';
1042
+ card.addEventListener('click', (event) => {
1043
+ if (
1044
+ !this.reactionPicker
1045
+ || event.target?.closest?.('.comment-reaction-picker-wrap')
1046
+ ) {
1047
+ return;
1048
+ }
1049
+
1050
+ this.reactionPicker = null;
1051
+ requestAnimationFrame(() => {
1052
+ if (this.activeCard) {
1053
+ this.renderCard();
1054
+ }
1055
+ });
1056
+ });
905
1057
 
906
1058
  const header = document.createElement('div');
907
1059
  header.className = 'comment-card-header';
@@ -929,15 +1081,18 @@ export class CommentUiController {
929
1081
  header.append(titleWrap, closeButton);
930
1082
  card.appendChild(header);
931
1083
 
1084
+ const content = document.createElement('div');
1085
+ content.className = 'comment-card-scroll';
1086
+
932
1087
  if (this.activeCard.anchor?.quote) {
933
1088
  const quote = document.createElement('p');
934
1089
  quote.className = 'comment-card-quote';
935
1090
  quote.textContent = this.activeCard.anchor.quote;
936
- card.appendChild(quote);
1091
+ content.appendChild(quote);
937
1092
  }
938
1093
 
939
1094
  if (this.activeCard.mode === 'create') {
940
- card.appendChild(this.createComposer());
1095
+ content.appendChild(this.createComposer());
941
1096
  } else {
942
1097
  const group = this.getThreadGroups().find((entry) => entry.key === this.activeCard.groupKey);
943
1098
  if (!group) {
@@ -946,16 +1101,46 @@ export class CommentUiController {
946
1101
  }
947
1102
 
948
1103
  group.threads.forEach((thread) => {
949
- card.appendChild(this.createThreadElement(thread));
1104
+ content.appendChild(this.createThreadElement(thread));
950
1105
  });
951
1106
  }
952
1107
 
1108
+ root.style.visibility = 'hidden';
1109
+ card.appendChild(content);
953
1110
  root.appendChild(card);
1111
+ this.updateReactionPickerPosition(card);
1112
+ this.positionCard(card);
1113
+ root.style.visibility = '';
954
1114
  this.flushPendingCardFocus();
955
- root.style.visibility = 'hidden';
956
1115
  this.scheduleLayoutRefresh();
957
1116
  }
958
1117
 
1118
+ updateReactionPickerPosition(card) {
1119
+ const bounds = getReactionPickerBounds(card);
1120
+ if (!bounds) {
1121
+ return;
1122
+ }
1123
+
1124
+ const { picker, scroll, wrap } = bounds;
1125
+ picker.classList.remove('is-upward');
1126
+ picker.style.maxHeight = '';
1127
+
1128
+ const wrapRect = wrap.getBoundingClientRect();
1129
+ const scrollRect = scroll.getBoundingClientRect();
1130
+ const pickerRect = picker.getBoundingClientRect();
1131
+ const safeViewportTop = 12;
1132
+ const safeViewportBottom = window.innerHeight - 12;
1133
+ const lowerBoundary = Math.min(scrollRect.bottom, safeViewportBottom);
1134
+ const upperBoundary = Math.max(scrollRect.top, safeViewportTop);
1135
+ const availableBelow = Math.max(lowerBoundary - wrapRect.bottom - 8, 0);
1136
+ const availableAbove = Math.max(wrapRect.top - upperBoundary - 8, 0);
1137
+ const shouldOpenUpward = pickerRect.height > availableBelow && availableAbove > availableBelow;
1138
+ const maxHeight = Math.max((shouldOpenUpward ? availableAbove : availableBelow), 120);
1139
+
1140
+ picker.classList.toggle('is-upward', shouldOpenUpward);
1141
+ picker.style.maxHeight = `${maxHeight}px`;
1142
+ }
1143
+
959
1144
  repositionActiveCard() {
960
1145
  const card = this.cardRoot?.firstElementChild;
961
1146
  if (!card || !this.activeCard) {
@@ -1022,6 +1207,9 @@ export class CommentUiController {
1022
1207
  const header = document.createElement('div');
1023
1208
  header.className = 'comment-thread-card-header';
1024
1209
 
1210
+ const heading = document.createElement('div');
1211
+ heading.className = 'comment-thread-card-heading';
1212
+
1025
1213
  const author = document.createElement('span');
1026
1214
  author.className = 'comment-thread-card-author';
1027
1215
  author.textContent = thread.createdByName;
@@ -1065,12 +1253,13 @@ export class CommentUiController {
1065
1253
  });
1066
1254
 
1067
1255
  actions.append(jump, reply, resolve);
1068
- header.append(author, time, actions);
1256
+ heading.append(author, time);
1257
+ header.append(heading, actions);
1069
1258
 
1070
1259
  article.append(header);
1071
1260
 
1072
1261
  thread.messages.forEach((message) => {
1073
- article.appendChild(this.createMessageElement(message));
1262
+ article.appendChild(this.createMessageElement(thread, message));
1074
1263
  });
1075
1264
 
1076
1265
  if (this.activeCard?.replyThreadId === thread.id) {
@@ -1080,7 +1269,7 @@ export class CommentUiController {
1080
1269
  return article;
1081
1270
  }
1082
1271
 
1083
- createMessageElement(message) {
1272
+ createMessageElement(thread, message) {
1084
1273
  const container = document.createElement('div');
1085
1274
  container.className = 'comment-message-card';
1086
1275
 
@@ -1095,15 +1284,133 @@ export class CommentUiController {
1095
1284
  time.className = 'comment-message-card-time';
1096
1285
  time.textContent = this.formatTimestamp(message.createdAt);
1097
1286
 
1098
- const body = document.createElement('p');
1099
- body.className = 'comment-message-card-body';
1100
- body.textContent = message.body;
1287
+ const renderedBody = createRenderedCommentBody(
1288
+ message.body,
1289
+ 'comment-message-card-body comment-markdown',
1290
+ );
1101
1291
 
1102
1292
  meta.append(author, time);
1103
- container.append(meta, body);
1293
+ container.append(meta, renderedBody);
1294
+ container.appendChild(this.createReactionBar(thread, message));
1104
1295
  return container;
1105
1296
  }
1106
1297
 
1298
+ createReactionBar(thread, message) {
1299
+ const localUserId = this.session?.getLocalUser?.()?.userId ?? '';
1300
+ const wrap = document.createElement('div');
1301
+ wrap.className = 'comment-reaction-bar';
1302
+
1303
+ const existingReactionEmojis = new Set((message.reactions ?? []).map((reaction) => reaction.emoji));
1304
+
1305
+ const chips = document.createElement('div');
1306
+ chips.className = 'comment-reaction-chips';
1307
+
1308
+ (message.reactions ?? []).forEach((reaction) => {
1309
+ const chip = document.createElement('button');
1310
+ chip.type = 'button';
1311
+ chip.className = 'comment-reaction-chip';
1312
+ chip.classList.toggle('is-active', hasLocalReaction(reaction, localUserId));
1313
+ chip.setAttribute('aria-pressed', String(hasLocalReaction(reaction, localUserId)));
1314
+ chip.title = reaction.users?.map((user) => user.userName).join(', ') || reaction.emoji;
1315
+
1316
+ const emoji = document.createElement('span');
1317
+ emoji.className = 'comment-reaction-chip-emoji';
1318
+ emoji.textContent = reaction.emoji;
1319
+
1320
+ const count = document.createElement('span');
1321
+ count.className = 'comment-reaction-chip-count';
1322
+ count.textContent = formatReactionCount(reaction);
1323
+
1324
+ chip.append(emoji, count);
1325
+ chip.addEventListener('click', async () => {
1326
+ await this.onToggleReaction?.(thread.id, message.id, reaction.emoji);
1327
+ });
1328
+ chips.appendChild(chip);
1329
+ });
1330
+
1331
+ const actions = document.createElement('div');
1332
+ actions.className = 'comment-reaction-actions';
1333
+
1334
+ COMMENT_REACTION_PRESET_EMOJIS
1335
+ .filter((emoji) => !existingReactionEmojis.has(emoji))
1336
+ .forEach((emoji) => {
1337
+ actions.appendChild(this.createQuickReactionButton(thread, message, emoji));
1338
+ });
1339
+
1340
+ const pickerWrap = document.createElement('div');
1341
+ pickerWrap.className = 'comment-reaction-picker-wrap';
1342
+
1343
+ const moreButton = document.createElement('button');
1344
+ moreButton.type = 'button';
1345
+ moreButton.className = 'comment-reaction-more-trigger';
1346
+ moreButton.dataset.reactionPickerToggle = 'true';
1347
+ moreButton.setAttribute('aria-expanded', String(
1348
+ isReactionPickerOpen(this.reactionPicker, thread.id, message.id),
1349
+ ));
1350
+ moreButton.textContent = 'More';
1351
+ moreButton.addEventListener('click', () => {
1352
+ const isOpen = isReactionPickerOpen(this.reactionPicker, thread.id, message.id);
1353
+ this.reactionPicker = isOpen
1354
+ ? null
1355
+ : {
1356
+ messageId: message.id,
1357
+ threadId: thread.id,
1358
+ };
1359
+ this.renderCard();
1360
+ });
1361
+
1362
+ pickerWrap.appendChild(moreButton);
1363
+
1364
+ if (isReactionPickerOpen(this.reactionPicker, thread.id, message.id)) {
1365
+ pickerWrap.appendChild(this.createReactionPicker(thread, message));
1366
+ }
1367
+
1368
+ actions.appendChild(pickerWrap);
1369
+ wrap.append(chips, actions);
1370
+ return wrap;
1371
+ }
1372
+
1373
+ createQuickReactionButton(thread, message, emoji) {
1374
+ const button = document.createElement('button');
1375
+ button.type = 'button';
1376
+ button.className = 'comment-reaction-quick-add';
1377
+ button.textContent = emoji;
1378
+ button.title = `React with ${emoji}`;
1379
+ button.addEventListener('click', async () => {
1380
+ await this.onToggleReaction?.(thread.id, message.id, emoji);
1381
+ });
1382
+ return button;
1383
+ }
1384
+
1385
+ createReactionPicker(thread, message) {
1386
+ const picker = document.createElement('div');
1387
+ picker.className = 'comment-reaction-picker';
1388
+ const moreGrid = document.createElement('div');
1389
+ moreGrid.className = 'comment-reaction-picker-grid';
1390
+ COMMENT_REACTION_MORE_EMOJIS.forEach((emoji) => {
1391
+ moreGrid.appendChild(this.createReactionPickerButton(thread, message, emoji));
1392
+ });
1393
+ picker.appendChild(moreGrid);
1394
+
1395
+ return picker;
1396
+ }
1397
+
1398
+ createReactionPickerButton(thread, message, emoji) {
1399
+ const button = document.createElement('button');
1400
+ button.type = 'button';
1401
+ button.className = 'comment-reaction-picker-btn';
1402
+ button.textContent = emoji;
1403
+ button.title = `React with ${emoji}`;
1404
+ button.addEventListener('click', async () => {
1405
+ const didToggle = await this.onToggleReaction?.(thread.id, message.id, emoji);
1406
+ if (didToggle) {
1407
+ this.reactionPicker = null;
1408
+ this.renderCard();
1409
+ }
1410
+ });
1411
+ return button;
1412
+ }
1413
+
1107
1414
  createReplyComposer(thread) {
1108
1415
  const form = document.createElement('form');
1109
1416
  form.className = 'comment-reply-form';
@@ -1204,7 +1511,32 @@ export class CommentUiController {
1204
1511
  }
1205
1512
 
1206
1513
  this.pendingCardFocusElement = null;
1207
- element.focus({ preventScroll: true });
1514
+ const focusElement = () => {
1515
+ if (!element.isConnected) {
1516
+ return;
1517
+ }
1518
+
1519
+ const activeElement = document.activeElement;
1520
+ if (activeElement instanceof HTMLElement && this.editorContainer?.contains(activeElement)) {
1521
+ activeElement.blur();
1522
+ }
1523
+
1524
+ element.focus({ preventScroll: true });
1525
+ };
1526
+
1527
+ focusElement();
1528
+ if (document.activeElement === element) {
1529
+ return;
1530
+ }
1531
+
1532
+ requestAnimationFrame(() => {
1533
+ focusElement();
1534
+ if (document.activeElement !== element) {
1535
+ setTimeout(() => {
1536
+ focusElement();
1537
+ }, 50);
1538
+ }
1539
+ });
1208
1540
  }
1209
1541
 
1210
1542
  clearSelectionRevealTimer() {
@@ -1,3 +1,4 @@
1
+ import { isImageAttachmentFilePath } from '../../domain/file-kind.js';
1
2
  import { vaultApiClient } from '../domain/vault-api-client.js';
2
3
  import { FileActionController } from './file-action-controller.js';
3
4
  import { FileTreeState } from './file-tree-state.js';
@@ -69,6 +70,10 @@ export class FileExplorerController {
69
70
  return this.state.flatFiles;
70
71
  }
71
72
 
73
+ get flatDocumentFiles() {
74
+ return this.state.flatFiles.filter((path) => !isImageAttachmentFilePath(path));
75
+ }
76
+
72
77
  renderTree() {
73
78
  this.view.render({
74
79
  activeFilePath: this.state.activeFilePath,
@@ -161,12 +161,16 @@ export class FileExplorerView {
161
161
  const button = document.createElement('button');
162
162
  button.className = 'file-tree-item file-tree-file';
163
163
  const isExcalidraw = fileType === 'excalidraw';
164
+ const isImage = fileType === 'image';
164
165
  const isMermaid = fileType === 'mermaid';
165
166
  const isPlantUml = fileType === 'plantuml';
166
167
 
167
168
  if (isExcalidraw) {
168
169
  button.classList.add('is-excalidraw');
169
170
  }
171
+ if (isImage) {
172
+ button.classList.add('is-image');
173
+ }
170
174
  if (isMermaid) {
171
175
  button.classList.add('is-mermaid');
172
176
  }
@@ -181,7 +185,7 @@ export class FileExplorerView {
181
185
  button.dataset.depth = depth;
182
186
  button.dataset.path = filePath;
183
187
  button.innerHTML = `
184
- ${this.getFileIconSvg({ isExcalidraw, isMermaid, isPlantUml })}
188
+ ${this.getFileIconSvg({ isExcalidraw, isImage, isMermaid, isPlantUml })}
185
189
  <span class="file-tree-name">${escapeHtml(stripVaultFileExtension(name))}</span>
186
190
  `;
187
191
 
@@ -196,11 +200,15 @@ export class FileExplorerView {
196
200
  return button;
197
201
  }
198
202
 
199
- getFileIconSvg({ isExcalidraw, isMermaid, isPlantUml }) {
203
+ getFileIconSvg({ isExcalidraw, isImage, isMermaid, isPlantUml }) {
200
204
  if (isExcalidraw) {
201
205
  return '<svg class="file-tree-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>';
202
206
  }
203
207
 
208
+ if (isImage) {
209
+ return '<svg class="file-tree-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="m21 16-5-5L7 20"/><path d="m14 14 2 2"/></svg>';
210
+ }
211
+
204
212
  if (isMermaid) {
205
213
  return '<svg class="file-tree-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 7.5c0-1.38 1.12-2.5 2.5-2.5 1.04 0 1.93.64 2.3 1.56A2.5 2.5 0 0 1 14 8.5v1"/><path d="M19 16.5c0 1.38-1.12 2.5-2.5 2.5-1.04 0-1.93-.64-2.3-1.56A2.5 2.5 0 0 1 10 15.5v-1"/><path d="M8 10.5h8"/><path d="M8 13.5h8"/><path d="M10 8.5v7"/><path d="M14 8.5v7"/></svg>';
206
214
  }
@@ -2,7 +2,13 @@ import { normalizeVaultPathInput } from '../domain/vault-paths.js';
2
2
 
3
3
  function flattenTree(nodes, files = []) {
4
4
  for (const node of nodes) {
5
- if (node.type === 'file' || node.type === 'excalidraw' || node.type === 'mermaid' || node.type === 'plantuml') {
5
+ if (
6
+ node.type === 'file'
7
+ || node.type === 'excalidraw'
8
+ || node.type === 'mermaid'
9
+ || node.type === 'plantuml'
10
+ || node.type === 'image'
11
+ ) {
6
12
  files.push(node.path);
7
13
  continue;
8
14
  }