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.
- package/README.md +99 -17
- 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-AH6Z3MXW.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 +59 -59
- package/public/assets/js/preview-render-worker.js +19 -19
- 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/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 +15 -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 +34 -1
- package/src/client/infrastructure/vault-api-client.js +17 -0
- package/src/client/presentation/comment-markdown-renderer.js +68 -0
- package/src/client/presentation/comment-ui-controller.js +311 -15
- 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/image-lightbox-controller.js +394 -0
- package/src/client/styles/style.css +539 -14
- package/src/domain/comment-threads.js +95 -13
- package/src/domain/file-kind.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/vault-file-store.js +187 -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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|