collabmd 0.1.18 → 0.1.19

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 (52) hide show
  1. package/README.md +99 -17
  2. package/package.json +1 -1
  3. package/public/assets/css/style.css +1 -1
  4. package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
  5. package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
  6. package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
  7. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +22 -0
  8. package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
  9. package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
  10. package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
  11. package/public/assets/js/excalidraw-editor.js +1 -1
  12. package/public/assets/js/main.js +59 -59
  13. package/public/assets/js/preview-render-worker.js +19 -19
  14. package/src/client/application/app-shell/presence-feature.js +2 -1
  15. package/src/client/application/app-shell/ui-feature.js +113 -1
  16. package/src/client/application/app-shell/workspace-feature.js +9 -0
  17. package/src/client/application/preview-render-compiler.js +55 -5
  18. package/src/client/application/preview-render-executor.js +8 -0
  19. package/src/client/application/preview-render-worker.js +13 -2
  20. package/src/client/application/preview-renderer.js +5 -0
  21. package/src/client/application/workspace-chrome-controller.js +12 -1
  22. package/src/client/application/workspace-coordinator.js +16 -7
  23. package/src/client/application/workspace-preview-controller.js +45 -5
  24. package/src/client/application/workspace-route-controller.js +4 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +15 -3
  26. package/src/client/domain/room.js +37 -0
  27. package/src/client/domain/vault-utils.js +46 -0
  28. package/src/client/infrastructure/comment-thread-store.js +98 -0
  29. package/src/client/infrastructure/editor-paste-utils.js +45 -0
  30. package/src/client/infrastructure/editor-session.js +10 -0
  31. package/src/client/infrastructure/editor-view-adapter.js +34 -1
  32. package/src/client/infrastructure/vault-api-client.js +17 -0
  33. package/src/client/presentation/comment-markdown-renderer.js +68 -0
  34. package/src/client/presentation/comment-ui-controller.js +311 -15
  35. package/src/client/presentation/file-explorer-controller.js +5 -0
  36. package/src/client/presentation/file-explorer-view.js +10 -2
  37. package/src/client/presentation/file-tree-state.js +7 -1
  38. package/src/client/presentation/image-lightbox-controller.js +394 -0
  39. package/src/client/styles/style.css +539 -14
  40. package/src/domain/comment-threads.js +95 -13
  41. package/src/domain/file-kind.js +16 -1
  42. package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
  43. package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
  44. package/src/server/infrastructure/http/request-body.js +11 -2
  45. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  46. package/src/server/infrastructure/persistence/vault-file-store.js +187 -2
  47. package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
  48. package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
  49. package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
  50. package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
  51. package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
  52. 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;
