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
@@ -18,7 +18,8 @@ export const presenceFeature = {
18
18
 
19
19
  const hasEditorSession = Boolean(this.session);
20
20
  const isExcalidrawRoute = Boolean(this.currentFilePath && this.isExcalidrawFile?.(this.currentFilePath));
21
- const shouldUseLobbyState = !hasEditorSession && (!this.currentFilePath || isExcalidrawRoute);
21
+ const isImageRoute = Boolean(this.currentFilePath && this.isImageFile?.(this.currentFilePath));
22
+ const shouldUseLobbyState = !hasEditorSession && (!this.currentFilePath || isExcalidrawRoute || isImageRoute);
22
23
  const effectiveConnectionState = shouldUseLobbyState
23
24
  ? this.lobby?.getConnectionState?.() ?? this.connectionState
24
25
  : this.connectionState;
@@ -1,6 +1,8 @@
1
1
  import { USER_NAME_MAX_LENGTH, normalizeUserName } from '../../domain/room.js';
2
2
  import { isMarkdownFilePath, supportsBacklinksForFilePath } from '../../../domain/file-kind.js';
3
3
 
4
+ const IMAGE_FILE_PICKER_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
5
+
4
6
  export const uiFeature = {
5
7
  initialize() {
6
8
  this.themeController.initialize();
@@ -260,15 +262,125 @@ export const uiFeature = {
260
262
  return;
261
263
  }
262
264
 
265
+ if (action === 'image') {
266
+ void this.handleToolbarImageInsert();
267
+ return;
268
+ }
269
+
263
270
  const applied = this.session.applyMarkdownToolbarAction(action);
264
271
  if (!applied) {
265
272
  this.toastController.show('Formatting action is unavailable');
266
273
  }
267
274
  },
268
275
 
276
+ async handleToolbarImageInsert() {
277
+ const file = await this.pickImageFile();
278
+ if (!file) {
279
+ return;
280
+ }
281
+
282
+ await this.handleEditorImageInsert(file);
283
+ },
284
+
285
+ pickImageFile() {
286
+ return new Promise((resolve) => {
287
+ const input = document.createElement('input');
288
+ input.type = 'file';
289
+ input.accept = IMAGE_FILE_PICKER_ACCEPT;
290
+ input.style.position = 'fixed';
291
+ input.style.left = '-9999px';
292
+ document.body.appendChild(input);
293
+ let settled = false;
294
+ let focusTimer = null;
295
+
296
+ const cleanup = (value) => {
297
+ if (settled) {
298
+ return;
299
+ }
300
+
301
+ settled = true;
302
+ if (focusTimer) {
303
+ window.clearTimeout(focusTimer);
304
+ }
305
+ window.removeEventListener('focus', handleWindowFocus);
306
+ input.remove();
307
+ resolve(value);
308
+ };
309
+
310
+ const handleWindowFocus = () => {
311
+ focusTimer = window.setTimeout(() => {
312
+ if (settled || input.files?.length) {
313
+ return;
314
+ }
315
+
316
+ cleanup(null);
317
+ }, 250);
318
+ };
319
+
320
+ input.addEventListener('change', () => {
321
+ cleanup(input.files?.[0] ?? null);
322
+ }, { once: true });
323
+
324
+ input.addEventListener('cancel', () => {
325
+ cleanup(null);
326
+ }, { once: true });
327
+
328
+ window.addEventListener('focus', handleWindowFocus, { once: true });
329
+ input.click();
330
+ });
331
+ },
332
+
333
+ async handleEditorImageInsert(file) {
334
+ if (!this.session || !isMarkdownFilePath(this.currentFilePath)) {
335
+ console.warn('[ui] Ignoring image insert because there is no active markdown session.', {
336
+ currentFilePath: this.currentFilePath,
337
+ hasSession: Boolean(this.session),
338
+ });
339
+ return false;
340
+ }
341
+
342
+ const activeFilePath = this.currentFilePath;
343
+ const activeSession = this.session;
344
+
345
+ try {
346
+ console.debug('[ui] Uploading image attachment.', {
347
+ fileName: file?.name || '',
348
+ size: file?.size ?? null,
349
+ sourcePath: activeFilePath,
350
+ type: file?.type || '',
351
+ });
352
+ const result = await this.vaultApiClient.uploadImageAttachment({
353
+ file,
354
+ fileName: file?.name || '',
355
+ sourcePath: activeFilePath,
356
+ });
357
+
358
+ await this.fileExplorer.refresh();
359
+
360
+ if (
361
+ this.currentFilePath === activeFilePath
362
+ && this.session
363
+ && this.session === activeSession
364
+ && typeof result?.markdown === 'string'
365
+ ) {
366
+ console.debug('[ui] Inserting uploaded image markdown into the editor.', {
367
+ sourcePath: activeFilePath,
368
+ storedPath: result.path ?? '',
369
+ });
370
+ this.session.insertText(result.markdown);
371
+ }
372
+
373
+ return true;
374
+ } catch (error) {
375
+ console.error('[ui] Failed to insert image attachment:', error);
376
+ this.toastController.show(error.message || 'Failed to upload image');
377
+ return false;
378
+ }
379
+ },
380
+
269
381
  handleThemeChange(theme) {
270
382
  this.previewRenderer.applyTheme(theme);
271
- if (!this.isExcalidrawFile(this.currentFilePath)) {
383
+ if (!this.isExcalidrawFile(this.currentFilePath) && !this.isImageFile?.(this.currentFilePath)) {
272
384
  this.previewRenderer.queueRender();
273
385
  }
274
386
  this.session?.applyTheme(theme);
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isExcalidrawFilePath,
3
+ isImageAttachmentFilePath,
3
4
  isMermaidFilePath,
4
5
  isPlantUmlFilePath,
5
6
  stripVaultFileExtension,
@@ -10,6 +11,10 @@ export const workspaceFeature = {
10
11
  return isExcalidrawFilePath(filePath);
11
12
  },
12
13
 
14
+ isImageFile(filePath) {
15
+ return isImageAttachmentFilePath(filePath);
16
+ },
17
+
13
18
  isMermaidFile(filePath) {
14
19
  return isMermaidFilePath(filePath);
15
20
  },
@@ -44,6 +49,10 @@ export const workspaceFeature = {
44
49
  this.workspacePreviewController.renderExcalidrawFilePreview(filePath);
45
50
  },
46
51
 
52
+ renderImageFilePreview(filePath) {
53
+ this.workspacePreviewController.renderImageFilePreview(filePath);
54
+ },
55
+
47
56
  createResizeHandler() {
48
57
  return this.workspacePreviewController.createResizeHandler(() => this.restoreSidebarState());
49
58
  },
@@ -1,7 +1,8 @@
1
1
  import markdownIt from 'markdown-it';
2
2
  import hljs from 'highlight.js';
3
3
 
4
- import { escapeHtml, resolveWikiTarget } from '../domain/vault-utils.js';
4
+ import { isImageAttachmentFilePath } from '../../domain/file-kind.js';
5
+ import { escapeHtml, resolveVaultRelativePath, resolveWikiTarget } from '../domain/vault-utils.js';
5
6
  import { analyzeMarkdownComplexity } from './preview-render-profile.js';
6
7
 
7
8
  const DIRECT_VIDEO_MIME_TYPES = Object.freeze({
@@ -189,6 +190,35 @@ function renderVideoEmbed(token, videoEmbedCounts) {
189
190
  });
190
191
  }
191
192
 
193
+ function isAbsoluteOrExternalUrl(source = '') {
194
+ const normalized = String(source ?? '').trim();
195
+ return (
196
+ !normalized
197
+ || normalized.startsWith('data:')
198
+ || normalized.startsWith('blob:')
199
+ || normalized.startsWith('#')
200
+ || normalized.startsWith('//')
201
+ || /^[a-zA-Z][a-zA-Z\d+.-]*:/u.test(normalized)
202
+ || normalized.startsWith('/')
203
+ );
204
+ }
205
+
206
+ function resolveLocalAttachmentUrl(source = '', {
207
+ attachmentApiPath,
208
+ sourceFilePath,
209
+ } = {}) {
210
+ if (!sourceFilePath || isAbsoluteOrExternalUrl(source)) {
211
+ return null;
212
+ }
213
+
214
+ const resolvedPath = resolveVaultRelativePath(sourceFilePath, source);
215
+ if (!resolvedPath || !isImageAttachmentFilePath(resolvedPath)) {
216
+ return null;
217
+ }
218
+
219
+ return `${attachmentApiPath}?path=${encodeURIComponent(resolvedPath)}`;
220
+ }
221
+
192
222
  function renderInlineWikiText(content, {
193
223
  excalidrawEmbedCounts,
194
224
  fileList,
@@ -253,7 +283,10 @@ function renderInlineWikiText(content, {
253
283
  return html;
254
284
  }
255
285
 
256
- function createMarkdownRenderer(fileList = []) {
286
+ function createMarkdownRenderer(fileList = [], {
287
+ attachmentApiPath = '/api/attachment',
288
+ sourceFilePath = '',
289
+ } = {}) {
257
290
  const markdown = markdownIt({
258
291
  highlight(source, language) {
259
292
  if (language === 'mermaid' || language === 'plantuml' || language === 'puml') {
@@ -396,7 +429,16 @@ function createMarkdownRenderer(fileList = []) {
396
429
  };
397
430
 
398
431
  markdown.renderer.rules.image = (tokens, index, options, env, self) => {
399
- const renderedVideo = renderVideoEmbed(tokens[index], videoEmbedCounts);
432
+ const token = tokens[index];
433
+ const localAttachmentUrl = resolveLocalAttachmentUrl(token.attrGet('src') || '', {
434
+ attachmentApiPath,
435
+ sourceFilePath,
436
+ });
437
+ if (localAttachmentUrl) {
438
+ token.attrSet('src', localAttachmentUrl);
439
+ }
440
+
441
+ const renderedVideo = renderVideoEmbed(token, videoEmbedCounts);
400
442
  if (renderedVideo) {
401
443
  return renderedVideo;
402
444
  }
@@ -415,9 +457,17 @@ function createMarkdownRenderer(fileList = []) {
415
457
  return markdown;
416
458
  }
417
459
 
418
- export function compilePreviewDocument({ fileList = [], markdownText = '' } = {}) {
460
+ export function compilePreviewDocument({
461
+ attachmentApiPath = '/api/attachment',
462
+ fileList = [],
463
+ markdownText = '',
464
+ sourceFilePath = '',
465
+ } = {}) {
419
466
  const normalizedMarkdown = String(markdownText);
420
- const renderer = createMarkdownRenderer(fileList);
467
+ const renderer = createMarkdownRenderer(fileList, {
468
+ attachmentApiPath,
469
+ sourceFilePath,
470
+ });
421
471
 
422
472
  return {
423
473
  html: renderer.render(normalizedMarkdown),
@@ -10,17 +10,21 @@ function createPreviewWorker() {
10
10
 
11
11
  export class PreviewRenderExecutor {
12
12
  constructor({
13
+ attachmentApiPath = '/api/attachment',
13
14
  cancelIdleRenderFn = cancelIdleRender,
14
15
  compilePreviewDocumentLoader = () => import('./preview-render-compiler.js'),
15
16
  createWorkerFn = createPreviewWorker,
16
17
  getFileList,
18
+ getSourceFilePath = null,
17
19
  idleTimeoutMs = IDLE_RENDER_TIMEOUT_MS,
18
20
  requestIdleRenderFn = requestIdleRender,
19
21
  } = {}) {
22
+ this.attachmentApiPath = attachmentApiPath;
20
23
  this.cancelIdleRenderFn = cancelIdleRenderFn;
21
24
  this.compilePreviewDocumentLoader = compilePreviewDocumentLoader;
22
25
  this.createWorkerFn = createWorkerFn;
23
26
  this.getFileList = getFileList;
27
+ this.getSourceFilePath = getSourceFilePath;
24
28
  this.idleTimeoutMs = idleTimeoutMs;
25
29
  this.requestIdleRenderFn = requestIdleRenderFn;
26
30
  this.worker = null;
@@ -105,17 +109,21 @@ export class PreviewRenderExecutor {
105
109
  return new Promise((resolve, reject) => {
106
110
  this.workerJob = { reject, renderVersion, resolve };
107
111
  activeWorker.postMessage({
112
+ attachmentApiPath: this.attachmentApiPath,
108
113
  fileList: this.getFileList?.() ?? [],
109
114
  markdownText,
110
115
  renderVersion,
116
+ sourceFilePath: this.getSourceFilePath?.() ?? '',
111
117
  });
112
118
  });
113
119
  }
114
120
 
115
121
  const { compilePreviewDocument } = await this.compilePreviewDocumentLoader();
116
122
  return compilePreviewDocument({
123
+ attachmentApiPath: this.attachmentApiPath,
117
124
  fileList: this.getFileList?.() ?? [],
118
125
  markdownText,
126
+ sourceFilePath: this.getSourceFilePath?.() ?? '',
119
127
  });
120
128
  }
121
129
 
@@ -1,10 +1,21 @@
1
1
  import { compilePreviewDocument } from './preview-render-compiler.js';
2
2
 
3
3
  self.onmessage = (event) => {
4
- const { fileList, markdownText, renderVersion } = event.data ?? {};
4
+ const {
5
+ attachmentApiPath,
6
+ fileList,
7
+ markdownText,
8
+ renderVersion,
9
+ sourceFilePath,
10
+ } = event.data ?? {};
5
11
 
6
12
  try {
7
- const result = compilePreviewDocument({ fileList, markdownText });
13
+ const result = compilePreviewDocument({
14
+ attachmentApiPath,
15
+ fileList,
16
+ markdownText,
17
+ sourceFilePath,
18
+ });
8
19
  self.postMessage({
9
20
  html: result.html,
10
21
  renderVersion,
@@ -4,11 +4,13 @@ import { IDLE_RENDER_TIMEOUT_MS } from './preview-diagram-utils.js';
4
4
  import { PreviewRenderExecutor } from './preview-render-executor.js';
5
5
  import { getRenderProfile, isLargeDocumentStats } from './preview-render-profile.js';
6
6
  import { PreviewRenderScheduler } from './preview-render-scheduler.js';
7
+ import { resolveApiUrl } from '../domain/runtime-paths.js';
7
8
 
8
9
  export class PreviewRenderer {
9
10
  constructor({
10
11
  getContent,
11
12
  getFileList,
13
+ getSourceFilePath,
12
14
  onAfterRenderCommit,
13
15
  onBeforeRenderCommit,
14
16
  onRenderComplete,
@@ -18,6 +20,7 @@ export class PreviewRenderer {
18
20
  }) {
19
21
  this.getContent = getContent;
20
22
  this.getFileList = getFileList;
23
+ this.getSourceFilePath = getSourceFilePath;
21
24
  this.onAfterRenderCommit = onAfterRenderCommit;
22
25
  this.onBeforeRenderCommit = onBeforeRenderCommit;
23
26
  this.onRenderComplete = onRenderComplete;
@@ -67,7 +70,9 @@ export class PreviewRenderer {
67
70
  this.plantUmlHydrator = new PlantUmlPreviewHydrator(this);
68
71
  this.renderScheduler = new PreviewRenderScheduler({ getRenderProfileFn: getRenderProfile });
69
72
  this.renderExecutor = new PreviewRenderExecutor({
73
+ attachmentApiPath: resolveApiUrl('/attachment'),
70
74
  getFileList: () => this.getFileList?.() ?? [],
75
+ getSourceFilePath: () => this.getSourceFilePath?.() ?? '',
71
76
  idleTimeoutMs: IDLE_RENDER_TIMEOUT_MS,
72
77
  });
73
78
 
@@ -12,6 +12,7 @@ export class WorkspaceChromeController {
12
12
  onFileOpenError,
13
13
  onFileOpenReady,
14
14
  onRenderExcalidrawPreview,
15
+ onRenderImagePreview,
15
16
  onSyncWrapToggle,
16
17
  onUpdateActiveFile,
17
18
  onUpdateCurrentFile,
@@ -29,6 +30,7 @@ export class WorkspaceChromeController {
29
30
  this.onFileOpenError = onFileOpenError;
30
31
  this.onFileOpenReady = onFileOpenReady;
31
32
  this.onRenderExcalidrawPreview = onRenderExcalidrawPreview;
33
+ this.onRenderImagePreview = onRenderImagePreview;
32
34
  this.onSyncWrapToggle = onSyncWrapToggle;
33
35
  this.onUpdateActiveFile = onUpdateActiveFile;
34
36
  this.onUpdateCurrentFile = onUpdateCurrentFile;
@@ -68,10 +70,19 @@ export class WorkspaceChromeController {
68
70
  this.onFileOpenReady(session);
69
71
  }
70
72
 
71
- finalizeFileOpen({ filePath, isExcalidraw, session = null, supportsBacklinks }) {
73
+ finalizeFileOpen({
74
+ filePath,
75
+ isExcalidraw = false,
76
+ isImage = false,
77
+ session = null,
78
+ supportsBacklinks,
79
+ }) {
72
80
  if (isExcalidraw) {
73
81
  this.onRenderExcalidrawPreview(filePath);
74
82
  }
83
+ if (isImage) {
84
+ this.onRenderImagePreview(filePath);
85
+ }
75
86
  this.onSyncWrapToggle();
76
87
  if (supportsBacklinks && session) {
77
88
  this.loadBacklinks(filePath);
@@ -14,6 +14,7 @@ export class WorkspaceCoordinator {
14
14
  getStoredUserName,
15
15
  getTheme,
16
16
  isExcalidrawFile,
17
+ isImageFile,
17
18
  isMermaidFile,
18
19
  isPlantUmlFile,
19
20
  isTabActive,
@@ -26,9 +27,11 @@ export class WorkspaceCoordinator {
26
27
  onFileAwarenessChange,
27
28
  onFileOpenError,
28
29
  onFileOpenReady,
30
+ onImagePaste,
29
31
  onSelectionChange,
30
32
  onSessionAssigned = null,
31
33
  onRenderExcalidrawPreview,
34
+ onRenderImagePreview,
32
35
  onSyncWrapToggle,
33
36
  onUpdateActiveFile,
34
37
  onUpdateCurrentFile,
@@ -50,9 +53,10 @@ export class WorkspaceCoordinator {
50
53
  this.getLocalUser = getLocalUser;
51
54
  this.getStoredUserName = getStoredUserName;
52
55
  this.getTheme = getTheme;
53
- this.isExcalidrawFile = isExcalidrawFile;
54
- this.isMermaidFile = isMermaidFile;
55
- this.isPlantUmlFile = isPlantUmlFile;
56
+ this.isExcalidrawFile = isExcalidrawFile ?? (() => false);
57
+ this.isImageFile = isImageFile ?? (() => false);
58
+ this.isMermaidFile = isMermaidFile ?? (() => false);
59
+ this.isPlantUmlFile = isPlantUmlFile ?? (() => false);
56
60
  this.isTabActive = isTabActive;
57
61
  this.loadEditorSessionClassPort = loadEditorSessionClass;
58
62
  this.loadBacklinks = loadBacklinks;
@@ -63,6 +67,7 @@ export class WorkspaceCoordinator {
63
67
  this.onFileAwarenessChange = onFileAwarenessChange;
64
68
  this.onFileOpenError = onFileOpenError;
65
69
  this.onFileOpenReady = onFileOpenReady;
70
+ this.onImagePaste = onImagePaste;
66
71
  this.onSelectionChange = onSelectionChange;
67
72
  this.onSessionAssigned = onSessionAssigned;
68
73
  this.onRenderExcalidrawPreview = onRenderExcalidrawPreview;
@@ -88,6 +93,7 @@ export class WorkspaceCoordinator {
88
93
  onFileOpenError,
89
94
  onFileOpenReady,
90
95
  onRenderExcalidrawPreview,
96
+ onRenderImagePreview,
91
97
  onSyncWrapToggle,
92
98
  onUpdateActiveFile,
93
99
  onUpdateCurrentFile,
@@ -129,10 +135,11 @@ export class WorkspaceCoordinator {
129
135
  }
130
136
 
131
137
  const isExcalidraw = this.isExcalidrawFile(filePath);
138
+ const isImage = this.isImageFile(filePath);
132
139
  const isMermaid = this.isMermaidFile(filePath);
133
140
  const isPlantUml = this.isPlantUmlFile(filePath);
134
141
 
135
- if (filePath === this.stateStore.get('currentFilePath') && (this.session || isExcalidraw)) {
142
+ if (filePath === this.stateStore.get('currentFilePath') && (this.session || isExcalidraw || isImage)) {
136
143
  this.onUpdateActiveFile(filePath);
137
144
  this.onUpdateLobbyCurrentFile(filePath);
138
145
  return;
@@ -142,10 +149,10 @@ export class WorkspaceCoordinator {
142
149
 
143
150
  this.cleanupSession();
144
151
  const chromeState = this.chromeController.prepareForFileOpen(filePath, {
145
- resetConnectionState: !isExcalidraw,
152
+ resetConnectionState: !isExcalidraw && !isImage,
146
153
  });
147
154
 
148
- if (isExcalidraw) {
155
+ if (isExcalidraw || isImage) {
149
156
  this.onSessionAssigned?.(null);
150
157
 
151
158
  if (loadToken !== this.stateStore.get('sessionLoadToken')) {
@@ -155,7 +162,8 @@ export class WorkspaceCoordinator {
155
162
  this.chromeController.markFileOpenReady(null);
156
163
  this.chromeController.finalizeFileOpen({
157
164
  filePath,
158
- isExcalidraw: true,
165
+ isExcalidraw,
166
+ isImage,
159
167
  session: null,
160
168
  supportsBacklinks: chromeState.supportsBacklinks,
161
169
  });
@@ -180,6 +188,7 @@ export class WorkspaceCoordinator {
180
188
  isPlantUml,
181
189
  });
182
190
  },
191
+ onImagePaste: (file) => this.onImagePaste?.(file),
183
192
  preferredUserName: this.getStoredUserName(),
184
193
  onSelectionChange: (anchor) => this.onSelectionChange?.(anchor),
185
194
  theme: this.getTheme(),
@@ -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();