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,6 +2,7 @@ import {
2
2
  isDiagramFilePath,
3
3
  isMarkdownFilePath,
4
4
  } from '../../domain/file-kind.js';
5
+ import { resolveApiUrl } from '../domain/runtime-paths.js';
5
6
 
6
7
  export class WorkspacePreviewController {
7
8
  constructor({
@@ -11,6 +12,7 @@ export class WorkspacePreviewController {
11
12
  getDisplayName,
12
13
  getSession,
13
14
  isExcalidrawFile,
15
+ isImageFile,
14
16
  isMermaidFile,
15
17
  isPlantUmlFile,
16
18
  layoutController,
@@ -25,9 +27,10 @@ export class WorkspacePreviewController {
25
27
  this.excalidrawEmbed = excalidrawEmbed;
26
28
  this.getDisplayName = getDisplayName;
27
29
  this.getSession = getSession;
28
- this.isExcalidrawFile = isExcalidrawFile;
29
- this.isMermaidFile = isMermaidFile;
30
- this.isPlantUmlFile = isPlantUmlFile;
30
+ this.isExcalidrawFile = isExcalidrawFile ?? (() => false);
31
+ this.isImageFile = isImageFile ?? (() => false);
32
+ this.isMermaidFile = isMermaidFile ?? (() => false);
33
+ this.isPlantUmlFile = isPlantUmlFile ?? (() => false);
31
34
  this.layoutController = layoutController;
32
35
  this.outlineController = outlineController;
33
36
  this.previewRenderer = previewRenderer;
@@ -58,23 +61,25 @@ export class WorkspacePreviewController {
58
61
 
59
62
  resetPreviewMode() {
60
63
  this.elements.previewContent?.classList.remove('is-excalidraw-file-preview');
64
+ this.elements.previewContent?.classList.remove('is-image-file-preview');
61
65
  this.elements.previewContent?.classList.remove('is-mermaid-file-preview');
62
66
  this.elements.previewContent?.classList.remove('is-plantuml-file-preview');
63
67
  }
64
68
 
65
69
  syncFileChrome(filePath) {
66
70
  const isExcalidraw = this.isExcalidrawFile(filePath);
71
+ const isImage = this.isImageFile(filePath);
67
72
  const isMarkdown = isMarkdownFilePath(filePath);
68
73
  const isMermaid = this.isMermaidFile(filePath);
69
74
  const isPlantUml = this.isPlantUmlFile(filePath);
70
75
  const isDiagramFile = isDiagramFilePath(filePath);
71
76
 
72
77
  this.elements.markdownToolbar?.classList.toggle('hidden', !isMarkdown);
73
- this.elements.outlineToggle?.classList.toggle('hidden', isDiagramFile);
78
+ this.elements.outlineToggle?.classList.toggle('hidden', isDiagramFile || isImage);
74
79
  this.elements.previewContent?.classList.toggle('is-mermaid-file-preview', isMermaid);
75
80
  this.elements.previewContent?.classList.toggle('is-plantuml-file-preview', isPlantUml);
76
81
 
77
- if (isExcalidraw) {
82
+ if (isExcalidraw || isImage) {
78
83
  this.layoutController.setView('preview', { persist: false });
79
84
  this.outlineController.close();
80
85
  this.backlinksPanel.clear();
@@ -124,6 +129,41 @@ export class WorkspacePreviewController {
124
129
  this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
125
130
  }
126
131
 
132
+ renderImageFilePreview(filePath) {
133
+ const previewElement = this.elements.previewContent;
134
+ if (!previewElement) {
135
+ return;
136
+ }
137
+
138
+ this.videoEmbed?.detachForCommit();
139
+ this.excalidrawEmbed.detachForCommit();
140
+ this.resetPreviewMode();
141
+ previewElement.classList.add('is-image-file-preview');
142
+ const renderHost = this.previewRenderer.ensureRenderHost();
143
+ this.previewRenderer.normalizePreviewChildren(renderHost);
144
+
145
+ const shell = document.createElement('figure');
146
+ shell.className = 'image-file-preview-shell';
147
+
148
+ const image = document.createElement('img');
149
+ image.className = 'image-file-preview-image';
150
+ image.alt = this.getDisplayName(filePath);
151
+ image.src = resolveApiUrl(`/attachment?path=${encodeURIComponent(filePath)}`);
152
+ shell.appendChild(image);
153
+
154
+ if (renderHost) {
155
+ renderHost.replaceChildren(shell);
156
+ renderHost.style.minHeight = '';
157
+ }
158
+
159
+ previewElement.dataset.renderPhase = 'ready';
160
+ this.outlineController.refresh();
161
+ this.scrollSyncController.setLargeDocumentMode(false);
162
+ this.scrollSyncController.invalidatePreviewBlocks();
163
+ this.videoEmbed?.reconcileEmbeds(previewElement);
164
+ this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
165
+ }
166
+
127
167
  createResizeHandler(restoreSidebarState) {
128
168
  let resizeTimer = null;
129
169
  return () => {
@@ -10,6 +10,7 @@ export class WorkspaceRouteController {
10
10
  getSessionLoadToken,
11
11
  gitDiffView,
12
12
  gitPanel,
13
+ imageLightbox = null,
13
14
  lobby,
14
15
  navigation,
15
16
  previewRenderer,
@@ -37,6 +38,7 @@ export class WorkspaceRouteController {
37
38
  this.getSessionLoadToken = getSessionLoadToken;
38
39
  this.gitDiffView = gitDiffView;
39
40
  this.gitPanel = gitPanel;
41
+ this.imageLightbox = imageLightbox;
40
42
  this.lobby = lobby;
41
43
  this.navigation = navigation;
42
44
  this.previewRenderer = previewRenderer;
@@ -140,6 +142,7 @@ export class WorkspaceRouteController {
140
142
  }
141
143
 
142
144
  async openFile(filePath) {
145
+ this.imageLightbox?.close?.();
143
146
  this.gitPanel.setSelection();
144
147
  this.gitDiffView.hide();
145
148
  this.syncMainChrome({ mode: 'editor' });
@@ -161,6 +164,7 @@ export class WorkspaceRouteController {
161
164
  }
162
165
 
163
166
  resetPreviewSurface() {
167
+ this.imageLightbox?.close?.();
164
168
  this.previewRenderer.setHydrationPaused(false);
165
169
  this.excalidrawEmbed.setHydrationPaused(false);
166
170
  this.videoEmbed?.detachForCommit();
@@ -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,
@@ -130,6 +131,7 @@ export class CollabMdAppShell {
130
131
  this.gitPanel = new GitPanelController({
131
132
  enabled: this.runtimeConfig.gitEnabled !== false,
132
133
  onCommitStaged: () => this.openGitCommitDialog(),
134
+ onOpenPullBackup: (filePath) => filePath && this.navigation.navigateToFile(filePath),
133
135
  onPullBranch: () => this.pullGitBranch(),
134
136
  onPushBranch: () => this.pushGitBranch(),
135
137
  onRepoChange: (isGitRepo, status) => this.handleGitRepoChange(isGitRepo, status),
@@ -148,13 +150,18 @@ export class CollabMdAppShell {
148
150
  this.scrollSyncController.suspendSync(250);
149
151
  this.session?.scrollToLine(sourceLine, 0);
150
152
  },
153
+ onWillOpen: () => this.commentUi?.closeDrawer(),
151
154
  });
152
155
  this.videoEmbed = new VideoEmbedController({
153
156
  previewElement: this.elements.previewContent,
154
157
  });
158
+ this.imageLightbox = new ImageLightboxController({
159
+ previewElement: this.elements.previewContent,
160
+ });
155
161
  this.previewRenderer = new PreviewRenderer({
156
162
  getContent: () => this.getPreviewSource(),
157
- getFileList: () => this.fileExplorer.flatFiles,
163
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
164
+ getSourceFilePath: () => this.currentFilePath,
158
165
  onAfterRenderCommit: (_previewElement, stats) => {
159
166
  this.videoEmbed.reconcileEmbeds(this.elements.previewContent);
160
167
  this.videoEmbed.syncLayout();
@@ -191,6 +198,7 @@ export class CollabMdAppShell {
191
198
  scrollEditorToLine: (lineNumber, viewportRatio) => this.session?.scrollToLine(lineNumber, viewportRatio),
192
199
  });
193
200
  this.backlinksPanel = new BacklinksPanel({
201
+ inlinePanelElement: this.elements.backlinksInlinePanel,
194
202
  onFileSelect: (filePath) => this.handleFileSelection(filePath, { closeSidebarOnMobile: true }),
195
203
  panelElement: this.elements.backlinksPanel,
196
204
  });
@@ -209,6 +217,7 @@ export class CollabMdAppShell {
209
217
  commentsDrawerList: this.elements.commentsDrawerList,
210
218
  commentsToggleButton: this.elements.commentsToggleButton,
211
219
  editorContainer: this.elements.editorContainer,
220
+ onWillOpenDrawer: () => this.outlineController.close(),
212
221
  previewContainer: this.elements.previewContainer,
213
222
  previewElement: this.elements.previewContent,
214
223
  onCreateThread: ({ anchor, body }) => this.session?.createCommentThread({ anchor, body }),
@@ -217,6 +226,7 @@ export class CollabMdAppShell {
217
226
  this.session?.scrollToLine(lineNumber, 0.2);
218
227
  },
219
228
  onReplyToThread: (threadId, body) => this.session?.replyToCommentThread(threadId, body),
229
+ onToggleReaction: (threadId, messageId, emoji) => this.session?.toggleCommentReaction(threadId, messageId, emoji),
220
230
  onResolveThread: (threadId) => this.session?.deleteCommentThread(threadId),
221
231
  });
222
232
  this.workspacePreviewController = new WorkspacePreviewController({
@@ -226,6 +236,7 @@ export class CollabMdAppShell {
226
236
  getDisplayName: (filePath) => this.getDisplayName(filePath),
227
237
  getSession: () => this.session,
228
238
  isExcalidrawFile: (filePath) => this.isExcalidrawFile(filePath),
239
+ isImageFile: (filePath) => this.isImageFile(filePath),
229
240
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
230
241
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
231
242
  layoutController: this.layoutController,
@@ -236,7 +247,7 @@ export class CollabMdAppShell {
236
247
  videoEmbed: this.videoEmbed,
237
248
  });
238
249
  this.wikiLinkFileController = new WikiLinkFileController({
239
- getFileList: () => this.fileExplorer.flatFiles,
250
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
240
251
  navigation: this.navigation,
241
252
  refreshExplorer: () => this.fileExplorer.refresh(),
242
253
  toastController: this.toastController,
@@ -271,6 +282,7 @@ export class CollabMdAppShell {
271
282
  lineInfoElement: this.elements.lineInfo,
272
283
  lineWrappingEnabled: options.lineWrappingEnabled,
273
284
  localUser: options.localUser,
285
+ onImagePaste: options.onImagePaste,
274
286
  onAwarenessChange: options.onAwarenessChange,
275
287
  onCommentsChange: options.onCommentsChange,
276
288
  onConnectionChange: options.onConnectionChange,
@@ -279,12 +291,13 @@ export class CollabMdAppShell {
279
291
  preferredUserName: options.preferredUserName,
280
292
  }),
281
293
  getDisplayName: (filePath) => this.getDisplayName(filePath),
282
- getFileList: () => this.fileExplorer.flatFiles,
294
+ getFileList: () => this.fileExplorer.flatDocumentFiles,
283
295
  getLineWrappingEnabled: () => this.getStoredLineWrapping(),
284
296
  getLocalUser: () => this.lobby.getLocalUser(),
285
297
  getStoredUserName: () => this.getStoredUserName(),
286
298
  getTheme: () => this.themeController.getTheme(),
287
299
  isExcalidrawFile: (filePath) => this.isExcalidrawFile(filePath),
300
+ isImageFile: (filePath) => this.isImageFile(filePath),
288
301
  isMermaidFile: (filePath) => this.isMermaidFile(filePath),
289
302
  isPlantUmlFile: (filePath) => this.isPlantUmlFile(filePath),
290
303
  isTabActive: () => this.isTabActive,
@@ -319,11 +332,13 @@ export class CollabMdAppShell {
319
332
  this.hideEditorLoading();
320
333
  },
321
334
  onSelectionChange: (anchor) => this.handleCommentSelectionChange(anchor),
335
+ onImagePaste: (file) => this.handleEditorImageInsert(file),
322
336
  onSessionAssigned: (session) => {
323
337
  this.session = session;
324
338
  this.commentUi.attachSession(session);
325
339
  },
326
340
  onRenderExcalidrawPreview: (filePath) => this.renderExcalidrawFilePreview(filePath),
341
+ onRenderImagePreview: (filePath) => this.renderImageFilePreview(filePath),
327
342
  onSyncWrapToggle: () => this.syncWrapToggle(),
328
343
  onUpdateActiveFile: (filePath) => this.fileExplorer.setActiveFile(filePath),
329
344
  onUpdateCurrentFile: (filePath) => {
@@ -354,6 +369,7 @@ export class CollabMdAppShell {
354
369
  getSessionLoadToken: () => this.sessionLoadToken,
355
370
  gitDiffView: this.gitDiffView,
356
371
  gitPanel: this.gitPanel,
372
+ imageLightbox: this.imageLightbox,
357
373
  layoutController: this.layoutController,
358
374
  lobby: this.lobby,
359
375
  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
  }