collabmd 0.1.31 → 0.1.32

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 (93) hide show
  1. package/dist/client/assets/{drawio-editor-B4sHHwWJ.js → drawio-editor-Br4qyh3r.js} +1 -1
  2. package/dist/client/assets/drawioEditor-ClmnTx5J.js +2 -0
  3. package/dist/client/assets/{editor-session-BKHEYkbY.js → editor-session-Cf-iV5-3.js} +2 -2
  4. package/dist/client/assets/embedded-editor-base-CgEkjNYn.css +1 -0
  5. package/dist/client/assets/{excalidraw-editor-CHbeNZu6.js → excalidraw-editor-CXAxVhlw.js} +2 -2
  6. package/dist/client/assets/{excalidrawEditor-BhaTX2Ko.js → excalidrawEditor-DoH_kMdu.js} +2 -2
  7. package/dist/client/assets/exportDocument-9wTrBZNS.css +1 -0
  8. package/dist/client/assets/{index-kPgEYvEX.js → index-C8QZYHvV.js} +2 -2
  9. package/dist/client/assets/index-CxSbUTs2.css +1 -0
  10. package/dist/client/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  11. package/dist/client/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  12. package/dist/client/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  13. package/dist/client/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  14. package/dist/client/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  15. package/dist/client/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  16. package/dist/client/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  17. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  18. package/dist/client/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  19. package/dist/client/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  20. package/dist/client/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  21. package/dist/client/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  22. package/dist/client/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  23. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  24. package/dist/client/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  25. package/dist/client/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  26. package/dist/client/assets/main-BjHHMlv0.js +1249 -0
  27. package/dist/client/assets/vault-api-client-Bf9tB_GW.js +1 -0
  28. package/dist/client/assets/vault-api-client-D7Ck0CvR.css +1 -0
  29. package/dist/client/drawio-editor.html +1 -1
  30. package/dist/client/excalidraw-editor.html +1 -1
  31. package/dist/client/export-document.html +2 -2
  32. package/dist/client/index.html +2 -2
  33. package/package.json +4 -1
  34. package/src/client/app/main-entry.js +2 -0
  35. package/src/client/application/app-shell/git-feature.js +28 -36
  36. package/src/client/application/app-shell/presence-feature.js +3 -1
  37. package/src/client/application/diagram-preview-hydrator.js +24 -15
  38. package/src/client/application/mermaid-preview-hydrator.js +121 -4
  39. package/src/client/application/plantuml-preview-hydrator.js +6 -16
  40. package/src/client/application/preview-renderer.js +11 -2
  41. package/src/client/bootstrap/collabmd-app-shell.js +176 -50
  42. package/src/client/domain/excalidraw-document-switch.js +3 -0
  43. package/src/client/excalidraw-editor.js +187 -84
  44. package/src/client/infrastructure/auth-client.js +4 -4
  45. package/src/client/infrastructure/backlinks-api-client.js +18 -0
  46. package/src/client/infrastructure/comment-thread-store.js +4 -0
  47. package/src/client/infrastructure/editor-session.js +2 -0
  48. package/src/client/infrastructure/git-api-client.js +134 -0
  49. package/src/client/infrastructure/plantuml-api-client.js +21 -0
  50. package/src/client/infrastructure/vault-api-client.js +67 -0
  51. package/src/client/presentation/backlinks-panel.js +6 -9
  52. package/src/client/presentation/bases-preview-controller.js +26 -7
  53. package/src/client/presentation/excalidraw-embed-controller.js +251 -15
  54. package/src/client/presentation/excalidraw-embed-reconciler.js +0 -1
  55. package/src/client/presentation/file-action-controller.js +97 -0
  56. package/src/client/presentation/file-explorer-controller.js +2 -0
  57. package/src/client/presentation/file-explorer-view.js +368 -0
  58. package/src/client/presentation/file-history-view-controller.js +41 -40
  59. package/src/client/presentation/git-diff-view-controller.js +70 -53
  60. package/src/client/presentation/git-panel-controller.js +12 -30
  61. package/src/client/styles/export-document.css +45 -2
  62. package/src/client/styles/features/auth-gate.css +13 -24
  63. package/src/client/styles/features/comment-card.css +6 -0
  64. package/src/client/styles/features/comments-drawer.css +38 -18
  65. package/src/client/styles/features/diagram-preview.css +33 -0
  66. package/src/client/styles/features/embedded-preview.css +37 -11
  67. package/src/client/styles/features/preview-markdown.css +103 -28
  68. package/src/client/styles/foundation/themes.css +4 -0
  69. package/src/client/styles/foundation/tokens.css +4 -3
  70. package/src/client/styles/layout/sidebar.css +72 -0
  71. package/src/server/auth/auth-common.js +224 -0
  72. package/src/server/auth/auth-constants.js +30 -0
  73. package/src/server/auth/auth-strategies.js +320 -0
  74. package/src/server/auth/create-auth-service.js +24 -545
  75. package/src/server/domain/bases/base-definition.js +255 -0
  76. package/src/server/domain/bases/base-expression-runtime.js +1159 -0
  77. package/src/server/domain/bases/base-index-snapshot-store.js +418 -0
  78. package/src/server/domain/bases/base-query-results.js +205 -0
  79. package/src/server/domain/bases/base-query-service.js +68 -1965
  80. package/src/server/domain/collaboration/collaboration-room.js +36 -1
  81. package/src/server/domain/collaboration/room-client-state-store.js +1 -0
  82. package/src/server/domain/collaboration/room-persistence-controller.js +1 -1
  83. package/src/server/infrastructure/http/create-vault-api-query-handler.js +109 -1
  84. package/src/server/infrastructure/http/http-response.js +56 -0
  85. package/src/server/infrastructure/persistence/vault-file-store.js +110 -0
  86. package/dist/client/assets/drawioEditor-DGpo2hcx.js +0 -2
  87. package/dist/client/assets/embedded-editor-base-DgmRcsR8.css +0 -1
  88. package/dist/client/assets/exportDocument-DmV7Gngb.css +0 -1
  89. package/dist/client/assets/index-D69HOZHr.css +0 -1
  90. package/dist/client/assets/main-DSxNTtD-.js +0 -1243
  91. package/dist/client/assets/vault-api-client-BzHQseMN.js +0 -1
  92. package/dist/client/assets/vault-api-client-De7kMvrE.css +0 -1
  93. /package/dist/client/assets/{exportDocument-CtZKWE2c.js → exportDocument-Bia2hI25.js} +0 -0