@@ -224,6 +282,7 @@ export class CommentUiController {
224
282
  onCreateThread,
225
283
  onNavigateToLine,
226
284
  onReplyToThread,
285
+ onToggleReaction,
227
286
  onResolveThread,
228
287
  previewContainer,
229
288
  previewElement,
@@ -237,6 +296,7 @@ export class CommentUiController {
237
296
  this.onCreateThread = onCreateThread;
238
297
  this.onNavigateToLine = onNavigateToLine;
239
298
  this.onReplyToThread = onReplyToThread;
299
+ this.onToggleReaction = onToggleReaction;
240
300
  this.onResolveThread = onResolveThread;
241
301
  this.previewContainer = previewContainer;
242
302
  this.previewElement = previewElement;
@@ -258,6 +318,7 @@ export class CommentUiController {
258
318
  this.previewHighlightLayer = null;
259
319
  this.cardRoot = null;
260
320
  this.pendingCardFocusElement = null;
321
+ this.reactionPicker = null;
261
322
  this.layoutFrame = 0;
262
323
  this.timeFormatter = new Intl.DateTimeFormat(undefined, {
263
324
  day: 'numeric',
@@ -268,6 +329,9 @@ export class CommentUiController {
268
329
  this.handleEditorScroll = () => this.scheduleLayoutRefresh();
269
330
  this.handlePreviewScroll = () => this.scheduleLayoutRefresh();
270
331
  this.handleWindowResize = () => this.scheduleLayoutRefresh();
332
+ this.handleCommentSelectionButtonPointerDown = (event) => {
333
+ event.preventDefault();
334
+ };
271
335
  this.handleEditorPointerDown = (event) => {
272
336
  if (event.button !== 0 || !this.supported || !this.session) {
273
337
  return;
@@ -329,6 +393,11 @@ export class CommentUiController {
329
393
  this.closeCard();
330
394
  };
331
395
  this.handleDocumentKeyDown = (event) => {
396
+ if (event.key === 'Escape' && this.reactionPicker) {
397
+ this.reactionPicker = null;
398
+ this.renderCard();
399
+ return;
400
+ }
332
401
  if (event.key === 'Escape' && this.activeCard) {
333
402
  this.closeCard();
334
403
  }
@@ -340,6 +409,7 @@ export class CommentUiController {
340
409
  }
341
410
  };
342
411
 
412
+ this.commentSelectionButton?.addEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
343
413
  this.commentSelectionButton?.addEventListener('click', () => {
344
414
  this.openComposerForSelection('toolbar');
345
415
  });
@@ -364,6 +434,7 @@ export class CommentUiController {
364
434
  }
365
435
  this.attachSession(null);
366
436
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
437
+ this.commentSelectionButton?.removeEventListener('pointerdown', this.handleCommentSelectionButtonPointerDown);
367
438
  this.editorContainer?.removeEventListener('pointerdown', this.handleEditorPointerDown);
368
439
  this.editorContainer?.removeEventListener('focusout', this.handleEditorFocusOut);
369
440
  window.removeEventListener('resize', this.handleWindowResize);
@@ -383,6 +454,7 @@ export class CommentUiController {
383
454
  this.selectionAnchor = session?.getCurrentSelectionCommentAnchor?.() ?? null;
384
455
  this.pendingSelectionAnchor = null;
385
456
  this.committedSelectionAnchor = null;
457
+ this.reactionPicker = null;
386
458
  this.clearSelectionRevealTimer();
387
459
  this.pointerSelecting = false;
388
460
  session?.getScrollContainer?.()?.addEventListener('scroll', this.handleEditorScroll, { passive: true });
@@ -403,6 +475,7 @@ export class CommentUiController {
403
475
  this.clearSelectionRevealTimer();
404
476
  this.pointerSelecting = false;
405
477
  this.activeCard = null;
478
+ this.reactionPicker = null;
406
479
  }
407
480
  if (!this.supported) {
408
481
  this.drawerOpen = false;
@@ -413,6 +486,7 @@ export class CommentUiController {
413
486
  this.committedSelectionAnchor = null;
414
487
  this.clearSelectionRevealTimer();
415
488
  this.pointerSelecting = false;
489
+ this.reactionPicker = null;
416
490
  }
417
491
  this.render();
418
492
  }
@@ -483,6 +557,15 @@ export class CommentUiController {
483
557
  ) {
484
558
  this.activeCard = null;
485
559
  }
560
+ if (
561
+ this.reactionPicker
562
+ && !this.threads.some((thread) => (
563
+ thread.id === this.reactionPicker.threadId
564
+ && thread.messages?.some((message) => message.id === this.reactionPicker.messageId)
565
+ ))
566
+ ) {
567
+ this.reactionPicker = null;
568
+ }
486
569
  this.render();
487
570
  }
488
571
 
@@ -578,11 +661,26 @@ export class CommentUiController {
578
661
  quote.className = 'comments-drawer-item-quote';
579
662
  quote.textContent = group.anchor.quote || group.anchor.excerpt || 'Source anchored comment';
580
663
 
664
+ const latestMessage = getLatestGroupMessage(group);
665
+ const preview = createRenderedCommentBody(
666
+ latestMessage?.body || '',
667
+ 'comment-markdown comments-drawer-item-preview',
668
+ );
669
+
581
670
  const footer = document.createElement('div');
582
671
  footer.className = 'comments-drawer-item-footer';
583
- footer.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
672
+ const countLabel = document.createElement('span');
673
+ countLabel.textContent = `${group.threads.length} thread${group.threads.length === 1 ? '' : 's'}`;
584
674
 
585
- button.append(header, quote, footer);
675
+ const updatedLabel = document.createElement('span');
676
+ updatedLabel.className = 'comments-drawer-item-updated';
677
+ updatedLabel.textContent = latestMessage
678
+ ? `${latestMessage.userName} • ${this.formatTimestamp(latestMessage.createdAt)}`
679
+ : '';
680
+
681
+ footer.append(countLabel, updatedLabel);
682
+
683
+ button.append(header, quote, preview, footer);
586
684
  fragment.appendChild(button);
587
685
  });
588
686
 
@@ -811,6 +909,7 @@ export class CommentUiController {
811
909
  }
812
910
 
813
911
  this.selectionAnchor = anchor;
912
+ this.reactionPicker = null;
814
913
  const nextOrigin = origin === 'editor' && sourceRect ? 'editor-chip' : origin;
815
914
  const nextSourceRect = sourceRect ?? (origin === 'toolbar'
816
915
  ? this.commentSelectionButton?.getBoundingClientRect?.()
@@ -826,6 +925,7 @@ export class CommentUiController {
826
925
  }
827
926
 
828
927
  openThreadGroup(group, { anchor, origin, sourceRect }) {
928
+ this.reactionPicker = null;
829
929
  this.activeCard = {
830
930
  anchor,
831
931
  groupKey: group.key,
@@ -841,6 +941,7 @@ export class CommentUiController {
841
941
  closeCard() {
842
942
  this.activeCard = null;
843
943
  this.pendingCardFocusElement = null;
944
+ this.reactionPicker = null;
844
945
  this.renderCard();
845
946
  this.scheduleLayoutRefresh();
846
947
  }
@@ -902,6 +1003,21 @@ export class CommentUiController {
902
1003
 
903
1004
  const card = document.createElement('section');
904
1005
  card.className = 'comment-card';
1006
+ card.addEventListener('click', (event) => {
1007
+ if (
1008
+ !this.reactionPicker
1009
+ || event.target?.closest?.('.comment-reaction-picker-wrap')
1010
+ ) {
1011
+ return;
1012
+ }
1013
+
1014
+ this.reactionPicker = null;
1015
+ requestAnimationFrame(() => {
1016
+ if (this.activeCard) {
1017
+ this.renderCard();
1018
+ }
1019
+ });
1020
+ });
905
1021
 
906
1022
  const header = document.createElement('div');
907
1023
  header.className = 'comment-card-header';
@@ -929,15 +1045,18 @@ export class CommentUiController {
929
1045
  header.append(titleWrap, closeButton);
930
1046
  card.appendChild(header);
931
1047
 
1048
+ const content = document.createElement('div');
1049
+ content.className = 'comment-card-scroll';
1050
+
932
1051
  if (this.activeCard.anchor?.quote) {
933
1052
  const quote = document.createElement('p');
934
1053
  quote.className = 'comment-card-quote';
935
1054
  quote.textContent = this.activeCard.anchor.quote;
936
- card.appendChild(quote);
1055
+ content.appendChild(quote);
937
1056
  }
938
1057
 
939
1058
  if (this.activeCard.mode === 'create') {
940
- card.appendChild(this.createComposer());
1059
+ content.appendChild(this.createComposer());
941
1060
  } else {
942
1061
  const group = this.getThreadGroups().find((entry) => entry.key === this.activeCard.groupKey);
943
1062
  if (!group) {
@@ -946,16 +1065,46 @@ export class CommentUiController {
946
1065
  }
947
1066
 
948
1067
  group.threads.forEach((thread) => {
949
- card.appendChild(this.createThreadElement(thread));
1068
+ content.appendChild(this.createThreadElement(thread));
950
1069
  });
951
1070
  }
952
1071
 
1072
+ root.style.visibility = 'hidden';
1073
+ card.appendChild(content);
953
1074
  root.appendChild(card);
1075
+ this.updateReactionPickerPosition(card);
1076
+ this.positionCard(card);
1077
+ root.style.visibility = '';
954
1078
  this.flushPendingCardFocus();
955
- root.style.visibility = 'hidden';
956
1079
  this.scheduleLayoutRefresh();
957
1080
  }
958
1081
 
1082
+ updateReactionPickerPosition(card) {
1083
+ const bounds = getReactionPickerBounds(card);
1084
+ if (!bounds) {
1085
+ return;
1086
+ }
1087
+
1088
+ const { picker, scroll, wrap } = bounds;
1089
+ picker.classList.remove('is-upward');
1090
+ picker.style.maxHeight = '';
1091
+
1092
+ const wrapRect = wrap.getBoundingClientRect();
1093
+ const scrollRect = scroll.getBoundingClientRect();
1094
+ const pickerRect = picker.getBoundingClientRect();
1095
+ const safeViewportTop = 12;
1096
+ const safeViewportBottom = window.innerHeight - 12;
1097
+ const lowerBoundary = Math.min(scrollRect.bottom, safeViewportBottom);
1098
+ const upperBoundary = Math.max(scrollRect.top, safeViewportTop);
1099
+ const availableBelow = Math.max(lowerBoundary - wrapRect.bottom - 8, 0);
1100
+ const availableAbove = Math.max(wrapRect.top - upperBoundary - 8, 0);
1101
+ const shouldOpenUpward = pickerRect.height > availableBelow && availableAbove > availableBelow;
1102
+ const maxHeight = Math.max((shouldOpenUpward ? availableAbove : availableBelow), 120);
1103
+
1104
+ picker.classList.toggle('is-upward', shouldOpenUpward);
1105
+ picker.style.maxHeight = `${maxHeight}px`;
1106
+ }
1107
+
959
1108
  repositionActiveCard() {
960
1109
  const card = this.cardRoot?.firstElementChild;
961
1110
  if (!card || !this.activeCard) {
@@ -1022,6 +1171,9 @@ export class CommentUiController {
1022
1171
  const header = document.createElement('div');
1023
1172
  header.className = 'comment-thread-card-header';
1024
1173
 
1174
+ const heading = document.createElement('div');
1175
+ heading.className = 'comment-thread-card-heading';
1176
+
1025
1177
  const author = document.createElement('span');
1026
1178
  author.className = 'comment-thread-card-author';
1027
1179
  author.textContent = thread.createdByName;
@@ -1065,12 +1217,13 @@ export class CommentUiController {
1065
1217
  });
1066
1218
 
1067
1219
  actions.append(jump, reply, resolve);
1068
- header.append(author, time, actions);
1220
+ heading.append(author, time);
1221
+ header.append(heading, actions);
1069
1222
 
1070
1223
  article.append(header);
1071
1224
 
1072
1225
  thread.messages.forEach((message) => {
1073
- article.appendChild(this.createMessageElement(message));
1226
+ article.appendChild(this.createMessageElement(thread, message));
1074
1227
  });
1075
1228
 
1076
1229
  if (this.activeCard?.replyThreadId === thread.id) {
@@ -1080,7 +1233,7 @@ export class CommentUiController {
1080
1233
  return article;
1081
1234
  }
1082
1235
 
1083
- createMessageElement(message) {
1236
+ createMessageElement(thread, message) {
1084
1237
  const container = document.createElement('div');
1085
1238
  container.className = 'comment-message-card';
1086
1239
 
@@ -1095,15 +1248,133 @@ export class CommentUiController {
1095
1248
  time.className = 'comment-message-card-time';
1096
1249
  time.textContent = this.formatTimestamp(message.createdAt);
1097
1250
 
1098
- const body = document.createElement('p');
1099
- body.className = 'comment-message-card-body';
1100
- body.textContent = message.body;
1251
+ const renderedBody = createRenderedCommentBody(
1252
+ message.body,
1253
+ 'comment-message-card-body comment-markdown',
1254
+ );
1101
1255
 
1102
1256
  meta.append(author, time);
1103
- container.append(meta, body);
1257
+ container.append(meta, renderedBody);
1258
+ container.appendChild(this.createReactionBar(thread, message));
1104
1259
  return container;
1105
1260
  }
1106
1261
 
1262
+ createReactionBar(thread, message) {
1263
+ const localUserId = this.session?.getLocalUser?.()?.userId ?? '';
1264
+ const wrap = document.createElement('div');
1265
+ wrap.className = 'comment-reaction-bar';
1266
+
1267
+ const existingReactionEmojis = new Set((message.reactions ?? []).map((reaction) => reaction.emoji));
1268
+
1269
+ const chips = document.createElement('div');
1270
+ chips.className = 'comment-reaction-chips';
1271
+
1272
+ (message.reactions ?? []).forEach((reaction) => {
1273
+ const chip = document.createElement('button');
1274
+ chip.type = 'button';
1275
+ chip.className = 'comment-reaction-chip';
1276
+ chip.classList.toggle('is-active', hasLocalReaction(reaction, localUserId));
1277
+ chip.setAttribute('aria-pressed', String(hasLocalReaction(reaction, localUserId)));
1278
+ chip.title = reaction.users?.map((user) => user.userName).join(', ') || reaction.emoji;
1279
+
1280
+ const emoji = document.createElement('span');
1281
+ emoji.className = 'comment-reaction-chip-emoji';
1282
+ emoji.textContent = reaction.emoji;
1283
+
1284
+ const count = document.createElement('span');
1285
+ count.className = 'comment-reaction-chip-count';
1286
+ count.textContent = formatReactionCount(reaction);
1287
+
1288
+ chip.append(emoji, count);
1289
+ chip.addEventListener('click', async () => {
1290
+ await this.onToggleReaction?.(thread.id, message.id, reaction.emoji);
1291
+ });
1292
+ chips.appendChild(chip);
1293
+ });
1294
+
1295
+ const actions = document.createElement('div');
1296
+ actions.className = 'comment-reaction-actions';
1297
+
1298
+ COMMENT_REACTION_PRESET_EMOJIS
1299
+ .filter((emoji) => !existingReactionEmojis.has(emoji))
1300
+ .forEach((emoji) => {
1301
+ actions.appendChild(this.createQuickReactionButton(thread, message, emoji));
1302
+ });
1303
+
1304
+ const pickerWrap = document.createElement('div');
1305
+ pickerWrap.className = 'comment-reaction-picker-wrap';
1306
+
1307
+ const moreButton = document.createElement('button');
1308
+ moreButton.type = 'button';
1309
+ moreButton.className = 'comment-reaction-more-trigger';
1310
+ moreButton.dataset.reactionPickerToggle = 'true';
1311
+ moreButton.setAttribute('aria-expanded', String(
1312
+ isReactionPickerOpen(this.reactionPicker, thread.id, message.id),
1313
+ ));
1314
+ moreButton.textContent = 'More';
1315
+ moreButton.addEventListener('click', () => {
1316
+ const isOpen = isReactionPickerOpen(this.reactionPicker, thread.id, message.id);
1317
+ this.reactionPicker = isOpen
1318
+ ? null
1319
+ : {
1320
+ messageId: message.id,
1321
+ threadId: thread.id,
1322
+ };
1323
+ this.renderCard();
1324
+ });
1325
+
1326
+ pickerWrap.appendChild(moreButton);
1327
+
1328
+ if (isReactionPickerOpen(this.reactionPicker, thread.id, message.id)) {
1329
+ pickerWrap.appendChild(this.createReactionPicker(thread, message));
1330
+ }
1331
+
1332
+ actions.appendChild(pickerWrap);
1333
+ wrap.append(chips, actions);
1334
+ return wrap;
1335
+ }
1336
+
1337
+ createQuickReactionButton(thread, message, emoji) {
1338
+ const button = document.createElement('button');
1339
+ button.type = 'button';
1340
+ button.className = 'comment-reaction-quick-add';
1341
+ button.textContent = emoji;
1342
+ button.title = `React with ${emoji}`;
1343
+ button.addEventListener('click', async () => {
1344
+ await this.onToggleReaction?.(thread.id, message.id, emoji);
1345
+ });
1346
+ return button;
1347
+ }
1348
+
1349
+ createReactionPicker(thread, message) {
1350
+ const picker = document.createElement('div');
1351
+ picker.className = 'comment-reaction-picker';
1352
+ const moreGrid = document.createElement('div');
1353
+ moreGrid.className = 'comment-reaction-picker-grid';
1354
+ COMMENT_REACTION_MORE_EMOJIS.forEach((emoji) => {
1355
+ moreGrid.appendChild(this.createReactionPickerButton(thread, message, emoji));
1356
+ });
1357
+ picker.appendChild(moreGrid);
1358
+
1359
+ return picker;
1360
+ }
1361
+
1362
+ createReactionPickerButton(thread, message, emoji) {
1363
+ const button = document.createElement('button');
1364
+ button.type = 'button';
1365
+ button.className = 'comment-reaction-picker-btn';
1366
+ button.textContent = emoji;
1367
+ button.title = `React with ${emoji}`;
1368
+ button.addEventListener('click', async () => {
1369
+ const didToggle = await this.onToggleReaction?.(thread.id, message.id, emoji);
1370
+ if (didToggle) {
1371
+ this.reactionPicker = null;
1372
+ this.renderCard();
1373
+ }
1374
+ });
1375
+ return button;
1376
+ }
1377
+
1107
1378
  createReplyComposer(thread) {
1108
1379
  const form = document.createElement('form');
1109
1380
  form.className = 'comment-reply-form';
@@ -1204,7 +1475,32 @@ export class CommentUiController {
1204
1475
  }
1205
1476
 
1206
1477
  this.pendingCardFocusElement = null;
1207
- element.focus({ preventScroll: true });
1478
+ const focusElement = () => {
1479
+ if (!element.isConnected) {
1480
+ return;
1481
+ }
1482
+
1483
+ const activeElement = document.activeElement;
1484
+ if (activeElement instanceof HTMLElement && this.editorContainer?.contains(activeElement)) {
1485
+ activeElement.blur();
1486
+ }
1487
+
1488
+ element.focus({ preventScroll: true });
1489
+ };
1490
+
1491
+ focusElement();
1492
+ if (document.activeElement === element) {
1493
+ return;
1494
+ }
1495
+
1496
+ requestAnimationFrame(() => {
1497
+ focusElement();
1498
+ if (document.activeElement !== element) {
1499
+ setTimeout(() => {
1500
+ focusElement();
1501
+ }, 50);
1502
+ }
1503
+ });
1208
1504
  }
1209
1505
 
1210
1506
  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
  }