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
@@ -30,6 +30,7 @@ import { ScrollSyncController } from '../presentation/scroll-sync-controller.js'
30
30
  import { ThemeController } from '../presentation/theme-controller.js';
31
31
  import { ToastController } from '../presentation/toast-controller.js';
32
32
  import { VideoEmbedController } from '../presentation/video-embed-controller.js';
33
+ import { ImageLightboxController } from '../presentation/image-lightbox-controller.js';
33
34
 
34
35
  const APP_SHELL_FEATURES = Object.freeze({
35
36
  chat: chatFeature,
@@ -152,9 +153,13 @@ export class CollabMdAppShell {
152
153
  this.videoEmbed = new VideoEmbedController({
153
154
  previewElement: this.elements.previewContent,
154
155
  });
156
+ this.imageLightbox = new ImageLightboxController({
157
+ previewElement: this.elements.previewContent,
158
+ });
155
159
  this.previewRenderer = new PreviewRenderer({
156
160
  getContent: () => this.getPreviewSource(),
157
- getFileList: () => this.fileExplorer.flatFiles,
161
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
162
+ getSourceFilePath: () => this.currentFilePath,
158
163
  onAfterRenderCommit: (_previewElement, stats) => {
159
164
  this.videoEmbed.reconcileEmbeds(this.elements.previewContent);
160
165
  this.videoEmbed.syncLayout();
@@ -217,6 +222,7 @@ export class CollabMdAppShell {
217
222
  this.session?.scrollToLine(lineNumber, 0.2);
218
223
  },
219
224
  onReplyToThread: (threadId, body) => this.session?.replyToCommentThread(threadId, body),
225
+ onToggleReaction: (threadId, messageId, emoji) => this.session?.toggleCommentReaction(threadId, messageId, emoji),
220
226
  onResolveThread: (threadId) => this.session?.deleteCommentThread(threadId),
221
227
  });
222
228
  this.workspacePreviewController = new WorkspacePreviewController({
@@ -226,6 +232,7 @@ export class CollabMdAppShell {
226
232
  getDisplayName: (filePath) => this.getDisplayName(filePath),
227
233
  getSession: () => this.session,
228
234
  isExcalidrawFile: (filePath) => this.isExcalidrawFile(filePath),
235
+ isImageFile: (filePath) => this.isImageFile(filePath),
229
236
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
230
237
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
231
238
  layoutController: this.layoutController,
@@ -236,7 +243,7 @@ export class CollabMdAppShell {
236
243
  videoEmbed: this.videoEmbed,
237
244
  });
238
245
  this.wikiLinkFileController = new WikiLinkFileController({
239
- getFileList: () => this.fileExplorer.flatFiles,
246
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
240
247
  navigation: this.navigation,
241
248
  refreshExplorer: () => this.fileExplorer.refresh(),
242
249
  toastController: this.toastController,
@@ -271,6 +278,7 @@ export class CollabMdAppShell {
271
278
  lineInfoElement: this.elements.lineInfo,
272
279
  lineWrappingEnabled: options.lineWrappingEnabled,
273
280
  localUser: options.localUser,
281
+ onImagePaste: options.onImagePaste,
274
282
  onAwarenessChange: options.onAwarenessChange,
275
283
  onCommentsChange: options.onCommentsChange,
276
284
  onConnectionChange: options.onConnectionChange,
@@ -279,12 +287,13 @@ export class CollabMdAppShell {
279
287
  preferredUserName: options.preferredUserName,
280
288
  }),
281
289
  getDisplayName: (filePath) => this.getDisplayName(filePath),
282
- getFileList: () => this.fileExplorer.flatFiles,
290
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
283
291
  getLineWrappingEnabled: () => this.getStoredLineWrapping(),
284
292
  getLocalUser: () => this.lobby.getLocalUser(),
285
293
  getStoredUserName: () => this.getStoredUserName(),
286
294
  getTheme: () => this.themeController.getTheme(),
287
295
  isExcalidrawFile: (filePath) => this.isExcalidrawFile(filePath),
296
+ isImageFile: (filePath) => this.isImageFile(filePath),
288
297
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
289
298
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
290
299
  isTabActive: () => this.isTabActive,
@@ -319,11 +328,13 @@ export class CollabMdAppShell {
319
328
  this.hideEditorLoading();
320
329
  },
321
330
  onSelectionChange: (anchor) => this.handleCommentSelectionChange(anchor),
331
+ onImagePaste: (file) => this.handleEditorImageInsert(file),
322
332
  onSessionAssigned: (session) => {
323
333
  this.session = session;
324
334
  this.commentUi.attachSession(session);
325
335
  },
326
336
  onRenderExcalidrawPreview: (filePath) => this.renderExcalidrawFilePreview(filePath),
337
+ onRenderImagePreview: (filePath) => this.renderImageFilePreview(filePath),
327
338
  onSyncWrapToggle: () => this.syncWrapToggle(),
328
339
  onUpdateActiveFile: (filePath) => this.fileExplorer.setActiveFile(filePath),
329
340
  onUpdateCurrentFile: (filePath) => {
@@ -354,6 +365,7 @@ export class CollabMdAppShell {
354
365
  getSessionLoadToken: () => this.sessionLoadToken,
355
366
  gitDiffView: this.gitDiffView,
356
367
  gitPanel: this.gitPanel,
368
+ imageLightbox: this.imageLightbox,
357
369
  layoutController: this.layoutController,
358
370
  lobby: this.lobby,
359
371
  navigation: this.navigation,
@@ -8,6 +8,7 @@ const USER_COLORS = [
8
8
  '#6366f1', '#10b981', '#f43f5e', '#0ea5e9', '#a855f7',
9
9
  ];
10
10
  export const USER_NAME_MAX_LENGTH = 24;
11
+ const LOCAL_USER_ID_STORAGE_KEY = 'collabmd-user-id';
11
12
 
12
13
  function pickRandom(items) {
13
14
  return items[Math.floor(Math.random() * items.length)];
@@ -19,8 +20,17 @@ function generatePeerId() {
19
20
  return Array.from(array, (b) => b.toString(16).padStart(2, '0')).join('');
20
21
  }
21
22
 
23
+ function generateUserId() {
24
+ if (globalThis.crypto?.randomUUID) {
25
+ return globalThis.crypto.randomUUID();
26
+ }
27
+
28
+ return `${generatePeerId()}-${Date.now().toString(36)}`;
29
+ }
30
+
22
31
  // A stable peer ID for this browser tab, shared across lobby and per-file sessions.
23
32
  let _localPeerId = null;
33
+ let _localUserId = null;
24
34
 
25
35
  export function getLocalPeerId() {
26
36
  if (!_localPeerId) {
@@ -29,6 +39,32 @@ export function getLocalPeerId() {
29
39
  return _localPeerId;
30
40
  }
31
41
 
42
+ export function getLocalUserId(storage = globalThis.localStorage) {
43
+ if (_localUserId) {
44
+ return _localUserId;
45
+ }
46
+
47
+ try {
48
+ const stored = storage?.getItem?.(LOCAL_USER_ID_STORAGE_KEY);
49
+ if (stored) {
50
+ _localUserId = stored;
51
+ return stored;
52
+ }
53
+ } catch {
54
+ // Ignore storage access errors.
55
+ }
56
+
57
+ _localUserId = generateUserId();
58
+
59
+ try {
60
+ storage?.setItem?.(LOCAL_USER_ID_STORAGE_KEY, _localUserId);
61
+ } catch {
62
+ // Ignore storage access errors.
63
+ }
64
+
65
+ return _localUserId;
66
+ }
67
+
32
68
  export function normalizeUserName(value) {
33
69
  const normalized = String(value ?? '')
34
70
  .trim()
@@ -45,5 +81,6 @@ export function createRandomUser(preferredName = null) {
45
81
  colorLight: `${color}33`,
46
82
  name: normalizeUserName(preferredName) ?? pickRandom(USER_NAMES),
47
83
  peerId: getLocalPeerId(),
84
+ userId: getLocalUserId(),
48
85
  };
49
86
  }
@@ -47,3 +47,49 @@ export function resolveWikiTarget(target, files) {
47
47
  export function clamp(value, min, max) {
48
48
  return Math.min(Math.max(value, min), max);
49
49
  }
50
+
51
+ function normalizePathSegments(pathValue) {
52
+ return String(pathValue ?? '')
53
+ .replace(/\\/g, '/')
54
+ .split('/')
55
+ .filter(Boolean);
56
+ }
57
+
58
+ export function resolveVaultRelativePath(fromFilePath, relativePath) {
59
+ const sourceSegments = normalizePathSegments(fromFilePath);
60
+ const targetSegments = String(relativePath ?? '')
61
+ .split('/')
62
+ .map((segment) => {
63
+ try {
64
+ return decodeURIComponent(segment);
65
+ } catch {
66
+ return segment;
67
+ }
68
+ });
69
+
70
+ if (targetSegments.length === 0) {
71
+ return '';
72
+ }
73
+
74
+ sourceSegments.pop();
75
+ const resolvedSegments = [...sourceSegments];
76
+
77
+ for (const rawSegment of targetSegments) {
78
+ const segment = String(rawSegment ?? '').trim();
79
+ if (!segment || segment === '.') {
80
+ continue;
81
+ }
82
+
83
+ if (segment === '..') {
84
+ if (resolvedSegments.length === 0) {
85
+ return '';
86
+ }
87
+ resolvedSegments.pop();
88
+ continue;
89
+ }
90
+
91
+ resolvedSegments.push(segment);
92
+ }
93
+
94
+ return resolvedSegments.join('/');
95
+ }
@@ -16,11 +16,36 @@ function createCommentMessage({ body, user }) {
16
16
  createdAt: Date.now(),
17
17
  id: createCommentId('comment'),
18
18
  peerId: user?.peerId ?? '',
19
+ reactions: [],
19
20
  userColor: user?.color ?? '',
20
21
  userName: user?.name ?? 'Anonymous',
21
22
  };
22
23
  }
23
24
 
25
+ function readRecordValue(record, key) {
26
+ if (record instanceof Y.Map) {
27
+ return record.get(key);
28
+ }
29
+
30
+ return record?.[key];
31
+ }
32
+
33
+ function cloneReactionGroups(source = []) {
34
+ return Array.isArray(source)
35
+ ? source.map((group) => ({
36
+ emoji: typeof group?.emoji === 'string' ? group.emoji : '',
37
+ users: Array.isArray(group?.users)
38
+ ? group.users.map((user) => ({
39
+ reactedAt: Number.isFinite(user?.reactedAt) ? user.reactedAt : Date.now(),
40
+ userColor: typeof user?.userColor === 'string' ? user.userColor : '',
41
+ userId: typeof user?.userId === 'string' ? user.userId : '',
42
+ userName: typeof user?.userName === 'string' && user.userName ? user.userName : 'Anonymous',
43
+ })).filter((user) => user.userId)
44
+ : [],
45
+ })).filter((group) => group.emoji && group.users.length > 0)
46
+ : [];
47
+ }
48
+
24
49
  function normalizeSelectionAnchorPayload(payload, state) {
25
50
  const doc = state?.doc;
26
51
  if (!doc) {
@@ -188,6 +213,79 @@ export class CommentThreadStore {
188
213
  return message.id;
189
214
  }
190
215
 
216
+ toggleCommentReaction(threadId, messageId, emoji) {
217
+ if (!this.ydoc || !threadId || !messageId || typeof emoji !== 'string' || !emoji.trim()) {
218
+ return false;
219
+ }
220
+
221
+ const localUser = this.getLocalUser?.();
222
+ const localUserId = typeof localUser?.userId === 'string' ? localUser.userId : '';
223
+ if (!localUserId) {
224
+ return false;
225
+ }
226
+
227
+ const thread = this.findSharedCommentThread(threadId);
228
+ const messages = thread?.get('messages');
229
+ if (!(messages instanceof Y.Array)) {
230
+ return false;
231
+ }
232
+
233
+ const items = messages.toArray();
234
+ const messageIndex = items.findIndex((message) => readRecordValue(message, 'id') === messageId);
235
+ if (messageIndex < 0) {
236
+ return false;
237
+ }
238
+
239
+ const messageRecord = items[messageIndex] instanceof Y.Map
240
+ ? items[messageIndex].toJSON()
241
+ : { ...items[messageIndex] };
242
+ const reactions = cloneReactionGroups(messageRecord.reactions);
243
+ const reactionIndex = reactions.findIndex((reaction) => reaction.emoji === emoji);
244
+
245
+ if (reactionIndex >= 0) {
246
+ const nextUsers = reactions[reactionIndex].users.filter((user) => user.userId !== localUserId);
247
+ if (nextUsers.length === reactions[reactionIndex].users.length) {
248
+ nextUsers.push({
249
+ reactedAt: Date.now(),
250
+ userColor: localUser?.color ?? '',
251
+ userId: localUserId,
252
+ userName: localUser?.name ?? 'Anonymous',
253
+ });
254
+ }
255
+
256
+ if (nextUsers.length === 0) {
257
+ reactions.splice(reactionIndex, 1);
258
+ } else {
259
+ reactions[reactionIndex] = {
260
+ ...reactions[reactionIndex],
261
+ users: nextUsers,
262
+ };
263
+ }
264
+ } else {
265
+ reactions.push({
266
+ emoji,
267
+ users: [{
268
+ reactedAt: Date.now(),
269
+ userColor: localUser?.color ?? '',
270
+ userId: localUserId,
271
+ userName: localUser?.name ?? 'Anonymous',
272
+ }],
273
+ });
274
+ }
275
+
276
+ const nextMessage = {
277
+ ...messageRecord,
278
+ reactions,
279
+ };
280
+
281
+ this.ydoc.transact(() => {
282
+ messages.delete(messageIndex, 1);
283
+ messages.insert(messageIndex, [nextMessage]);
284
+ }, 'comment-reaction-toggle');
285
+
286
+ return true;
287
+ }
288
+
191
289
  deleteCommentThread(threadId) {
192
290
  if (!this.commentThreads || !this.ydoc) {
193
291
  return false;
@@ -0,0 +1,45 @@
1
+ function extractPastedImageFile(event) {
2
+ const clipboardFiles = Array.from(event?.clipboardData?.files ?? []);
3
+ for (const file of clipboardFiles) {
4
+ if (file?.type?.startsWith?.('image/')) {
5
+ return file;
6
+ }
7
+ }
8
+
9
+ const clipboardItems = Array.from(event?.clipboardData?.items ?? []);
10
+ for (const item of clipboardItems) {
11
+ if (item.kind !== 'file' || !item.type?.startsWith?.('image/')) {
12
+ continue;
13
+ }
14
+
15
+ const file = item.getAsFile?.();
16
+ if (file) {
17
+ return file;
18
+ }
19
+ }
20
+
21
+ return null;
22
+ }
23
+
24
+ export function handleImagePasteEvent(event, onImagePaste) {
25
+ const imageFile = extractPastedImageFile(event);
26
+ if (!imageFile) {
27
+ return false;
28
+ }
29
+
30
+ if (typeof onImagePaste !== 'function') {
31
+ console.warn('[editor] Ignoring pasted image because no image paste handler is registered.');
32
+ return false;
33
+ }
34
+
35
+ console.debug('[editor] Detected pasted image.', {
36
+ name: imageFile.name ?? '',
37
+ size: imageFile.size ?? null,
38
+ type: imageFile.type ?? '',
39
+ });
40
+ event.preventDefault?.();
41
+ void Promise.resolve(onImagePaste(imageFile)).catch((error) => {
42
+ console.error('[editor] Failed to process pasted image:', error);
43
+ });
44
+ return true;
45
+ }
@@ -14,6 +14,7 @@ export class EditorSession {
14
14
  onConnectionChange,
15
15
  onCommentsChange,
16
16
  onContentChange,
17
+ onImagePaste,
17
18
  onSelectionChange,
18
19
  preferredUserName,
19
20
  localUser,
@@ -43,6 +44,7 @@ export class EditorSession {
43
44
  onDocChanged: () => {
44
45
  this.emitContentChange();
45
46
  },
47
+ onImagePaste,
46
48
  onViewportChanged: (viewport) => {
47
49
  this.collaborationClient.setLocalViewport(viewport);
48
50
  },
@@ -157,6 +159,10 @@ export class EditorSession {
157
159
  return this.commentThreadStore.replyToCommentThread(threadId, body);
158
160
  }
159
161
 
162
+ toggleCommentReaction(threadId, messageId, emoji) {
163
+ return this.commentThreadStore.toggleCommentReaction(threadId, messageId, emoji);
164
+ }
165
+
160
166
  deleteCommentThread(threadId) {
161
167
  return this.commentThreadStore.deleteCommentThread(threadId);
162
168
  }
@@ -219,6 +225,10 @@ export class EditorSession {
219
225
  return this.viewAdapter.applyMarkdownToolbarAction(action);
220
226
  }
221
227
 
228
+ insertText(text) {
229
+ return this.viewAdapter.insertText(text);
230
+ }
231
+
222
232
  waitForInitialSync(timeoutMs = 1500) {
223
233
  return this.collaborationClient.waitForInitialSync(timeoutMs);
224
234
  }
@@ -11,7 +11,7 @@ import {
11
11
  } from '@codemirror/language';
12
12
  import { languages } from '@codemirror/language-data';
13
13
  import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
14
- import { Compartment, EditorSelection, EditorState } from '@codemirror/state';
14
+ import { Compartment, EditorSelection, EditorState, Prec } from '@codemirror/state';
15
15
  import { oneDark } from '@codemirror/theme-one-dark';
16
16
  import {
17
17
  EditorView,
@@ -30,6 +30,7 @@ import { normalizeCommentQuote } from '../../domain/comment-threads.js';
30
30
  import { createMarkdownToolbarEdit } from '../domain/markdown-formatting.js';
31
31
  import { wikiLinkCompletions } from '../domain/wiki-link-completions.js';
32
32
  import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantuml-language.js';
33
+ import { handleImagePasteEvent } from './editor-paste-utils.js';
33
34
 
34
35
  const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
35
36
 
@@ -127,6 +128,7 @@ export class EditorViewAdapter {
127
128
  lineInfoElement,
128
129
  lineWrappingEnabled = true,
129
130
  onDocChanged = null,
131
+ onImagePaste = null,
130
132
  onSelectionChanged = null,
131
133
  onViewportChanged = null,
132
134
  }) {
@@ -136,6 +138,7 @@ export class EditorViewAdapter {
136
138
  this.lineInfoElement = lineInfoElement;
137
139
  this.lineWrappingEnabled = lineWrappingEnabled;
138
140
  this.onDocChanged = onDocChanged;
141
+ this.onImagePaste = onImagePaste;
139
142
  this.onSelectionChanged = onSelectionChanged;
140
143
  this.onViewportChanged = onViewportChanged;
141
144
  this.editorView = null;
@@ -208,6 +211,9 @@ export class EditorViewAdapter {
208
211
  EditorView.contentAttributes.of({
209
212
  'aria-label': 'Markdown editor',
210
213
  }),
214
+ Prec.highest(EditorView.domEventHandlers({
215
+ paste: (event) => handleImagePasteEvent(event, this.onImagePaste),
216
+ })),
211
217
  createLanguageExtension(filePath),
212
218
  this.themeCompartment.of(createEditorTheme(this.initialTheme)),
213
219
  this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
@@ -561,6 +567,33 @@ export class EditorViewAdapter {
561
567
  return true;
562
568
  }
563
569
 
570
+ insertText(text) {
571
+ if (!this.editorView) {
572
+ return false;
573
+ }
574
+
575
+ const insertValue = String(text ?? '');
576
+ const { state } = this.editorView;
577
+ const range = state.selection.main;
578
+ const anchor = range.from + insertValue.length;
579
+
580
+ this.editorView.dispatch({
581
+ changes: {
582
+ from: range.from,
583
+ insert: insertValue,
584
+ to: range.to,
585
+ },
586
+ scrollIntoView: true,
587
+ selection: {
588
+ anchor,
589
+ head: anchor,
590
+ },
591
+ userEvent: 'input',
592
+ });
593
+ this.editorView.focus();
594
+ return true;
595
+ }
596
+
564
597
  updateCursorInfo(state) {
565
598
  if (!this.lineInfoElement) {
566
599
  return;
@@ -1,5 +1,9 @@
1
1
  import { resolveApiUrl } from '../domain/runtime-paths.js';
2
2
 
3
+ function encodeHeaderMetadata(value) {
4
+ return encodeURIComponent(String(value ?? ''));
5
+ }
6
+
3
7
  async function parseApiResponse(response, fallbackError) {
4
8
  const data = await response.json().catch(() => ({}));
5
9
  if (!response.ok || data.ok === false) {
@@ -56,6 +60,19 @@ export class VaultApiClient {
56
60
  });
57
61
  return parseApiResponse(response, 'Failed to create folder');
58
62
  }
63
+
64
+ async uploadImageAttachment({ file, fileName = '', sourcePath }) {
65
+ const response = await fetch(resolveApiUrl('/attachments'), {
66
+ body: file,
67
+ headers: {
68
+ 'Content-Type': file?.type || 'application/octet-stream',
69
+ 'X-CollabMD-File-Name': encodeHeaderMetadata(fileName),
70
+ 'X-CollabMD-Source-Path': encodeHeaderMetadata(sourcePath),
71
+ },
72
+ method: 'POST',
73
+ });
74
+ return parseApiResponse(response, 'Failed to upload image');
75
+ }
59
76
  }
60
77
 
61
78
  export const vaultApiClient = new VaultApiClient();
@@ -0,0 +1,68 @@
1
+ import markdownIt from 'markdown-it';
2
+ import hljs from 'highlight.js';
3
+
4
+ import { escapeHtml } from '../domain/vault-utils.js';
5
+
6
+ function renderToken(renderer, tokens, index, options, env, self) {
7
+ return renderer?.(tokens, index, options, env, self) ?? self.renderToken(tokens, index, options);
8
+ }
9
+
10
+ function renderPlainTextCommentHtml(text = '') {
11
+ const escaped = escapeHtml(String(text ?? ''));
12
+ return `<p>${escaped.replace(/\n/g, '<br>')}</p>`;
13
+ }
14
+
15
+ function createCommentMarkdownRenderer() {
16
+ const markdown = markdownIt({
17
+ breaks: true,
18
+ highlight(source, language) {
19
+ try {
20
+ if (language && hljs.getLanguage(language)) {
21
+ return hljs.highlight(source, {
22
+ ignoreIllegals: true,
23
+ language,
24
+ }).value;
25
+ }
26
+
27
+ return hljs.highlightAuto(source).value;
28
+ } catch {
29
+ return escapeHtml(source);
30
+ }
31
+ },
32
+ html: false,
33
+ linkify: true,
34
+ typographer: true,
35
+ });
36
+
37
+ const fallbackLinkOpen = markdown.renderer.rules.link_open;
38
+ const fallbackTableOpen = markdown.renderer.rules.table_open;
39
+ const fallbackTableClose = markdown.renderer.rules.table_close;
40
+
41
+ markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
42
+ tokens[index].attrSet('target', '_blank');
43
+ tokens[index].attrSet('rel', 'noopener noreferrer');
44
+ return renderToken(fallbackLinkOpen, tokens, index, options, env, self);
45
+ };
46
+
47
+ markdown.renderer.rules.table_open = (tokens, index, options, env, self) => (
48
+ `<div class="comment-markdown-table">${renderToken(fallbackTableOpen, tokens, index, options, env, self)}`
49
+ );
50
+
51
+ markdown.renderer.rules.table_close = (tokens, index, options, env, self) => (
52
+ `${renderToken(fallbackTableClose, tokens, index, options, env, self)}</div>`
53
+ );
54
+
55
+ return markdown;
56
+ }
57
+
58
+ const commentMarkdownRenderer = createCommentMarkdownRenderer();
59
+
60
+ export function renderCommentMarkdownToHtml(markdownText = '') {
61
+ const normalizedMarkdown = String(markdownText ?? '');
62
+
63
+ try {
64
+ return commentMarkdownRenderer.render(normalizedMarkdown);
65
+ } catch {
66
+ return renderPlainTextCommentHtml(normalizedMarkdown);
67
+ }
68
+ }