@@ -4,6 +4,39 @@ function encodeHeaderMetadata(value) {
4
4
  return encodeURIComponent(String(value ?? ''));
5
5
  }
6
6
 
7
+ function getPathLeaf(pathValue) {
8
+ return String(pathValue ?? '')
9
+ .replace(/\/+$/u, '')
10
+ .split('/')
11
+ .filter(Boolean)
12
+ .pop() || '';
13
+ }
14
+
15
+ function downloadBlob(blob, fileName) {
16
+ const url = URL.createObjectURL(blob);
17
+ const anchor = document.createElement('a');
18
+ anchor.href = url;
19
+ anchor.download = fileName;
20
+ document.body.appendChild(anchor);
21
+ anchor.click();
22
+ anchor.remove();
23
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
24
+ }
25
+
26
+ function parseDownloadFileName(contentDisposition = '', fallbackName = 'download') {
27
+ const utfMatch = String(contentDisposition).match(/filename\*=UTF-8''([^;]+)/iu);
28
+ if (utfMatch) {
29
+ try {
30
+ return decodeURIComponent(utfMatch[1]);
31
+ } catch {
32
+ return fallbackName;
33
+ }
34
+ }
35
+
36
+ const asciiMatch = String(contentDisposition).match(/filename="([^"]+)"/iu);
37
+ return asciiMatch?.[1] || fallbackName;
38
+ }
39
+
7
40
  function createRequestHeaders(requestId, headers = {}) {
8
41
  const nextHeaders = { ...headers };
9
42
  if (requestId) {
@@ -25,6 +58,24 @@ async function parseApiResponse(response, fallbackError) {
25
58
  return data;
26
59
  }
27
60
 
61
+ async function triggerDownload(url, {
62
+ fallbackError,
63
+ fallbackFileName,
64
+ } = {}) {
65
+ const response = await fetch(url);
66
+ if (!response.ok) {
67
+ const payload = await response.json().catch(() => ({}));
68
+ throw new Error(payload.error || fallbackError);
69
+ }
70
+
71
+ const blob = await response.blob();
72
+ downloadBlob(
73
+ blob,
74
+ parseDownloadFileName(response.headers.get('content-disposition') || '', fallbackFileName),
75
+ );
76
+ return response;
77
+ }
78
+
28
79
  export class VaultApiClient {
29
80
  async readTree() {
30
81
  const response = await fetch(resolveApiUrl('/files'));
@@ -170,6 +221,22 @@ export class VaultApiClient {
170
221
  });
171
222
  return parseApiResponse(response, 'Failed to upload image');
172
223
  }
224
+
225
+ async downloadFile(path) {
226
+ const fallbackFileName = getPathLeaf(path) || 'download';
227
+ return triggerDownload(resolveApiUrl(`/download/file?path=${encodeURIComponent(path)}`), {
228
+ fallbackError: 'Failed to download file',
229
+ fallbackFileName,
230
+ });
231
+ }
232
+
233
+ async downloadDirectory(path) {
234
+ const directoryName = getPathLeaf(path) || 'vault';
235
+ return triggerDownload(resolveApiUrl(`/download/directory?path=${encodeURIComponent(path)}`), {
236
+ fallbackError: 'Failed to download folder',
237
+ fallbackFileName: `${directoryName}.zip`,
238
+ });
239
+ }
173
240
  }
174
241
 
175
242
  export const vaultApiClient = new VaultApiClient();
@@ -1,6 +1,5 @@
1
1
  import { stripVaultFileExtension } from '../../domain/file-kind.js';
2
2
  import { escapeHtml } from '../domain/vault-utils.js';
3
- import { resolveApiUrl } from '../domain/runtime-paths.js';
4
3
 
5
4
  /**
6
5
  * BacklinksPanel — renders "Linked Mentions" for the current file.
@@ -13,6 +12,7 @@ export class BacklinksPanel {
13
12
  documentRef = document,
14
13
  headerPanelElement = null,
15
14
  inlinePanelElement = null,
15
+ loadBacklinks = null,
16
16
  onFileSelect,
17
17
  panelElement,
18
18
  }) {
@@ -20,6 +20,7 @@ export class BacklinksPanel {
20
20
  this.headerPanel = headerPanelElement;
21
21
  this.panelRoot = panelElement;
22
22
  this.inlinePanel = inlinePanelElement;
23
+ this.loadBacklinks = loadBacklinks;
23
24
  this.onFileSelect = onFileSelect;
24
25
 
25
26
  this._expanded = false;
@@ -132,15 +133,11 @@ export class BacklinksPanel {
132
133
  this._fetchController = new AbortController();
133
134
 
134
135
  try {
135
- const response = await fetch(
136
- resolveApiUrl(`/backlinks?file=${encodeURIComponent(filePath)}`),
137
- { signal: this._fetchController.signal },
138
- );
139
- const data = await response.json();
140
-
136
+ const backlinks = await this.loadBacklinks(filePath, {
137
+ signal: this._fetchController.signal,
138
+ });
141
139
  if (this._currentFile !== filePath) return;
142
-
143
- this._backlinks = data.backlinks ?? [];
140
+ this._backlinks = backlinks;
144
141
  } catch (error) {
145
142
  if (error.name === 'AbortError') return;
146
143
  console.warn('[backlinks] Failed to load:', error.message);
@@ -1,6 +1,9 @@
1
1
  import { isImageAttachmentFilePath } from '../../domain/file-kind.js';
2
2
  import { resolveApiUrl } from '../domain/runtime-paths.js';
3
3
  import { escapeHtml } from '../domain/vault-utils.js';
4
+ import { buttonClassNames } from './components/ui/button.js';
5
+ import { inputClassNames } from './components/ui/input.js';
6
+ import { segmentedButtonClassNames, segmentedControlClassNames } from './components/ui/segmented-control.js';
4
7
 
5
8
  function createShellKey() {
6
9
  return `base-${Math.random().toString(36).slice(2, 10)}`;
@@ -73,11 +76,11 @@ function renderValue(cell = {}) {
73
76
  return `<a class="bases-link" href="${escapeHtml(cell.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(formatCellText(cell))}</a>`;
74
77
  }
75
78
  if (cell.path) {
76
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
79
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
77
80
  }
78
81
  }
79
82
  if (type === 'file' && cell.path) {
80
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
83
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(cell.path)}">${escapeHtml(formatCellText(cell))}</button>`;
81
84
  }
82
85
 
83
86
  const text = formatCellText(cell);
@@ -92,7 +95,7 @@ function renderRowValue(column, row) {
92
95
  && cell.type !== 'empty'
93
96
  && (column.id === 'file.name' || column.id === 'file.basename')
94
97
  ) {
95
- return `<button type="button" class="bases-link bases-link-button" data-base-open-file="${escapeHtml(row.path)}">${escapeHtml(formatCellText(cell))}</button>`;
98
+ return `<button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', size: 'compact', pill: true, extra: ['bases-link', 'bases-link-button'] }))}" data-base-open-file="${escapeHtml(row.path)}">${escapeHtml(formatCellText(cell))}</button>`;
96
99
  }
97
100
 
98
101
  return renderValue(cell);
@@ -171,7 +174,7 @@ function renderViewBody(result) {
171
174
 
172
175
  function renderViewTabs(result) {
173
176
  return (result.views ?? []).map((view) => (
174
- `<button type="button" class="bases-view-tab${view.id === result.view.id ? ' is-active' : ''}" data-base-view="${escapeHtml(view.id)}">${escapeHtml(view.name)}</button>`
177
+ `<button type="button" class="${escapeHtml(segmentedButtonClassNames({ active: view.id === result.view.id, extra: 'bases-view-tab' }))}" data-base-view="${escapeHtml(view.id)}">${escapeHtml(view.name)}</button>`
175
178
  )).join('');
176
179
  }
177
180
 
@@ -180,14 +183,20 @@ function renderShellHtml(result, state) {
180
183
  <section class="bases-shell" data-base-shell-key="${escapeHtml(state.key)}">
181
184
  <header class="bases-toolbar">
182
185
  <div class="bases-toolbar-main">
183
- <div class="bases-tabs" data-base-tabs>
186
+ <div class="${escapeHtml(segmentedControlClassNames({ pill: true, extra: 'bases-tabs' }))}" data-base-tabs>
184
187
  ${renderViewTabs(result)}
185
188
  </div>
186
189
  <div class="bases-meta" data-base-meta>${escapeHtml(String(result.totalRows ?? 0))} results</div>
187
190
  </div>
188
191
  <div class="bases-toolbar-actions">
189
- <input class="bases-search-input" type="search" value="${escapeHtml(state.search)}" placeholder="Search this base">
190
- <button type="button" class="bases-export-btn">Export CSV</button>
192
+ <label class="bases-search-shell" aria-label="Search this base">
193
+ <svg class="bases-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
194
+ <circle cx="11" cy="11" r="7"></circle>
195
+ <path d="m20 20-3.5-3.5"></path>
196
+ </svg>
197
+ <input class="${escapeHtml(inputClassNames({ extra: 'bases-search-input' }))}" type="search" value="${escapeHtml(state.search)}" placeholder="Search this base" aria-label="Search this base">
198
+ </label>
199
+ <button type="button" class="${escapeHtml(buttonClassNames({ variant: 'secondary', pill: true, extra: 'bases-export-btn' }))}">Export CSV</button>
191
200
  </div>
192
201
  </header>
193
202
  <div data-base-summary-slot>${renderSummaryBar(result.summaries)}</div>
@@ -205,10 +214,20 @@ function findShellElement(entry) {
205
214
  ?? entry.placeholder.querySelector('.bases-shell');
206
215
  }
207
216
 
217
+ function markPlaceholderHydrated(placeholder) {
218
+ if (!placeholder?.classList) {
219
+ return;
220
+ }
221
+
222
+ placeholder.classList.add('is-hydrated');
223
+ placeholder.classList.remove('diagram-preview-shell');
224
+ }
225
+
208
226
  function updateShellContent(entry, result) {
209
227
  const shell = findShellElement(entry);
210
228
  if (!shell?.querySelector) {
211
229
  entry.placeholder.innerHTML = renderShellHtml(result, entry);
230
+ markPlaceholderHydrated(entry.placeholder);
212
231
  return;
213
232
  }
214
233
 
@@ -75,6 +75,8 @@ export class ExcalidrawEmbedController {
75
75
  this.maximizedEmbed = null;
76
76
  this.overlayRoot = null;
77
77
  this.maximizedRoot = null;
78
+ this.warmCacheRoot = null;
79
+ this.warmEntry = null;
78
80
  this.placeholderObserver = null;
79
81
  this.followedPeerIdsByFilePath = new Map();
80
82
  this.pendingDisconnectRequests = new Map();
@@ -106,6 +108,11 @@ export class ExcalidrawEmbedController {
106
108
  entry.placeholder = null;
107
109
  });
108
110
  this.embedEntries.clear();
111
+ if (this.warmEntry) {
112
+ this._clearEntryBootTimeout(this.warmEntry);
113
+ this.warmEntry.wrapper?.remove();
114
+ this.warmEntry = null;
115
+ }
109
116
  this.pendingDisconnectRequests.forEach((request) => {
110
117
  request.resolve(false);
111
118
  });
@@ -115,6 +122,8 @@ export class ExcalidrawEmbedController {
115
122
  this.overlayRoot = null;
116
123
  this.maximizedRoot?.remove();
117
124
  this.maximizedRoot = null;
125
+ this.warmCacheRoot?.remove();
126
+ this.warmCacheRoot = null;
118
127
  }
119
128
 
120
129
  async setFollowedUser(filePath, peerId = null) {
@@ -186,6 +195,7 @@ export class ExcalidrawEmbedController {
186
195
  }));
187
196
 
188
197
  const { nextEntries, removedEntries } = reconcileEmbedEntries(this.embedEntries, descriptors);
198
+ this._captureWarmEntry(removedEntries);
189
199
  this._adoptReusableEntries(nextEntries, removedEntries);
190
200
  removedEntries.forEach((entry) => this._destroyEntry(entry));
191
201
  this.embedEntries = nextEntries;
@@ -196,8 +206,7 @@ export class ExcalidrawEmbedController {
196
206
  entry.queued = false;
197
207
  entry.followedPeerId = this.followedPeerIdsByFilePath.get(entry.filePath);
198
208
  if (entry.wrapper) {
199
- this._updateEmbedLabel(entry);
200
- this._attachWrapper(entry);
209
+ void this._hydrateEntry(entry);
201
210
  return;
202
211
  }
203
212
 
@@ -233,7 +242,7 @@ export class ExcalidrawEmbedController {
233
242
  }
234
243
 
235
244
  updateTheme(theme) {
236
- this.embedEntries.forEach((entry) => {
245
+ this._forEachManagedEntry((entry) => {
237
246
  this._postMessageToEntry(entry, {
238
247
  source: 'collabmd-host',
239
248
  type: 'set-theme',
@@ -243,7 +252,7 @@ export class ExcalidrawEmbedController {
243
252
  }
244
253
 
245
254
  updateLocalUser(user) {
246
- this.embedEntries.forEach((entry) => {
255
+ this._forEachManagedEntry((entry) => {
247
256
  this._syncEntryUser(entry, user);
248
257
  });
249
258
  }
@@ -390,19 +399,31 @@ export class ExcalidrawEmbedController {
390
399
  return;
391
400
  }
392
401
 
402
+ entry.isParked = false;
403
+
393
404
  if (!entry.wrapper) {
394
405
  const mount = this._createEmbedContainer(entry);
406
+ entry.header = mount.header;
395
407
  entry.wrapper = mount.wrapper;
396
408
  entry.iframe = mount.iframe;
397
409
  entry.labelElement = mount.labelElement;
410
+ entry.maxButton = mount.maxButton;
411
+ entry.openButton = mount.openButton;
398
412
  entry.instanceId = mount.instanceId;
399
413
  entry.isReady = false;
414
+ entry.bootFilePath = entry.filePath;
415
+ entry.bootMode = this._getEntryMode(entry);
416
+ entry.loadedFilePath = entry.bootFilePath;
417
+ entry.loadedMode = entry.bootMode;
400
418
  entry.bootAttempts = (entry.bootAttempts ?? 0) + 1;
401
419
  this._armEntryBootTimeout(entry);
402
420
  }
403
421
 
404
422
  entry.followedPeerId = this.followedPeerIdsByFilePath.get(entry.filePath);
405
423
  this._updateEmbedLabel(entry);
424
+ if (this._entryNeedsHardReload(entry)) {
425
+ this._reloadEntry(entry);
426
+ }
406
427
  this._attachWrapper(entry);
407
428
  }
408
429
 
@@ -444,22 +465,16 @@ export class ExcalidrawEmbedController {
444
465
  return;
445
466
  }
446
467
 
447
- const removedIndex = removedEntries.findIndex((entry) => (
448
- entry.filePath === nextEntry.filePath
449
- && this._getEntryMode(entry) === this._getEntryMode(nextEntry)
450
- && (entry.wrapper || entry.iframe)
451
- ));
452
- if (removedIndex < 0) {
468
+ const reusedEntry = this._claimReusableEntry(nextEntry, removedEntries);
469
+ if (!reusedEntry) {
453
470
  return;
454
471
  }
455
472
 
456
- const [reusedEntry] = removedEntries.splice(removedIndex, 1);
457
473
  reusedEntry.filePath = nextEntry.filePath;
458
474
  reusedEntry.key = nextEntry.key;
459
475
  reusedEntry.label = nextEntry.label;
460
476
  reusedEntry.placeholder = nextEntry.placeholder;
461
477
  reusedEntry.queued = false;
462
- reusedEntry.reusedFromEmbed = this._isFilePreviewEntry(nextEntry);
463
478
  nextEntries.set(key, reusedEntry);
464
479
  });
465
480
  }
@@ -477,16 +492,20 @@ export class ExcalidrawEmbedController {
477
492
  }
478
493
 
479
494
  this._clearEntryBootTimeout(entry);
495
+ if (this.warmEntry === entry) {
496
+ this.warmEntry = null;
497
+ }
480
498
  this._resetPlaceholderLayout(entry);
499
+ this._setEntryLoadingState(entry, false);
481
500
  entry.wrapper?.remove();
482
501
  entry.placeholder = null;
483
502
  }
484
503
 
485
- _armEntryBootTimeout(entry) {
504
+ _armEntryBootTimeout(entry, delayMs = IFRAME_BOOT_TIMEOUT_MS) {
486
505
  this._clearEntryBootTimeout(entry);
487
506
  entry.bootTimerId = window.setTimeout(() => {
488
507
  void this._handleEntryBootTimeout(entry);
489
- }, IFRAME_BOOT_TIMEOUT_MS);
508
+ }, delayMs);
490
509
  }
491
510
 
492
511
  _clearEntryBootTimeout(entry) {
@@ -519,6 +538,7 @@ export class ExcalidrawEmbedController {
519
538
 
520
539
  this._clearEntryBootTimeout(entry);
521
540
  this._resetPlaceholderLayout(entry);
541
+ this._setEntryLoadingState(entry, false);
522
542
 
523
543
  if (this.maximizedEmbed?.wrapper === entry.wrapper) {
524
544
  this._exitMaximizedEmbed();
@@ -527,9 +547,17 @@ export class ExcalidrawEmbedController {
527
547
  entry.wrapper?.remove();
528
548
  entry.wrapper = null;
529
549
  entry.iframe = null;
550
+ entry.header = null;
530
551
  entry.labelElement = null;
552
+ entry.maxButton = null;
553
+ entry.openButton = null;
531
554
  entry.instanceId = null;
532
555
  entry.isReady = false;
556
+ entry.isParked = false;
557
+ entry.bootFilePath = null;
558
+ entry.bootMode = null;
559
+ entry.loadedFilePath = null;
560
+ entry.loadedMode = null;
533
561
 
534
562
  await this._hydrateEntry(entry);
535
563
  }
@@ -545,6 +573,10 @@ export class ExcalidrawEmbedController {
545
573
  }
546
574
  }
547
575
 
576
+ if (this.warmEntry?.iframe?.contentWindow === contentWindow) {
577
+ return this.warmEntry;
578
+ }
579
+
548
580
  return null;
549
581
  }
550
582
 
@@ -559,6 +591,10 @@ export class ExcalidrawEmbedController {
559
591
  }
560
592
  }
561
593
 
594
+ if (this.warmEntry?.filePath === filePath) {
595
+ return this.warmEntry;
596
+ }
597
+
562
598
  return null;
563
599
  }
564
600
 
@@ -572,11 +608,20 @@ export class ExcalidrawEmbedController {
572
608
  if (entry.wrapper) {
573
609
  entry.wrapper.dataset.embedKey = entry.key;
574
610
  entry.wrapper.dataset.file = entry.filePath;
611
+ entry.wrapper.classList.toggle('is-direct-file', this._isFilePreviewEntry(entry));
575
612
  }
576
613
 
577
614
  if (entry.iframe) {
578
615
  entry.iframe.title = `Excalidraw: ${entry.filePath}`;
579
616
  }
617
+
618
+ if (entry.openButton) {
619
+ if (this._isFilePreviewEntry(entry)) {
620
+ entry.openButton.remove();
621
+ } else if (!entry.openButton.isConnected && entry.header) {
622
+ entry.header.insertBefore(entry.openButton, entry.maxButton || null);
623
+ }
624
+ }
580
625
  }
581
626
 
582
627
  _buildIframeUrl(entry) {
@@ -724,13 +769,33 @@ export class ExcalidrawEmbedController {
724
769
  syncMaximizeButtonState();
725
770
 
726
771
  return {
772
+ header,
727
773
  iframe,
728
774
  instanceId: iframe.dataset.instanceId,
729
775
  labelElement: label,
776
+ maxButton: maxBtn,
777
+ openButton: openBtn,
730
778
  wrapper,
731
779
  };
732
780
  }
733
781
 
782
+ _ensureWarmCacheRoot() {
783
+ if (this.warmCacheRoot?.isConnected && this.warmCacheRoot.parentElement === document.body) {
784
+ return this.warmCacheRoot;
785
+ }
786
+
787
+ let warmCacheRoot = document.body.querySelector('[data-excalidraw-warm-cache-root="true"]');
788
+ if (!warmCacheRoot) {
789
+ warmCacheRoot = document.createElement('div');
790
+ warmCacheRoot.dataset.excalidrawWarmCacheRoot = 'true';
791
+ warmCacheRoot.className = 'excalidraw-warm-cache-root';
792
+ document.body.appendChild(warmCacheRoot);
793
+ }
794
+
795
+ this.warmCacheRoot = warmCacheRoot;
796
+ return warmCacheRoot;
797
+ }
798
+
734
799
  _setupResizer(resizer, iframe, onResizeEnd = null) {
735
800
  let startY = 0;
736
801
  let startHeight = 0;
@@ -801,7 +866,164 @@ export class ExcalidrawEmbedController {
801
866
  }
802
867
 
803
868
  _shouldInlineFilePreview(entry) {
804
- return this._isFilePreviewEntry(entry) && !entry?.reusedFromEmbed;
869
+ return this._isFilePreviewEntry(entry);
870
+ }
871
+
872
+ _entryNeedsHardReload(entry) {
873
+ const desiredMode = this._getEntryMode(entry);
874
+ return Boolean(
875
+ entry?.wrapper
876
+ && entry.iframe
877
+ && (
878
+ entry.isReady
879
+ ? (
880
+ entry.loadedFilePath !== entry.filePath
881
+ || entry.loadedMode !== desiredMode
882
+ )
883
+ : (
884
+ entry.bootFilePath !== entry.filePath
885
+ || entry.bootMode !== desiredMode
886
+ )
887
+ )
888
+ );
889
+ }
890
+
891
+ _setEntryLoadingState(entry, isLoading) {
892
+ if (!entry?.wrapper) {
893
+ return;
894
+ }
895
+
896
+ entry.wrapper.classList.toggle('is-loading', Boolean(isLoading));
897
+ entry.wrapper.setAttribute('aria-busy', isLoading ? 'true' : 'false');
898
+ }
899
+
900
+ _forEachManagedEntry(callback) {
901
+ const seen = new Set();
902
+ this.embedEntries.forEach((entry) => {
903
+ if (!entry || seen.has(entry)) {
904
+ return;
905
+ }
906
+
907
+ seen.add(entry);
908
+ callback(entry);
909
+ });
910
+
911
+ if (this.warmEntry && !seen.has(this.warmEntry)) {
912
+ callback(this.warmEntry);
913
+ }
914
+ }
915
+
916
+ _claimEntryFromRemovedEntries(removedEntries, predicate) {
917
+ const removedIndex = removedEntries.findIndex((entry) => (
918
+ (entry.wrapper || entry.iframe) && predicate(entry)
919
+ ));
920
+ if (removedIndex < 0) {
921
+ return null;
922
+ }
923
+
924
+ const [reusedEntry] = removedEntries.splice(removedIndex, 1);
925
+ return reusedEntry;
926
+ }
927
+
928
+ _claimReusableEntry(nextEntry, removedEntries) {
929
+ const desiredMode = this._getEntryMode(nextEntry);
930
+ const exactMatch = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
931
+ entry.filePath === nextEntry.filePath
932
+ && this._getEntryMode(entry) === desiredMode
933
+ ));
934
+ if (exactMatch) {
935
+ return exactMatch;
936
+ }
937
+
938
+ if (this._isFilePreviewEntry(nextEntry)) {
939
+ const promotedEntry = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
940
+ entry.filePath === nextEntry.filePath
941
+ ));
942
+ if (promotedEntry) {
943
+ return promotedEntry;
944
+ }
945
+
946
+ const warmEntry = this._claimWarmEntry();
947
+ if (warmEntry) {
948
+ return warmEntry;
949
+ }
950
+
951
+ return this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
952
+ this._isFilePreviewEntry(entry)
953
+ ));
954
+ }
955
+
956
+ return null;
957
+ }
958
+
959
+ _claimWarmEntry() {
960
+ const warmEntry = this.warmEntry;
961
+ if (!warmEntry) {
962
+ return null;
963
+ }
964
+
965
+ this.warmEntry = null;
966
+ return warmEntry;
967
+ }
968
+
969
+ _captureWarmEntry(removedEntries) {
970
+ const warmCandidate = this._claimEntryFromRemovedEntries(removedEntries, (entry) => (
971
+ this._isFilePreviewEntry(entry)
972
+ ));
973
+ if (!warmCandidate) {
974
+ return;
975
+ }
976
+
977
+ if (this.warmEntry && this.warmEntry !== warmCandidate) {
978
+ this._destroyEntry(this.warmEntry);
979
+ }
980
+
981
+ this.warmEntry = warmCandidate;
982
+ this._parkWarmEntry(warmCandidate);
983
+ }
984
+
985
+ _parkWarmEntry(entry) {
986
+ if (!entry?.wrapper) {
987
+ return;
988
+ }
989
+
990
+ if (this.maximizedEmbed?.wrapper === entry.wrapper) {
991
+ this._exitMaximizedEmbed();
992
+ }
993
+
994
+ const warmCacheRoot = this._ensureWarmCacheRoot();
995
+ this._setEntryLoadingState(entry, false);
996
+ entry.isParked = true;
997
+ entry.isReady = false;
998
+ entry.bootFilePath = null;
999
+ entry.bootMode = null;
1000
+ entry.loadedFilePath = null;
1001
+ entry.loadedMode = null;
1002
+ entry.placeholder = null;
1003
+ if (entry.wrapper.parentElement !== warmCacheRoot) {
1004
+ warmCacheRoot.appendChild(entry.wrapper);
1005
+ }
1006
+ this._postMessageToEntry(entry, {
1007
+ source: 'collabmd-host',
1008
+ type: 'park-room',
1009
+ });
1010
+ }
1011
+
1012
+ _reloadEntry(entry) {
1013
+ if (!entry?.iframe) {
1014
+ return;
1015
+ }
1016
+
1017
+ this._clearEntryBootTimeout(entry);
1018
+ this._setEntryLoadingState(entry, true);
1019
+ entry.isReady = false;
1020
+ entry.bootAttempts = (entry.bootAttempts ?? 0) + 1;
1021
+ entry.bootFilePath = entry.filePath;
1022
+ entry.bootMode = this._getEntryMode(entry);
1023
+ entry.loadedFilePath = null;
1024
+ entry.loadedMode = null;
1025
+ entry.iframe.src = this._buildIframeUrl(entry).toString();
1026
+ this._armEntryBootTimeout(entry);
805
1027
  }
806
1028
 
807
1029
  _resetPlaceholderLayout(entry) {
@@ -950,8 +1172,15 @@ export class ExcalidrawEmbedController {
950
1172
  }
951
1173
 
952
1174
  if (msg.type === 'ready') {
1175
+ if (entry.isParked) {
1176
+ return;
1177
+ }
1178
+
953
1179
  entry.isReady = true;
954
1180
  this._clearEntryBootTimeout(entry);
1181
+ entry.loadedFilePath = entry.bootFilePath || entry.filePath;
1182
+ entry.loadedMode = entry.bootMode || this._getEntryMode(entry);
1183
+ this._setEntryLoadingState(entry, false);
955
1184
  this._syncEntryUser(entry);
956
1185
  this._postMessageToEntry(entry, {
957
1186
  source: 'collabmd-host',
@@ -959,10 +1188,17 @@ export class ExcalidrawEmbedController {
959
1188
  theme: this.getTheme?.() || 'dark',
960
1189
  });
961
1190
  this._syncEntryFollowState(entry);
1191
+ if (this._entryNeedsHardReload(entry)) {
1192
+ void this._hydrateEntry(entry);
1193
+ }
962
1194
  return;
963
1195
  }
964
1196
 
965
1197
  if (msg.type === 'error') {
1198
+ if (entry.isParked) {
1199
+ return;
1200
+ }
1201
+
966
1202
  entry.isReady = false;
967
1203
  void this._handleEntryBootTimeout(entry);
968
1204
  return;
@@ -11,7 +11,6 @@ export function reconcileEmbedEntries(currentEntries, placeholderDescriptors) {
11
11
  label: descriptor.label,
12
12
  placeholder: descriptor.placeholder,
13
13
  queued: false,
14
- reusedFromEmbed: false,
15
14
  wrapper: null,
16
15
  };
17
16