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.
- package/README.md +107 -25
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
- package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
- package/public/assets/js/chunks/editor-session-TAV2VXTA.js +22 -0
- package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
- package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
- package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +100 -73
- package/public/assets/js/preview-render-worker.js +19 -19
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +29 -1
- package/src/client/application/app-shell/presence-feature.js +2 -1
- package/src/client/application/app-shell/ui-feature.js +113 -1
- package/src/client/application/app-shell/workspace-feature.js +9 -0
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/application/preview-render-compiler.js +55 -5
- package/src/client/application/preview-render-executor.js +8 -0
- package/src/client/application/preview-render-worker.js +13 -2
- package/src/client/application/preview-renderer.js +5 -0
- package/src/client/application/workspace-chrome-controller.js +12 -1
- package/src/client/application/workspace-coordinator.js +16 -7
- package/src/client/application/workspace-preview-controller.js +45 -5
- package/src/client/application/workspace-route-controller.js +4 -0
- package/src/client/bootstrap/collabmd-app-shell.js +19 -3
- package/src/client/domain/room.js +37 -0
- package/src/client/domain/vault-utils.js +46 -0
- package/src/client/infrastructure/comment-thread-store.js +98 -0
- package/src/client/infrastructure/editor-paste-utils.js +45 -0
- package/src/client/infrastructure/editor-session.js +10 -0
- package/src/client/infrastructure/editor-view-adapter.js +98 -7
- package/src/client/infrastructure/vault-api-client.js +17 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-markdown-renderer.js +68 -0
- package/src/client/presentation/comment-ui-controller.js +352 -20
- package/src/client/presentation/file-explorer-controller.js +5 -0
- package/src/client/presentation/file-explorer-view.js +10 -2
- package/src/client/presentation/file-tree-state.js +7 -1
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/image-lightbox-controller.js +394 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +677 -21
- package/src/domain/comment-threads.js +95 -13
- package/src/domain/file-kind.js +16 -1
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
- package/src/server/infrastructure/http/request-body.js +11 -2
- package/src/server/infrastructure/persistence/path-utils.js +1 -1
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +203 -2
- package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
- package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
- package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
- package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
- package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
- 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.
|
|
30
|
-
this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|