collabmd 0.1.45 → 0.1.46

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 (190) hide show
  1. package/README.md +39 -19
  2. package/bin/collabmd.js +27 -1
  3. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CYM5nxUi.js +1 -0
  4. package/dist/client/assets/browser-utils--H_DkyEF.js +1 -0
  5. package/dist/client/assets/chunk-ERSWUTIN-hHdvnhaY.js +41 -0
  6. package/dist/client/assets/comment-thread-store-CdcwrObc.js +1 -0
  7. package/dist/client/assets/comment-threads-9J6Em-_y.js +4 -0
  8. package/dist/client/assets/comment-ui-shared-BC4pt4w8.js +7 -0
  9. package/dist/client/assets/dist-B5HTH2BO.js +1 -0
  10. package/dist/client/assets/{dist-DhLvC8DS.js → dist-B5Jf1SLZ.js} +2 -2
  11. package/dist/client/assets/{dist-CnL7Y2sM.js → dist-BDt3rz7l.js} +1 -1
  12. package/dist/client/assets/dist-BEwK4csK.js +1 -0
  13. package/dist/client/assets/dist-BKW4PALF.js +1 -0
  14. package/dist/client/assets/dist-BWcHHOx-.js +1 -0
  15. package/dist/client/assets/{dist-CYZmbokb.js → dist-BalScOwZ.js} +9 -9
  16. package/dist/client/assets/dist-Bf8f4HmM.js +6 -0
  17. package/dist/client/assets/dist-BjHJDtyU.js +1 -0
  18. package/dist/client/assets/dist-Bm6pf9Bl.js +1 -0
  19. package/dist/client/assets/dist-BoVBdSjk.js +1 -0
  20. package/dist/client/assets/dist-CC5IrQMG.js +1 -0
  21. package/dist/client/assets/{dist-C11hHqv_.js → dist-CCP_AKPG.js} +12 -12
  22. package/dist/client/assets/dist-CZgOmxK6.js +1 -0
  23. package/dist/client/assets/{dist-D2AQ-76g.js → dist-Cqhv5LAk.js} +1 -1
  24. package/dist/client/assets/{dist-MvcV4190.js → dist-D-Z6FLem.js} +1 -1
  25. package/dist/client/assets/dist-DLMiIsNC.js +11 -0
  26. package/dist/client/assets/{dist-3bgCp7iY.js → dist-DZKu9Cs8.js} +1 -1
  27. package/dist/client/assets/dist-DaYdhcVs.js +1 -0
  28. package/dist/client/assets/dist-FWJsgGYI.js +1 -0
  29. package/dist/client/assets/dist-Kh2COra_.js +1 -0
  30. package/dist/client/assets/{dist-JYEDRrPT.js → dist-PDZ3bSqm.js} +1 -1
  31. package/dist/client/assets/{dist-Drp8uu5J.js → dist-TjMh8n1u.js} +1 -1
  32. package/dist/client/assets/{dist-DGb27Dl6.js → dist-dtAdgL39.js} +1 -1
  33. package/dist/client/assets/dist-jv4WdPnK.js +9 -0
  34. package/dist/client/assets/{dist-DK5Gs2wa.js → dist-rGBmqxC7.js} +1 -1
  35. package/dist/client/assets/{dist-DCzuX1AF.js → dist-shrl6K_0.js} +1 -1
  36. package/dist/client/assets/dist-tOaegDYS2.js +1 -0
  37. package/dist/client/assets/dist-ule2n6ak.js +1 -0
  38. package/dist/client/assets/dist-xElaxbia.js +1 -0
  39. package/dist/client/assets/{drawio-editor-ClE8jpoG.js → drawio-editor-FzY1cVw8.js} +1 -1
  40. package/dist/client/assets/drawioEditor-BjiKJqlc.js +2 -0
  41. package/dist/client/assets/editor-session-Cm9ekeNo.js +54 -0
  42. package/dist/client/assets/embedded-editor-base-CwTkk911.css +1 -0
  43. package/dist/client/assets/{preview-render-profile-BZzGSg6K.js → es-DXGV3RHd.js} +13 -13
  44. package/dist/client/assets/excalidraw-editor-B46zsTFI.js +9 -0
  45. package/dist/client/assets/{excalidraw-editor-CyGwOhLz.css → excalidraw-editor-Bt7gMSoh.css} +1 -1
  46. package/dist/client/assets/excalidraw-scene-BOben2gr.js +1 -0
  47. package/dist/client/assets/excalidrawEditor-CT2pHq7e.js +2 -0
  48. package/dist/client/assets/{export-page-CUfws2T-.js → export-page-A7NHawJY.js} +3 -3
  49. package/dist/client/assets/exportDocument-B2L8fe34.css +1 -0
  50. package/dist/client/assets/exportDocument-BaqcOAAW.js +2 -0
  51. package/dist/client/assets/file-icon-svg-CQAjlWjK.js +1 -0
  52. package/dist/client/assets/file-kind-BbqgGzpc.js +1 -0
  53. package/dist/client/assets/image-QXRYHUNP-SWEV8H0J.js +1 -0
  54. package/dist/client/assets/index-ClbjMBVt.js +2 -0
  55. package/dist/client/assets/index-Pum8ET-5.css +1 -0
  56. package/dist/client/assets/main-B3uPyqic.js +893 -0
  57. package/dist/client/assets/pdf-BL9Pr2D5.js +54 -0
  58. package/dist/client/assets/pdf.worker-CLesOks4.mjs +64857 -0
  59. package/dist/client/assets/pdf_viewer-FVkkThnz.js +551 -0
  60. package/dist/client/assets/{preview-render-compiler-BSaQO_p3.js → preview-render-compiler-BD-XhkS2.js} +2 -2
  61. package/dist/client/assets/preview-render-compiler-BKedaMxx.js +1 -0
  62. package/dist/client/assets/preview-render-profile-CE-5rVTC.js +1 -0
  63. package/dist/client/assets/{preview-render-worker-B42LWvrD.js → preview-render-worker-CiPqc9n6.js} +20 -20
  64. package/dist/client/assets/prod-Dy20X7eT.js +1 -0
  65. package/dist/client/assets/prod-hnwh7wRM.js +177 -0
  66. package/dist/client/assets/quick-switcher-controller-fqOYR7qR.js +12 -0
  67. package/dist/client/assets/runtime-config-BLrEKVO1.js +1 -0
  68. package/dist/client/assets/vault-api-client-_LpV1aIn.js +6 -0
  69. package/dist/client/drawio-editor.html +1 -1
  70. package/dist/client/excalidraw-editor.html +1 -1
  71. package/dist/client/export-document.html +2 -2
  72. package/dist/client/index.html +15 -3
  73. package/docker-compose.yml +42 -0
  74. package/package.json +17 -4
  75. package/scripts/local-structurizr-compose.mjs +101 -0
  76. package/src/client/app/index.html +13 -1
  77. package/src/client/application/app-shell/comments-feature.js +19 -10
  78. package/src/client/application/app-shell/git-feature.js +12 -0
  79. package/src/client/application/app-shell/presence-feature.js +2 -1
  80. package/src/client/application/app-shell/ui-feature-shell.js +45 -2
  81. package/src/client/application/app-shell/ui-feature-toolbar.js +2 -52
  82. package/src/client/application/app-shell/workspace-feature.js +5 -1
  83. package/src/client/application/app-shell-elements.js +2 -0
  84. package/src/client/application/pdf-preview-controller.js +992 -0
  85. package/src/client/application/structurizr-preview-controller.js +395 -0
  86. package/src/client/application/workspace-coordinator.js +25 -3
  87. package/src/client/application/workspace-preview-controller.js +91 -2
  88. package/src/client/bootstrap/collabmd-app-shell.js +31 -5
  89. package/src/client/browser-utils.js +44 -0
  90. package/src/client/domain/runtime-paths.js +26 -5
  91. package/src/client/domain/structurizr-language.js +99 -0
  92. package/src/client/domain/vault-paths.js +48 -0
  93. package/src/client/excalidraw-editor.js +833 -51
  94. package/src/client/infrastructure/browser-preferences-port.js +10 -0
  95. package/src/client/infrastructure/comment-thread-store.js +34 -13
  96. package/src/client/infrastructure/editor-session.js +10 -0
  97. package/src/client/infrastructure/editor-view-adapter.js +46 -2
  98. package/src/client/infrastructure/excalidraw-room-client.js +103 -14
  99. package/src/client/infrastructure/git-api-client.js +5 -0
  100. package/src/client/infrastructure/vault-api-client.js +26 -0
  101. package/src/client/presentation/comment-ui/comment-ui-shared.js +4 -0
  102. package/src/client/presentation/drawio-embed-controller.js +9 -102
  103. package/src/client/presentation/drawio-viewer.js +118 -0
  104. package/src/client/presentation/excalidraw-embed-controller.js +74 -8
  105. package/src/client/presentation/file-action-controller.js +102 -0
  106. package/src/client/presentation/file-explorer-view.js +10 -29
  107. package/src/client/presentation/file-icon-svg.js +40 -0
  108. package/src/client/presentation/file-tree-state.js +2 -0
  109. package/src/client/presentation/git-diff-view-controller.js +731 -3
  110. package/src/client/presentation/outline-controller.js +86 -0
  111. package/src/client/presentation/quick-switcher-controller.js +25 -13
  112. package/src/client/presentation/quick-switcher-text-search.js +1 -1
  113. package/src/client/styles/components/navigation.css +11 -1
  114. package/src/client/styles/features/diagram-preview.css +76 -0
  115. package/src/client/styles/features/diff-special.css +309 -0
  116. package/src/client/styles/foundation/themes.css +6 -0
  117. package/src/client/styles/layout/shell.css +358 -1
  118. package/src/client/styles/layout/view-modes.css +1 -0
  119. package/src/client/styles/style.css +1 -0
  120. package/src/client/styles/surfaces/excalidraw-editor.css +294 -0
  121. package/src/domain/comment-threads.js +63 -5
  122. package/src/domain/excalidraw-room-codec.js +6 -4
  123. package/src/domain/file-kind.js +35 -3
  124. package/src/server/config/env.js +79 -0
  125. package/src/server/create-app-server.js +12 -0
  126. package/src/server/domain/bases/base-definition.js +1 -1
  127. package/src/server/domain/bases/base-expression-runtime.js +1 -4
  128. package/src/server/domain/bases/base-index-snapshot-store.js +1 -4
  129. package/src/server/domain/bases/base-transform.js +1 -4
  130. package/src/server/domain/collaboration/collaboration-room.js +55 -21
  131. package/src/server/domain/comment-overview.js +15 -5
  132. package/src/server/domain/ripgrep-search-service.js +4 -0
  133. package/src/server/infrastructure/git/diff-service.js +5 -0
  134. package/src/server/infrastructure/git/git-service.js +21 -0
  135. package/src/server/infrastructure/git/history-service.js +54 -0
  136. package/src/server/infrastructure/git/image-mime.js +14 -0
  137. package/src/server/infrastructure/git/untracked-files.js +28 -1
  138. package/src/server/infrastructure/http/create-git-api-query-handler.js +31 -1
  139. package/src/server/infrastructure/http/create-request-handler.js +16 -23
  140. package/src/server/infrastructure/http/create-static-handler.js +5 -2
  141. package/src/server/infrastructure/http/create-structurizr-api-handler.js +145 -0
  142. package/src/server/infrastructure/http/create-vault-api-command-handler.js +66 -4
  143. package/src/server/infrastructure/http/create-vault-api-query-handler.js +51 -3
  144. package/src/server/infrastructure/http/http-request-helpers.js +22 -0
  145. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  146. package/src/server/infrastructure/persistence/vault-file-store.js +3 -0
  147. package/src/server/infrastructure/structurizr/structurizr-embed.css +43 -0
  148. package/src/server/infrastructure/structurizr/structurizr-workspace-service.js +374 -0
  149. package/src/server/infrastructure/websocket/attach-collaboration-gateway.js +4 -22
  150. package/dist/client/assets/CodeMirrorEditor-UCMRAMCV-CbSISC1w.js +0 -1
  151. package/dist/client/assets/browser-utils-CKtI_rZl.js +0 -1
  152. package/dist/client/assets/chunk-ERSWUTIN-ME9RVNel.js +0 -41
  153. package/dist/client/assets/comment-threads-DDegmaFO.js +0 -4
  154. package/dist/client/assets/dist-BH87JoHx.js +0 -11
  155. package/dist/client/assets/dist-BIkPRZO7.js +0 -6
  156. package/dist/client/assets/dist-BqCsFpx4.js +0 -1
  157. package/dist/client/assets/dist-C2JHCt8U.js +0 -1
  158. package/dist/client/assets/dist-CZjTtmTu.js +0 -1
  159. package/dist/client/assets/dist-Cig4Ov_X.js +0 -1
  160. package/dist/client/assets/dist-CqQEC8gR.js +0 -1
  161. package/dist/client/assets/dist-D4Rzqod1.js +0 -1
  162. package/dist/client/assets/dist-D5sn-mwk.js +0 -9
  163. package/dist/client/assets/dist-D7G-n87v.js +0 -1
  164. package/dist/client/assets/dist-DRqOrNgN.js +0 -1
  165. package/dist/client/assets/dist-DetkMS_4.js +0 -1
  166. package/dist/client/assets/dist-Dp6FVkQ1.js +0 -1
  167. package/dist/client/assets/dist-DtbjNB28.js +0 -1
  168. package/dist/client/assets/dist-DyrYcvpo2.js +0 -1
  169. package/dist/client/assets/dist-GrygD2zE.js +0 -1
  170. package/dist/client/assets/dist-HmEpWUow.js +0 -1
  171. package/dist/client/assets/dist-RTa81Km7.js +0 -1
  172. package/dist/client/assets/drawioEditor-CXaTM9Ds.js +0 -2
  173. package/dist/client/assets/editor-session-GTwZS6Df.js +0 -16
  174. package/dist/client/assets/embedded-editor-base-CiNEc8vy.css +0 -1
  175. package/dist/client/assets/excalidraw-editor-BZVM81BO.js +0 -9
  176. package/dist/client/assets/excalidraw-scene-C8n9CHsK.js +0 -176
  177. package/dist/client/assets/excalidrawEditor-DxJanCze.js +0 -2
  178. package/dist/client/assets/exportDocument-BCuxqXDY.js +0 -2
  179. package/dist/client/assets/exportDocument-DV-OX74E.css +0 -1
  180. package/dist/client/assets/file-kind-BKPOIRPE.js +0 -1
  181. package/dist/client/assets/image-QXRYHUNP-lfTHE3bb.js +0 -1
  182. package/dist/client/assets/index-C3YnUgMD.css +0 -1
  183. package/dist/client/assets/index-D2wXraJ1.js +0 -2
  184. package/dist/client/assets/main-DtiGz7uI.js +0 -795
  185. package/dist/client/assets/preview-render-compiler-csGM5ZFz.js +0 -1
  186. package/dist/client/assets/quick-switcher-controller-shW_6vPY.js +0 -12
  187. package/dist/client/assets/room-CQLoqtMo.js +0 -1
  188. package/dist/client/assets/runtime-config-DSGmGE9V.js +0 -1
  189. package/dist/client/assets/vault-api-client-C6bcGwy-.js +0 -5
  190. package/src/server/domain/collaboration/room-persistence-controller.js +0 -80
@@ -6,7 +6,9 @@ import {
6
6
  reconcileElements,
7
7
  restoreAppState,
8
8
  restoreElements,
9
+ sceneCoordsToViewportCoords,
9
10
  } from '@excalidraw/excalidraw';
11
+ import { getElementBounds } from '@excalidraw/element';
10
12
  import '@excalidraw/excalidraw/index.css';
11
13
 
12
14
  import {
@@ -17,6 +19,9 @@ import {
17
19
  resolveLocalAwarenessUser,
18
20
  } from './domain/excalidraw-collaboration.js';
19
21
  import './styles/surfaces/embedded-editor-base.css';
22
+ import './styles/features/comment-markdown.css';
23
+ import './styles/features/comment-overview.css';
24
+ import './styles/features/comments-drawer.css';
20
25
  import './styles/surfaces/excalidraw-editor.css';
21
26
  import {
22
27
  applySceneUpdateWithFiles,
@@ -27,6 +32,11 @@ import {
27
32
  parseSceneJson,
28
33
  sceneToInitialData,
29
34
  } from './domain/excalidraw-scene.js';
35
+ import {
36
+ createCommentOverviewThread,
37
+ formatAnchorLabel,
38
+ getLatestMessage,
39
+ } from './presentation/comment-ui/comment-ui-shared.js';
30
40
  import {
31
41
  buildReconciledExcalidrawSceneUpdate,
32
42
  } from './domain/excalidraw-scene-reconcile.js';
@@ -76,17 +86,39 @@ let apiStateCleanupCallbacks = [];
76
86
  let collaboratorRenderFrame = 0;
77
87
  let queuedCollaborators = null;
78
88
  let initialViewportFitPending = true;
89
+ let viewportFitGeneration = 0;
79
90
  let previewViewportFitTimerId = 0;
91
+ let previewViewportFitRetryTimerId = 0;
80
92
  let roomClient = null;
81
93
  let roomClientGeneration = 0;
82
94
  let reactRoot = null;
83
95
  let editorRenderKey = 0;
84
96
  let skipRoomDisconnectOnUnmount = false;
85
97
  let roomConnectionState = EXCALIDRAW_ROOM_CONNECTION_STATE.CONNECTING;
98
+ let diagramCommentThreads = [];
99
+ let pendingDiagramCommentFocus = null;
100
+ let diagramCommentFocusRequestId = 0;
86
101
  let parkRequestedWhileBlocked = false;
87
102
  const pendingDisconnectRequestIds = new Set();
88
103
  const reportedFileConflictSignatures = new Set();
89
104
 
105
+ function getMountedExcalidrawAPI() {
106
+ return excalidrawAPI && !excalidrawAPI.isDestroyed ? excalidrawAPI : null;
107
+ }
108
+
109
+ function requestDiagramCommentFocus(threadId) {
110
+ const normalizedThreadId = String(threadId ?? '').trim();
111
+ if (!normalizedThreadId) {
112
+ return;
113
+ }
114
+
115
+ pendingDiagramCommentFocus = {
116
+ requestId: ++diagramCommentFocusRequestId,
117
+ threadId: normalizedThreadId,
118
+ };
119
+ renderExcalidrawApp();
120
+ }
121
+
90
122
  if (diagnostics.enabled) {
91
123
  window.__COLLABMD_EXCALIDRAW_DEBUG__ = diagnostics;
92
124
  }
@@ -120,6 +152,213 @@ function getAuthorityBannerText() {
120
152
  return '';
121
153
  }
122
154
 
155
+ function getSelectedDiagramElement() {
156
+ const api = getMountedExcalidrawAPI();
157
+ const selectedElementIds = api?.getAppState?.()?.selectedElementIds || {};
158
+ const selectedElements = api?.getSceneElementsIncludingDeleted?.()
159
+ ?.filter((element) => selectedElementIds[element.id] && !element.isDeleted) ?? [];
160
+ return selectedElements.length === 1 ? selectedElements[0] : null;
161
+ }
162
+
163
+ function normalizeDiagramLabelText(value, maxLength = 72) {
164
+ const text = typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
165
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
166
+ }
167
+
168
+ function getDiagramElementContent(element, api = getMountedExcalidrawAPI()) {
169
+ const directText = normalizeDiagramLabelText(element?.text);
170
+ if (directText) {
171
+ return directText;
172
+ }
173
+
174
+ const sceneElements = api?.getSceneElementsIncludingDeleted?.() || [];
175
+ const boundElementIds = new Set(
176
+ Array.isArray(element?.boundElements)
177
+ ? element.boundElements.map((boundElement) => boundElement?.id).filter(Boolean)
178
+ : [],
179
+ );
180
+ if (boundElementIds.size === 0) {
181
+ return '';
182
+ }
183
+
184
+ return normalizeDiagramLabelText(
185
+ sceneElements
186
+ .filter((sceneElement) => boundElementIds.has(sceneElement.id) && !sceneElement.isDeleted)
187
+ .map((sceneElement) => sceneElement.text)
188
+ .filter((text) => typeof text === 'string' && text.trim())
189
+ .join(' '),
190
+ );
191
+ }
192
+
193
+ function formatDiagramAnchorLabel(type, content = '') {
194
+ const normalizedType = typeof type === 'string' && type.trim() ? type.trim() : 'element';
195
+ const typeLabel = normalizedType === 'element'
196
+ ? 'Diagram element'
197
+ : `${normalizedType.charAt(0).toUpperCase()}${normalizedType.slice(1)} element`;
198
+ const normalizedContent = normalizeDiagramLabelText(content);
199
+ return normalizedContent ? `${typeLabel} · ${normalizedContent}` : typeLabel;
200
+ }
201
+
202
+ function getDiagramAnchorLabel(thread) {
203
+ const snapshot = thread?.anchorSnapshot;
204
+ const element = getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.()
205
+ ?.find((candidate) => candidate.id === thread?.elementId && !candidate.isDeleted);
206
+ const elementContent = normalizeDiagramLabelText(snapshot?.text) || getDiagramElementContent(element);
207
+ const firstMessageContent = thread?.messages?.[0]?.body;
208
+ return formatDiagramAnchorLabel(snapshot?.type || element?.type, elementContent || firstMessageContent);
209
+ }
210
+
211
+ const diagramCommentTimeFormatter = new Intl.DateTimeFormat(undefined, {
212
+ day: 'numeric',
213
+ hour: 'numeric',
214
+ minute: '2-digit',
215
+ month: 'short',
216
+ });
217
+
218
+ function formatDiagramCommentTimestamp(value) {
219
+ if (!Number.isFinite(value)) {
220
+ return '';
221
+ }
222
+
223
+ try {
224
+ return diagramCommentTimeFormatter.format(new Date(value));
225
+ } catch {
226
+ return '';
227
+ }
228
+ }
229
+
230
+ function renderDiagramCommentOverviewThread(thread, activeThreadId, onOpen) {
231
+ const latestMessage = getLatestMessage(thread?.messages ?? []);
232
+ const messageCount = Array.isArray(thread?.messages) ? thread.messages.length : 0;
233
+ const button = createCommentOverviewThread({
234
+ authorName: latestMessage?.userName || thread?.createdByName || 'Anonymous',
235
+ buttonClassName: 'comment-overview-thread comments-drawer-item',
236
+ footerClassName: 'comment-overview-thread-footer comments-drawer-item-footer',
237
+ headerClassName: 'comment-overview-thread-header comments-drawer-item-header',
238
+ lineClassName: 'comment-overview-thread-line comments-drawer-item-title',
239
+ lineLabel: formatAnchorLabel(thread),
240
+ messageCount,
241
+ previewBody: latestMessage?.body || '',
242
+ previewClassName: 'comment-markdown comment-overview-thread-preview comments-drawer-item-preview',
243
+ quote: thread?.anchorQuote || thread?.anchorSnapshot?.text || 'Diagram element',
244
+ quoteClassName: 'comment-overview-thread-quote comments-drawer-item-quote',
245
+ timestamp: formatDiagramCommentTimestamp(latestMessage?.createdAt),
246
+ });
247
+ button.classList.toggle('is-active', thread.id === activeThreadId);
248
+
249
+ return React.createElement('button', {
250
+ className: button.className,
251
+ dangerouslySetInnerHTML: { __html: button.innerHTML },
252
+ key: thread.id,
253
+ onClick: () => onOpen(thread.id),
254
+ type: 'button',
255
+ });
256
+ }
257
+
258
+ function DiagramCommentIcon({ add = false }) {
259
+ const paths = [React.createElement('path', {
260
+ d: 'M3 4.75A1.75 1.75 0 0 1 4.75 3h6.5A1.75 1.75 0 0 1 13 4.75v4.5A1.75 1.75 0 0 1 11.25 11H8.9L6.5 13v-2H4.75A1.75 1.75 0 0 1 3 9.25v-4.5Z',
261
+ fill: 'none',
262
+ key: 'bubble',
263
+ stroke: 'currentColor',
264
+ strokeLinejoin: 'round',
265
+ strokeWidth: '1.35',
266
+ })];
267
+ if (add) {
268
+ paths.push(React.createElement('path', {
269
+ d: 'M8 5.5v3.5M6.25 7.25h3.5',
270
+ fill: 'none',
271
+ key: 'plus',
272
+ stroke: 'currentColor',
273
+ strokeLinecap: 'round',
274
+ strokeWidth: '1.35',
275
+ }));
276
+ }
277
+
278
+ return React.createElement('svg', {
279
+ 'aria-hidden': 'true',
280
+ className: 'diagram-comment-icon',
281
+ fill: 'none',
282
+ focusable: 'false',
283
+ viewBox: '0 0 16 16',
284
+ }, paths);
285
+ }
286
+
287
+ function getDiagramCommentMarkerPosition(thread, api = getMountedExcalidrawAPI()) {
288
+ if (!api || thread?.anchorKind !== 'diagram-element') {
289
+ return null;
290
+ }
291
+
292
+ let scenePoint = thread.anchorPoint;
293
+ const element = api.getSceneElementsIncludingDeleted?.()
294
+ ?.find((candidate) => candidate.id === thread.elementId && !candidate.isDeleted);
295
+ if (element) {
296
+ try {
297
+ const bounds = getElementBounds(element, api.getSceneElementsMapIncludingDeleted?.());
298
+ scenePoint = {
299
+ x: bounds[2],
300
+ y: bounds[1],
301
+ };
302
+ } catch {
303
+ scenePoint = thread.anchorPoint;
304
+ }
305
+ }
306
+
307
+ const x = Number(scenePoint?.x);
308
+ const y = Number(scenePoint?.y);
309
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
310
+ return null;
311
+ }
312
+
313
+ const appState = api.getAppState?.();
314
+ if (!appState?.zoom || !Number.isFinite(appState.scrollX) || !Number.isFinite(appState.scrollY)) {
315
+ return null;
316
+ }
317
+
318
+ return sceneCoordsToViewportCoords(
319
+ { sceneX: x, sceneY: y },
320
+ {
321
+ offsetLeft: Number.isFinite(appState.offsetLeft) ? appState.offsetLeft : 0,
322
+ offsetTop: Number.isFinite(appState.offsetTop) ? appState.offsetTop : 0,
323
+ scrollX: appState.scrollX,
324
+ scrollY: appState.scrollY,
325
+ zoom: appState.zoom,
326
+ },
327
+ );
328
+ }
329
+
330
+ function focusDiagramCommentThread(thread) {
331
+ const api = getMountedExcalidrawAPI();
332
+ const element = api?.getSceneElementsIncludingDeleted?.()
333
+ ?.find((candidate) => candidate.id === thread?.elementId && !candidate.isDeleted);
334
+ if (!api || !element) {
335
+ return;
336
+ }
337
+
338
+ api.updateScene({
339
+ appState: {
340
+ selectedElementIds: { [element.id]: true },
341
+ },
342
+ captureUpdate: CaptureUpdateAction.NEVER,
343
+ });
344
+
345
+ if (typeof api.setViewport !== 'function') {
346
+ return;
347
+ }
348
+
349
+ suppressViewportBroadcast = true;
350
+ try {
351
+ api.setViewport({
352
+ animation: false,
353
+ fit: 'contain',
354
+ offsets: { ui: true },
355
+ target: element,
356
+ });
357
+ } finally {
358
+ releaseViewportBroadcastSuppressionAfterPaint();
359
+ }
360
+ }
361
+
123
362
  function applyDocumentMode(mode = currentDocument.mode) {
124
363
  document.body.dataset.documentMode = normalizeDocumentMode(mode);
125
364
  }
@@ -140,6 +379,16 @@ function createRoomClient(filePath) {
140
379
 
141
380
  queueCollaboratorsRender(collaborators);
142
381
  },
382
+ onCommentThreadsChange: (threads) => {
383
+ if (generation !== roomClientGeneration) {
384
+ return;
385
+ }
386
+
387
+ diagramCommentThreads = Array.isArray(threads) ? threads : [];
388
+ if (reactRoot) {
389
+ renderExcalidrawApp();
390
+ }
391
+ },
143
392
  onConnectionStateChange: (event) => {
144
393
  if (generation !== roomClientGeneration) {
145
394
  return;
@@ -164,7 +413,7 @@ function createRoomClient(filePath) {
164
413
  };
165
414
  }
166
415
 
167
- function buildExcalidrawProps({ initialData } = {}) {
416
+ function buildExcalidrawProps({ initialData, renderTopRightUI, viewModeEnabled } = {}) {
168
417
  const props = {
169
418
  onMount: handleEditorMount,
170
419
  onInitialize: (api) => {
@@ -172,6 +421,7 @@ function buildExcalidrawProps({ initialData } = {}) {
172
421
  },
173
422
  onUnmount: () => {
174
423
  clearEditorApiStateBindings();
424
+ clearPreviewViewportFitTimers();
175
425
  if (skipRoomDisconnectOnUnmount) {
176
426
  skipRoomDisconnectOnUnmount = false;
177
427
  } else {
@@ -189,12 +439,17 @@ function buildExcalidrawProps({ initialData } = {}) {
189
439
  onPointerUpdate: (payload) => {
190
440
  roomClient?.scheduleLocalPointerAwareness(payload);
191
441
  },
442
+ onPointerDown: () => {
443
+ initialViewportFitPending = false;
444
+ },
445
+ renderTopRightUI,
192
446
  historyOptions: {
193
447
  traversal: 'single-entry',
194
448
  },
195
449
  onHistoryAction: handleHistoryAction,
196
450
  theme: currentTheme,
197
451
  ...getDocumentViewState(),
452
+ ...(typeof viewModeEnabled === 'boolean' ? { viewModeEnabled } : {}),
198
453
  UIOptions: {
199
454
  canvasActions: {
200
455
  export: false,
@@ -212,6 +467,425 @@ function buildExcalidrawProps({ initialData } = {}) {
212
467
  return props;
213
468
  }
214
469
 
470
+ const diagramCommentsContext = React.createContext(null);
471
+
472
+ function DiagramCommentsToolbar() {
473
+ const context = React.useContext(diagramCommentsContext);
474
+ if (!context?.visible || !context.room) {
475
+ return null;
476
+ }
477
+
478
+ const addCommentButton = context.selectedElement
479
+ ? React.createElement('button', {
480
+ 'aria-label': 'Add comment',
481
+ className: 'diagram-comment-add sidebar-trigger',
482
+ 'data-testid': 'diagram-add-comment',
483
+ disabled: !context.canWrite,
484
+ key: 'add',
485
+ onClick: context.openComposer,
486
+ title: context.canWrite ? 'Add comment to selected element' : 'Reconnect to add a comment',
487
+ type: 'button',
488
+ }, React.createElement(DiagramCommentIcon, { add: true }))
489
+ : null;
490
+
491
+ return React.createElement('div', {
492
+ 'aria-label': 'Diagram comments',
493
+ className: 'diagram-comments-toolbar',
494
+ role: 'toolbar',
495
+ }, [
496
+ addCommentButton,
497
+ React.createElement('button', {
498
+ 'aria-expanded': context.drawerOpen,
499
+ 'aria-label': 'Comments',
500
+ className: `diagram-comment-button sidebar-trigger${context.drawerOpen ? ' active' : ''}`,
501
+ 'data-testid': 'diagram-comments-toggle',
502
+ key: 'toggle',
503
+ onClick: context.toggleDrawer,
504
+ title: context.drawerOpen ? 'Close comments' : 'Open comments',
505
+ type: 'button',
506
+ }, [
507
+ React.createElement(DiagramCommentIcon, { key: 'icon' }),
508
+ context.threads.length > 0
509
+ ? React.createElement('span', {
510
+ className: 'diagram-comment-count',
511
+ key: 'count',
512
+ }, String(context.threads.length))
513
+ : null,
514
+ ]),
515
+ ]);
516
+ }
517
+
518
+ const DiagramCommentsExcalidraw = React.memo(function DiagramCommentsExcalidraw({ initialData = null, renderKey = 0, viewModeEnabled }) {
519
+ const renderTopRightUI = React.useCallback(
520
+ () => React.createElement(DiagramCommentsToolbar),
521
+ [],
522
+ );
523
+
524
+ return React.createElement(Excalidraw, {
525
+ key: `editor-${renderKey}`,
526
+ ...buildExcalidrawProps({ initialData, renderTopRightUI, viewModeEnabled }),
527
+ });
528
+ });
529
+
530
+ function useCloseDiagramCommentsOnCanvas(drawerOpen, setDrawerOpen) {
531
+ React.useEffect(() => {
532
+ if (!drawerOpen) {
533
+ return undefined;
534
+ }
535
+
536
+ const closeOnCanvasPointerDown = (event) => {
537
+ if (!(event.target instanceof Element) || !event.target.closest('canvas.excalidraw__canvas')) {
538
+ return;
539
+ }
540
+
541
+ setDrawerOpen(false);
542
+ };
543
+ const closeOnEscape = (event) => {
544
+ if (event.key !== 'Escape') {
545
+ return;
546
+ }
547
+
548
+ event.preventDefault();
549
+ event.stopPropagation();
550
+ setDrawerOpen(false);
551
+ };
552
+
553
+ document.addEventListener('pointerdown', closeOnCanvasPointerDown, true);
554
+ document.addEventListener('keydown', closeOnEscape, true);
555
+ return () => {
556
+ document.removeEventListener('pointerdown', closeOnCanvasPointerDown, true);
557
+ document.removeEventListener('keydown', closeOnEscape, true);
558
+ };
559
+ }, [drawerOpen, setDrawerOpen]);
560
+ }
561
+
562
+ function DiagramCommentsEditor({ apiId = '', canWrite = false, focusRequest = null, initialData = null, room = null, threads = [], visible = false }) {
563
+ const [activeThreadId, setActiveThreadId] = React.useState(null);
564
+ const [composerOpen, setComposerOpen] = React.useState(false);
565
+ const [drawerOpen, setDrawerOpen] = React.useState(false);
566
+ const [draft, setDraft] = React.useState('');
567
+ const [replyDraft, setReplyDraft] = React.useState('');
568
+ const [, setViewportRevision] = React.useState(0);
569
+
570
+ React.useEffect(() => {
571
+ const api = getMountedExcalidrawAPI();
572
+ if (!api) {
573
+ return undefined;
574
+ }
575
+
576
+ const refresh = () => setViewportRevision((revision) => revision + 1);
577
+ const cleanups = [
578
+ api.onChange?.(refresh),
579
+ api.onScrollChange?.(refresh),
580
+ ].filter((cleanup) => typeof cleanup === 'function');
581
+ window.addEventListener('resize', refresh);
582
+ return () => {
583
+ cleanups.forEach((cleanup) => cleanup());
584
+ window.removeEventListener('resize', refresh);
585
+ };
586
+ }, [apiId]);
587
+
588
+ useCloseDiagramCommentsOnCanvas(drawerOpen, setDrawerOpen);
589
+
590
+ React.useEffect(() => {
591
+ if (activeThreadId && !threads.some((thread) => thread.id === activeThreadId)) {
592
+ setActiveThreadId(null);
593
+ }
594
+ }, [activeThreadId, threads]);
595
+
596
+ React.useEffect(() => {
597
+ const threadId = focusRequest?.threadId;
598
+ const thread = threads.find((candidate) => candidate.id === threadId);
599
+ if (!thread) {
600
+ return;
601
+ }
602
+
603
+ setDrawerOpen(true);
604
+ setComposerOpen(false);
605
+ setActiveThreadId(threadId);
606
+ setReplyDraft('');
607
+ focusDiagramCommentThread(thread);
608
+ }, [focusRequest?.requestId, threads]);
609
+
610
+ const selectedElement = getSelectedDiagramElement();
611
+ const activeThread = threads.find((thread) => thread.id === activeThreadId) ?? null;
612
+ const openComposer = () => {
613
+ if (!canWrite || !selectedElement) {
614
+ return;
615
+ }
616
+
617
+ setDrawerOpen(true);
618
+ setComposerOpen(true);
619
+ setActiveThreadId(null);
620
+ };
621
+ const openThread = (threadId) => {
622
+ setDrawerOpen(true);
623
+ setComposerOpen(false);
624
+ setActiveThreadId(threadId);
625
+ setReplyDraft('');
626
+ };
627
+ const submitComposer = (event) => {
628
+ event.preventDefault();
629
+ const selectedElementForAnchor = getSelectedDiagramElement();
630
+ const elementContent = getDiagramElementContent(selectedElementForAnchor);
631
+ const threadId = room.createCommentThread({
632
+ body: draft,
633
+ element: elementContent
634
+ ? { ...selectedElementForAnchor, text: elementContent }
635
+ : selectedElementForAnchor,
636
+ });
637
+ if (!threadId) {
638
+ return;
639
+ }
640
+
641
+ setDraft('');
642
+ setComposerOpen(false);
643
+ setActiveThreadId(threadId);
644
+ };
645
+ const submitReply = (event) => {
646
+ event.preventDefault();
647
+ if (!activeThread) {
648
+ return;
649
+ }
650
+
651
+ const messageId = room.replyToCommentThread(activeThread.id, replyDraft);
652
+ if (!messageId) {
653
+ return;
654
+ }
655
+
656
+ setReplyDraft('');
657
+ };
658
+ const resolveThread = () => {
659
+ if (!activeThread || !room.deleteCommentThread(activeThread.id)) {
660
+ return;
661
+ }
662
+
663
+ setActiveThreadId(null);
664
+ };
665
+ const renderComposer = () => React.createElement('form', {
666
+ className: 'diagram-comment-form',
667
+ onSubmit: submitComposer,
668
+ }, [
669
+ React.createElement('p', {
670
+ className: 'diagram-comment-anchor-label',
671
+ key: 'anchor',
672
+ }, selectedElement
673
+ ? `On ${formatDiagramAnchorLabel(selectedElement.type, getDiagramElementContent(selectedElement))}`
674
+ : 'On selected element'),
675
+ React.createElement('textarea', {
676
+ 'aria-label': 'Comment',
677
+ autoFocus: true,
678
+ className: 'diagram-comment-input',
679
+ key: 'input',
680
+ maxLength: 2000,
681
+ onChange: (event) => setDraft(event.target.value),
682
+ placeholder: 'Add context, feedback, or a question…',
683
+ rows: 4,
684
+ value: draft,
685
+ }),
686
+ React.createElement('div', {
687
+ className: 'diagram-comment-form-actions',
688
+ key: 'actions',
689
+ }, [
690
+ React.createElement('button', {
691
+ className: 'diagram-comment-button is-secondary',
692
+ key: 'cancel',
693
+ onClick: () => setComposerOpen(false),
694
+ type: 'button',
695
+ }, 'Cancel'),
696
+ React.createElement('button', {
697
+ className: 'diagram-comment-button is-primary',
698
+ disabled: !draft.trim() || !canWrite,
699
+ key: 'submit',
700
+ type: 'submit',
701
+ }, 'Post comment'),
702
+ ]),
703
+ ]);
704
+ const renderThread = () => React.createElement(React.Fragment, null, [
705
+ React.createElement('button', {
706
+ className: 'diagram-comment-back',
707
+ key: 'back',
708
+ onClick: () => setActiveThreadId(null),
709
+ type: 'button',
710
+ }, '← All comments'),
711
+ React.createElement('p', {
712
+ className: 'diagram-comment-anchor-label',
713
+ key: 'label',
714
+ }, getDiagramAnchorLabel(activeThread)),
715
+ React.createElement('div', {
716
+ className: 'diagram-comment-messages',
717
+ key: 'messages',
718
+ }, activeThread.messages?.map((message) => React.createElement('article', {
719
+ className: 'diagram-comment-message',
720
+ key: message.id,
721
+ }, [
722
+ React.createElement('div', {
723
+ className: 'diagram-comment-message-meta',
724
+ key: 'meta',
725
+ }, message.userName || 'Anonymous'),
726
+ React.createElement('p', {
727
+ className: 'diagram-comment-message-body',
728
+ key: 'body',
729
+ }, message.body),
730
+ ]))),
731
+ React.createElement('form', {
732
+ className: 'diagram-comment-form',
733
+ key: 'reply-form',
734
+ onSubmit: submitReply,
735
+ }, [
736
+ React.createElement('textarea', {
737
+ 'aria-label': 'Reply',
738
+ className: 'diagram-comment-input',
739
+ disabled: !canWrite,
740
+ key: 'reply-input',
741
+ maxLength: 2000,
742
+ onChange: (event) => setReplyDraft(event.target.value),
743
+ placeholder: canWrite ? 'Reply…' : 'Reconnect to reply',
744
+ rows: 3,
745
+ value: replyDraft,
746
+ }),
747
+ React.createElement('div', {
748
+ className: 'diagram-comment-form-actions',
749
+ key: 'reply-actions',
750
+ }, [
751
+ React.createElement('button', {
752
+ className: 'diagram-comment-button is-primary',
753
+ disabled: !replyDraft.trim() || !canWrite,
754
+ type: 'submit',
755
+ }, 'Reply'),
756
+ React.createElement('button', {
757
+ className: 'diagram-comment-button is-danger',
758
+ disabled: !canWrite,
759
+ onClick: resolveThread,
760
+ type: 'button',
761
+ }, 'Resolve'),
762
+ ]),
763
+ ]),
764
+ ]);
765
+ const renderDrawer = () => {
766
+ if (!drawerOpen) {
767
+ return null;
768
+ }
769
+
770
+ const isListView = !composerOpen && !activeThread;
771
+ const content = composerOpen
772
+ ? renderComposer()
773
+ : activeThread
774
+ ? renderThread()
775
+ : threads.length > 0
776
+ ? threads.map((thread) => renderDiagramCommentOverviewThread(thread, activeThreadId, openThread))
777
+ : React.createElement('div', {
778
+ className: 'comments-drawer-empty ui-empty-state ui-empty-state--compact',
779
+ }, [
780
+ React.createElement('p', {
781
+ className: 'ui-empty-state-title',
782
+ key: 'title',
783
+ }, 'No comments yet'),
784
+ React.createElement('p', {
785
+ className: 'ui-empty-state-copy',
786
+ key: 'copy',
787
+ }, 'Select an element and add the first comment.'),
788
+ ]);
789
+ const drawerAddButton = selectedElement && !composerOpen
790
+ ? React.createElement('button', {
791
+ 'aria-label': 'Add comment',
792
+ className: 'diagram-comment-add',
793
+ disabled: !canWrite,
794
+ key: 'add',
795
+ onClick: openComposer,
796
+ title: canWrite ? 'Add comment to selected element' : 'Reconnect to add a comment',
797
+ type: 'button',
798
+ }, React.createElement(DiagramCommentIcon, { add: true }))
799
+ : null;
800
+
801
+ return React.createElement('aside', {
802
+ 'aria-label': 'Diagram comments',
803
+ className: 'diagram-comments-drawer',
804
+ 'data-testid': 'diagram-comments-drawer',
805
+ }, [
806
+ React.createElement('div', {
807
+ className: 'comments-drawer-header diagram-comments-drawer-header',
808
+ key: 'header',
809
+ }, [
810
+ React.createElement('span', {
811
+ className: 'comments-drawer-title',
812
+ key: 'title',
813
+ }, composerOpen ? 'New comment' : activeThread ? 'Comment thread' : 'Comments'),
814
+ React.createElement('div', {
815
+ className: 'diagram-comments-drawer-header-actions',
816
+ key: 'actions',
817
+ }, [
818
+ drawerAddButton,
819
+ React.createElement('button', {
820
+ 'aria-label': 'Close comments',
821
+ className: 'diagram-comment-close',
822
+ key: 'close',
823
+ onClick: () => setDrawerOpen(false),
824
+ type: 'button',
825
+ }, '×'),
826
+ ]),
827
+ ]),
828
+ React.createElement('div', {
829
+ className: `diagram-comments-drawer-content${isListView ? ' comments-drawer-list is-list' : ''}`,
830
+ key: 'content',
831
+ }, content),
832
+ ]);
833
+ };
834
+
835
+ const renderCommentsOverlay = visible && room
836
+ ? React.createElement('div', {
837
+ 'aria-label': 'Diagram comments overlay',
838
+ className: 'diagram-comments-overlay',
839
+ key: 'overlay',
840
+ }, [
841
+ React.createElement('div', {
842
+ className: 'diagram-comment-markers',
843
+ key: 'markers',
844
+ }, threads.map((thread) => {
845
+ const position = getDiagramCommentMarkerPosition(thread);
846
+ if (!position) {
847
+ return null;
848
+ }
849
+
850
+ return React.createElement('button', {
851
+ 'aria-label': `Open comment on ${getDiagramAnchorLabel(thread)}`,
852
+ className: `diagram-comment-marker${thread.id === activeThreadId ? ' is-active' : ''}`,
853
+ 'data-comment-thread-id': thread.id,
854
+ key: thread.id,
855
+ onClick: () => openThread(thread.id),
856
+ style: {
857
+ left: `${position.x}px`,
858
+ top: `${position.y}px`,
859
+ },
860
+ type: 'button',
861
+ }, React.createElement(DiagramCommentIcon));
862
+ })),
863
+ renderDrawer(),
864
+ ])
865
+ : null;
866
+
867
+ return React.createElement(diagramCommentsContext.Provider, {
868
+ value: {
869
+ canWrite,
870
+ drawerOpen,
871
+ openComposer,
872
+ room,
873
+ selectedElement,
874
+ threads,
875
+ toggleDrawer: () => setDrawerOpen((open) => !open),
876
+ visible,
877
+ },
878
+ }, [
879
+ React.createElement(DiagramCommentsExcalidraw, {
880
+ initialData,
881
+ key: 'excalidraw',
882
+ renderKey: editorRenderKey,
883
+ viewModeEnabled: getDocumentViewState().viewModeEnabled,
884
+ }),
885
+ renderCommentsOverlay,
886
+ ]);
887
+ }
888
+
215
889
  function renderExcalidrawApp({ initialData } = {}) {
216
890
  if (!reactRoot) {
217
891
  return;
@@ -221,9 +895,14 @@ function renderExcalidrawApp({ initialData } = {}) {
221
895
  React.createElement(
222
896
  'div',
223
897
  { className: 'excalidraw-editor-shell' },
224
- React.createElement(Excalidraw, {
225
- key: `editor-${editorRenderKey}`,
226
- ...buildExcalidrawProps({ initialData }),
898
+ React.createElement(DiagramCommentsEditor, {
899
+ apiId: getMountedExcalidrawAPI()?.id || '',
900
+ canWrite: roomConnectionState === EXCALIDRAW_ROOM_CONNECTION_STATE.AUTHORITATIVE,
901
+ focusRequest: pendingDiagramCommentFocus,
902
+ initialData,
903
+ room: roomClient,
904
+ threads: diagramCommentThreads,
905
+ visible: normalizeDocumentMode(currentDocument.mode) !== 'preview',
227
906
  }),
228
907
  getAuthorityBannerText()
229
908
  ? React.createElement('div', {
@@ -260,12 +939,13 @@ function recordSceneDiagnostic(event, details = {}, sceneJson = '') {
260
939
 
261
940
  function handleHistoryAction({ action = '', outcome = '' } = {}) {
262
941
  recordSceneDiagnostic('history-action', { action, outcome });
263
- if (outcome !== 'no-visible-change' || !excalidrawAPI) {
942
+ const api = getMountedExcalidrawAPI();
943
+ if (outcome !== 'no-visible-change' || !api) {
264
944
  return;
265
945
  }
266
946
 
267
947
  const label = action === 'redo' ? 'Redo' : 'Undo';
268
- excalidrawAPI.setToast?.({
948
+ api.setToast?.({
269
949
  message: `${label} skipped: a collaborator changed that item`,
270
950
  });
271
951
  }
@@ -298,7 +978,7 @@ function handleRoomConnectionStateChange({
298
978
  roomConnectionState === EXCALIDRAW_ROOM_CONNECTION_STATE.AUTHORITATIVE
299
979
  && previousState === EXCALIDRAW_ROOM_CONNECTION_STATE.RECONNECTING_READONLY
300
980
  ) {
301
- excalidrawAPI?.setToast?.({ message: 'Live diagram reconnected' });
981
+ getMountedExcalidrawAPI()?.setToast?.({ message: 'Live diagram reconnected' });
302
982
  }
303
983
 
304
984
  if (roomConnectionState === EXCALIDRAW_ROOM_CONNECTION_STATE.AUTHORITATIVE) {
@@ -349,7 +1029,7 @@ if (isTestMode) {
349
1029
  window.__COLLABMD_EXCALIDRAW_TEST__ = {
350
1030
  disconnectTransport: () => roomClient?.provider?.disconnect?.(),
351
1031
  getElementBounds: (elementId) => {
352
- const element = excalidrawAPI?.getSceneElementsIncludingDeleted?.()?.find((entry) => entry.id === elementId && !entry.isDeleted);
1032
+ const element = getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.()?.find((entry) => entry.id === elementId && !entry.isDeleted);
353
1033
  if (!element) {
354
1034
  return null;
355
1035
  }
@@ -364,49 +1044,66 @@ if (isTestMode) {
364
1044
  };
365
1045
  },
366
1046
  getElementCount: () => (
367
- excalidrawAPI?.getSceneElementsIncludingDeleted?.()?.filter((element) => !element.isDeleted).length ?? 0
1047
+ getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.()?.filter((element) => !element.isDeleted).length ?? 0
368
1048
  ),
369
1049
  getElementIds: () => (
370
- excalidrawAPI?.getSceneElementsIncludingDeleted?.()
1050
+ getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.()
371
1051
  ?.filter((element) => !element.isDeleted)
372
1052
  .map((element) => element.id)
373
1053
  .sort() ?? []
374
1054
  ),
375
1055
  getElementStatus: (elementId) => (
376
- excalidrawAPI?.getSceneElementsIncludingDeleted?.()
1056
+ getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.()
377
1057
  ?.find((entry) => entry.id === elementId && !entry.isDeleted)?.status ?? null
378
1058
  ),
379
1059
  getFileIds: () => (
380
- Object.keys(excalidrawAPI?.getFiles?.() || {}).sort()
1060
+ Object.keys(getMountedExcalidrawAPI()?.getFiles?.() || {}).sort()
381
1061
  ),
382
- getFileVersion: (fileId) => excalidrawAPI?.getFiles?.()?.[fileId]?.version ?? null,
383
- getEditorId: () => excalidrawAPI?.id || null,
1062
+ getFileVersion: (fileId) => getMountedExcalidrawAPI()?.getFiles?.()?.[fileId]?.version ?? null,
1063
+ getEditorId: () => getMountedExcalidrawAPI()?.id || null,
384
1064
  getForkCapabilities: () => ({
385
- replaceFiles: typeof excalidrawAPI?.replaceFiles === 'function',
1065
+ replaceFiles: typeof getMountedExcalidrawAPI()?.replaceFiles === 'function',
386
1066
  }),
387
1067
  getAuthorityState: () => roomConnectionState,
1068
+ getCommentThreads: () => roomClient?.getCommentThreads?.() || [],
388
1069
  getDiagnosticTrace: () => diagnostics.exportTrace(),
389
1070
  getHistoryState: () => getNativeHistoryState(),
390
1071
  getLocalUserName: () => localAwarenessUser?.name || '',
391
1072
  getLocalPeerId: () => localAwarenessUser?.peerId || '',
392
1073
  getViewport: () => {
393
- const appState = excalidrawAPI?.getAppState?.();
1074
+ const appState = getMountedExcalidrawAPI()?.getAppState?.();
394
1075
  return appState ? {
1076
+ offsetLeft: appState.offsetLeft,
1077
+ offsetTop: appState.offsetTop,
395
1078
  scrollX: appState.scrollX,
396
1079
  scrollY: appState.scrollY,
397
1080
  zoom: appState.zoom?.value ?? null,
398
1081
  } : null;
399
1082
  },
400
- isViewMode: () => Boolean(excalidrawAPI?.getAppState?.().viewModeEnabled),
1083
+ isViewMode: () => Boolean(getMountedExcalidrawAPI()?.getAppState?.().viewModeEnabled),
1084
+ selectElement: (elementId) => {
1085
+ const api = getMountedExcalidrawAPI();
1086
+ if (!api || !elementId) {
1087
+ return false;
1088
+ }
1089
+
1090
+ api.updateScene({
1091
+ appState: {
1092
+ selectedElementIds: { [elementId]: true },
1093
+ },
1094
+ captureUpdate: CaptureUpdateAction.NEVER,
1095
+ });
1096
+ return true;
1097
+ },
401
1098
  getSceneJson: () => roomClient?.getLastSceneJson?.() || '',
402
1099
  isAuthoritativeReady: () => (
403
- Boolean(excalidrawAPI)
1100
+ Boolean(getMountedExcalidrawAPI())
404
1101
  && collabReady
405
1102
  && roomClient?.canWriteToRoom === true
406
1103
  && roomClient?.waitingForAuthoritativeSync === false
407
1104
  && roomClient?.isApplyingSharedSnapshot?.() === false
408
1105
  ),
409
- isReady: () => collabReady && Boolean(excalidrawAPI) && Boolean(getNativeHistoryButton('undo')) && Boolean(getNativeHistoryButton('redo')),
1106
+ isReady: () => collabReady && Boolean(getMountedExcalidrawAPI()),
410
1107
  redoShared: () => triggerNativeHistory('redo'),
411
1108
  reconnectTransport: () => roomClient?.provider?.connect?.(),
412
1109
  setScene: (scene) => {
@@ -415,12 +1112,13 @@ if (isTestMode) {
415
1112
  });
416
1113
  },
417
1114
  setViewport: (viewport) => {
418
- if (!excalidrawAPI) {
1115
+ const api = getMountedExcalidrawAPI();
1116
+ if (!api) {
419
1117
  return;
420
1118
  }
421
1119
 
422
- const currentAppState = excalidrawAPI.getAppState();
423
- excalidrawAPI.updateScene({
1120
+ const currentAppState = api.getAppState();
1121
+ api.updateScene({
424
1122
  appState: {
425
1123
  scrollX: Number.isFinite(viewport?.scrollX) ? viewport.scrollX : currentAppState.scrollX,
426
1124
  scrollY: Number.isFinite(viewport?.scrollY) ? viewport.scrollY : currentAppState.scrollY,
@@ -440,14 +1138,15 @@ function applyCollaborators(collaborators) {
440
1138
  const renderableCollaborators = buildRenderableCollaboratorsMap(activeCollaborators);
441
1139
  const renderSignature = getCollaboratorsRenderSignature(renderableCollaborators);
442
1140
 
443
- if (!excalidrawAPI) {
1141
+ const api = getMountedExcalidrawAPI();
1142
+ if (!api) {
444
1143
  pendingCollaborators = activeCollaborators;
445
1144
  return;
446
1145
  }
447
1146
 
448
1147
  if (renderSignature !== lastRenderedCollaboratorsSignature) {
449
1148
  lastRenderedCollaboratorsSignature = renderSignature;
450
- excalidrawAPI.updateScene({
1149
+ api.updateScene({
451
1150
  collaborators: renderableCollaborators,
452
1151
  captureUpdate: CaptureUpdateAction.NEVER,
453
1152
  });
@@ -476,11 +1175,11 @@ function queueCollaboratorsRender(collaborators) {
476
1175
  }
477
1176
 
478
1177
  function isEditingTextElement() {
479
- return Boolean(excalidrawAPI?.getAppState?.()?.editingTextElement);
1178
+ return Boolean(getMountedExcalidrawAPI()?.getAppState?.()?.editingTextElement);
480
1179
  }
481
1180
 
482
1181
  function flushPendingRemoteScene() {
483
- if (!pendingRemoteSceneJson || !excalidrawAPI || !collabReady || isEditingTextElement()) {
1182
+ if (!pendingRemoteSceneJson || !getMountedExcalidrawAPI() || !collabReady || isEditingTextElement()) {
484
1183
  return false;
485
1184
  }
486
1185
 
@@ -504,7 +1203,7 @@ function applySceneFromJson(rawJson, {
504
1203
  appliedSceneJson = normalizedJson;
505
1204
  recordSceneDiagnostic('remote-scene-received', {}, normalizedJson);
506
1205
 
507
- if (!excalidrawAPI || !collabReady) {
1206
+ if (!getMountedExcalidrawAPI() || !collabReady) {
508
1207
  pendingRemoteSceneJson = normalizedJson;
509
1208
  return;
510
1209
  }
@@ -531,9 +1230,14 @@ function releaseViewportBroadcastSuppressionAfterPaint() {
531
1230
 
532
1231
  function buildApiSceneUpdate(scene, {
533
1232
  appStateOverrides = {},
1233
+ api = getMountedExcalidrawAPI(),
534
1234
  } = {}) {
535
- const currentAppState = excalidrawAPI.getAppState();
536
- const currentElements = excalidrawAPI.getSceneElementsIncludingDeleted?.() || excalidrawAPI.getSceneElements();
1235
+ if (!api) {
1236
+ return null;
1237
+ }
1238
+
1239
+ const currentAppState = api.getAppState();
1240
+ const currentElements = api.getSceneElementsIncludingDeleted?.() || api.getSceneElements();
537
1241
 
538
1242
  return buildReconciledExcalidrawSceneUpdate({
539
1243
  appStateOverrides,
@@ -575,6 +1279,7 @@ function requestEditorRemount(scene) {
575
1279
  appliedSceneJson = normalizedJson;
576
1280
  pendingCollaborators = activeCollaborators;
577
1281
  initialViewportFitPending = true;
1282
+ clearPreviewViewportFitTimers();
578
1283
  clearEditorApiStateBindings();
579
1284
  skipRoomDisconnectOnUnmount = true;
580
1285
  editorRenderKey += 1;
@@ -589,8 +1294,14 @@ function applySceneToMountedApi(scene, {
589
1294
  captureUpdate = CaptureUpdateAction.NEVER,
590
1295
  trackedSharedSnapshot = false,
591
1296
  } = {}) {
1297
+ const api = getMountedExcalidrawAPI();
1298
+ if (!api) {
1299
+ return { skipped: true };
1300
+ }
1301
+
592
1302
  const nextSceneUpdate = buildApiSceneUpdate(scene, {
593
1303
  appStateOverrides,
1304
+ api,
594
1305
  });
595
1306
  let applyResult;
596
1307
 
@@ -599,7 +1310,7 @@ function applySceneToMountedApi(scene, {
599
1310
  }
600
1311
 
601
1312
  try {
602
- applyResult = applySceneUpdateWithFiles(excalidrawAPI, {
1313
+ applyResult = applySceneUpdateWithFiles(api, {
603
1314
  captureUpdate,
604
1315
  files: scene?.files || {},
605
1316
  sceneUpdate: nextSceneUpdate,
@@ -643,7 +1354,7 @@ function applyLocalScene(scene, {
643
1354
 
644
1355
  appliedSceneJson = normalizedJson;
645
1356
 
646
- if (!excalidrawAPI || !collabReady) {
1357
+ if (!getMountedExcalidrawAPI() || !collabReady) {
647
1358
  pendingRemoteSceneJson = normalizedJson;
648
1359
  return;
649
1360
  }
@@ -661,7 +1372,7 @@ function onRoomTextUpdate() {
661
1372
  }
662
1373
 
663
1374
  function getLiveSceneElementsForSync(fallbackElements = []) {
664
- return excalidrawAPI?.getSceneElementsIncludingDeleted?.() || fallbackElements;
1375
+ return getMountedExcalidrawAPI()?.getSceneElementsIncludingDeleted?.() || fallbackElements;
665
1376
  }
666
1377
 
667
1378
  function postToParent(type, payload = {}) {
@@ -679,8 +1390,9 @@ function handleQuickSwitcherKeyDown(event) {
679
1390
  }
680
1391
 
681
1392
  function getSceneElementsForPreviewFit() {
1393
+ const api = getMountedExcalidrawAPI();
682
1394
  return (
683
- excalidrawAPI?.getSceneElementsIncludingDeleted?.()
1395
+ api?.getSceneElementsIncludingDeleted?.()
684
1396
  ?.filter((element) => !element.isDeleted) ?? []
685
1397
  );
686
1398
  }
@@ -691,7 +1403,8 @@ function scheduleViewportFit({
691
1403
  consumeInitialFit = false,
692
1404
  } = {}) {
693
1405
  const normalizedMode = normalizeDocumentMode(currentDocument.mode);
694
- if (!excalidrawAPI || (forcePreview && normalizedMode !== 'preview')) {
1406
+ const api = getMountedExcalidrawAPI();
1407
+ if (!api || (forcePreview && normalizedMode !== 'preview')) {
695
1408
  return;
696
1409
  }
697
1410
 
@@ -699,6 +1412,11 @@ function scheduleViewportFit({
699
1412
  return;
700
1413
  }
701
1414
 
1415
+ if (!forcePreview && api.getAppState?.().cursorButton === 'down') {
1416
+ initialViewportFitPending = false;
1417
+ return;
1418
+ }
1419
+
702
1420
  const elements = getSceneElementsForPreviewFit();
703
1421
  if (elements.length === 0) {
704
1422
  return;
@@ -707,15 +1425,42 @@ function scheduleViewportFit({
707
1425
  initialViewportFitPending = false;
708
1426
  }
709
1427
 
1428
+ if (!forcePreview && delayMs === 0) {
1429
+ api.setViewport({
1430
+ target: elements,
1431
+ animation: false,
1432
+ fit: 'contain',
1433
+ });
1434
+ return;
1435
+ }
1436
+
1437
+ const generation = ++viewportFitGeneration;
1438
+
710
1439
  if (previewViewportFitTimerId) {
711
1440
  window.clearTimeout(previewViewportFitTimerId);
712
1441
  }
713
1442
 
714
1443
  previewViewportFitTimerId = window.setTimeout(() => {
715
1444
  previewViewportFitTimerId = 0;
1445
+ if (generation !== viewportFitGeneration) {
1446
+ return;
1447
+ }
1448
+
716
1449
  requestAnimationFrame(() => {
1450
+ if (generation !== viewportFitGeneration) {
1451
+ return;
1452
+ }
1453
+
717
1454
  requestAnimationFrame(() => {
718
- if (!excalidrawAPI) {
1455
+ if (generation !== viewportFitGeneration) {
1456
+ return;
1457
+ }
1458
+
1459
+ const api = getMountedExcalidrawAPI();
1460
+ if (!api) {
1461
+ return;
1462
+ }
1463
+ if (!forcePreview && api.getAppState?.().cursorButton === 'down') {
719
1464
  return;
720
1465
  }
721
1466
 
@@ -728,11 +1473,10 @@ function scheduleViewportFit({
728
1473
  suppressViewportBroadcast = true;
729
1474
  }
730
1475
 
731
- excalidrawAPI.scrollToContent(latestElements, {
732
- animate: false,
733
- fitToViewport: true,
734
- maxZoom: 2,
735
- viewportZoomFactor: normalizedMode === 'preview' ? 0.92 : 0.88,
1476
+ api.setViewport({
1477
+ target: latestElements,
1478
+ animation: false,
1479
+ fit: 'contain',
736
1480
  });
737
1481
 
738
1482
  if (forcePreview) {
@@ -748,18 +1492,38 @@ function scheduleInitialViewportFit() {
748
1492
  }
749
1493
 
750
1494
  function schedulePreviewViewportFit() {
1495
+ if (previewViewportFitRetryTimerId) {
1496
+ window.clearTimeout(previewViewportFitRetryTimerId);
1497
+ }
1498
+
751
1499
  scheduleViewportFit({ forcePreview: true, delayMs: 80 });
752
- window.setTimeout(() => {
1500
+ previewViewportFitRetryTimerId = window.setTimeout(() => {
1501
+ previewViewportFitRetryTimerId = 0;
753
1502
  scheduleViewportFit({ forcePreview: true });
754
1503
  }, 240);
755
1504
  }
756
1505
 
1506
+ function clearPreviewViewportFitTimers() {
1507
+ viewportFitGeneration += 1;
1508
+
1509
+ if (previewViewportFitTimerId) {
1510
+ window.clearTimeout(previewViewportFitTimerId);
1511
+ previewViewportFitTimerId = 0;
1512
+ }
1513
+
1514
+ if (previewViewportFitRetryTimerId) {
1515
+ window.clearTimeout(previewViewportFitRetryTimerId);
1516
+ previewViewportFitRetryTimerId = 0;
1517
+ }
1518
+ }
1519
+
757
1520
  function syncLocalViewportToRoom() {
758
- if (!collabReady || !excalidrawAPI || !roomClient || suppressViewportBroadcast) {
1521
+ const api = getMountedExcalidrawAPI();
1522
+ if (!collabReady || !api || !roomClient || suppressViewportBroadcast) {
759
1523
  return;
760
1524
  }
761
1525
 
762
- const appState = excalidrawAPI.getAppState();
1526
+ const appState = api.getAppState();
763
1527
  roomClient.scheduleLocalViewportAwareness({
764
1528
  scrollX: appState.scrollX,
765
1529
  scrollY: appState.scrollY,
@@ -781,7 +1545,8 @@ function setFollowedSocket(nextSocketId, { force = false } = {}) {
781
1545
  }
782
1546
 
783
1547
  function applyFollowedViewport(collaborators = activeCollaborators, { force = false } = {}) {
784
- if (!excalidrawAPI || !followedSocketId) {
1548
+ const api = getMountedExcalidrawAPI();
1549
+ if (!api || !followedSocketId) {
785
1550
  return;
786
1551
  }
787
1552
 
@@ -798,7 +1563,7 @@ function applyFollowedViewport(collaborators = activeCollaborators, { force = fa
798
1563
 
799
1564
  lastAppliedFollowViewportSignature = nextSignature;
800
1565
  suppressViewportBroadcast = true;
801
- excalidrawAPI.updateScene({
1566
+ api.updateScene({
802
1567
  appState: {
803
1568
  scrollX: viewport.scrollX,
804
1569
  scrollY: viewport.scrollY,
@@ -811,12 +1576,13 @@ function applyFollowedViewport(collaborators = activeCollaborators, { force = fa
811
1576
 
812
1577
  function applyHostFollowRequest(peerId) {
813
1578
  pendingHostFollowPeerId = peerId || null;
814
- if (!excalidrawAPI) {
1579
+ const api = getMountedExcalidrawAPI();
1580
+ if (!api) {
815
1581
  return;
816
1582
  }
817
1583
 
818
1584
  if (!peerId) {
819
- excalidrawAPI.updateScene({
1585
+ api.updateScene({
820
1586
  appState: { userToFollow: null },
821
1587
  captureUpdate: CaptureUpdateAction.NEVER,
822
1588
  });
@@ -830,7 +1596,7 @@ function applyHostFollowRequest(peerId) {
830
1596
  return;
831
1597
  }
832
1598
 
833
- excalidrawAPI.updateScene({
1599
+ api.updateScene({
834
1600
  appState: {
835
1601
  userToFollow: {
836
1602
  socketId: collaborator.socketId,
@@ -853,6 +1619,8 @@ function resetRealtimeRoomState() {
853
1619
  pendingRemoteSceneJson = '';
854
1620
  pendingCollaborators = null;
855
1621
  activeCollaborators = new Map();
1622
+ diagramCommentThreads = [];
1623
+ pendingDiagramCommentFocus = null;
856
1624
  followedSocketId = null;
857
1625
  pendingHostFollowPeerId = null;
858
1626
  suppressViewportBroadcast = false;
@@ -984,6 +1752,10 @@ window.addEventListener('message', (event) => {
984
1752
  return;
985
1753
  }
986
1754
 
1755
+ if (event.source !== window.parent) {
1756
+ return;
1757
+ }
1758
+
987
1759
  const message = event.data;
988
1760
  if (!message || message.source !== 'collabmd-host') {
989
1761
  return;
@@ -992,8 +1764,9 @@ window.addEventListener('message', (event) => {
992
1764
  if (message.type === 'set-theme') {
993
1765
  currentTheme = message.theme || 'dark';
994
1766
  applySurfaceTheme(currentTheme);
995
- if (excalidrawAPI) {
996
- excalidrawAPI.updateScene({
1767
+ const api = getMountedExcalidrawAPI();
1768
+ if (api) {
1769
+ api.updateScene({
997
1770
  appState: { theme: currentTheme },
998
1771
  captureUpdate: CaptureUpdateAction.NEVER,
999
1772
  });
@@ -1007,6 +1780,11 @@ window.addEventListener('message', (event) => {
1007
1780
  return;
1008
1781
  }
1009
1782
 
1783
+ if (message.type === 'open-comment-thread') {
1784
+ requestDiagramCommentFocus(message.threadId);
1785
+ return;
1786
+ }
1787
+
1010
1788
  if (message.type === 'follow-user') {
1011
1789
  applyHostFollowRequest(message.peerId || null);
1012
1790
  return;
@@ -1102,6 +1880,9 @@ function initializeEditor(api) {
1102
1880
 
1103
1881
  setFollowedSocket(null, { force: true });
1104
1882
  }));
1883
+ apiStateCleanupCallbacks.push(api.onStateChange('selectedElementIds', () => {
1884
+ renderExcalidrawApp();
1885
+ }));
1105
1886
  apiStateCleanupCallbacks.push(api.onStateChange('editingTextElement', (editingTextElement) => {
1106
1887
  if (!editingTextElement) {
1107
1888
  requestAnimationFrame(() => {
@@ -1121,7 +1902,7 @@ function initializeEditor(api) {
1121
1902
  if (pendingCollaborators) {
1122
1903
  const renderableCollaborators = buildRenderableCollaboratorsMap(pendingCollaborators);
1123
1904
  lastRenderedCollaboratorsSignature = getCollaboratorsRenderSignature(renderableCollaborators);
1124
- excalidrawAPI.updateScene({
1905
+ api.updateScene({
1125
1906
  collaborators: renderableCollaborators,
1126
1907
  captureUpdate: CaptureUpdateAction.NEVER,
1127
1908
  });
@@ -1137,6 +1918,7 @@ function initializeEditor(api) {
1137
1918
 
1138
1919
  scheduleInitialViewportFit();
1139
1920
  postToParent('ready');
1921
+ renderExcalidrawApp();
1140
1922
  }
1141
1923
 
1142
1924
  function handleEditorMount({ excalidrawAPI: api }) {