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
@@ -22,6 +22,7 @@ export class BrowserPreferencesPort {
22
22
  fileTreeShowExtensionsKey,
23
23
  lineWrappingKey,
24
24
  recentFilesKey = 'collabmd-recent-files',
25
+ vimModeKey = 'collabmd-editor-vim-mode',
25
26
  sidebarVisibleKey,
26
27
  userNameKey,
27
28
  storage = globalThis.localStorage,
@@ -32,6 +33,7 @@ export class BrowserPreferencesPort {
32
33
  this.sidebarVisibleKey = sidebarVisibleKey;
33
34
  this.storage = storage;
34
35
  this.userNameKey = userNameKey;
36
+ this.vimModeKey = vimModeKey;
35
37
  }
36
38
 
37
39
  getUserName() {
@@ -58,6 +60,14 @@ export class BrowserPreferencesPort {
58
60
  writeStorage(this.storage, this.lineWrappingKey, String(enabled));
59
61
  }
60
62
 
63
+ getVimModeEnabled() {
64
+ return readStorage(this.storage, this.vimModeKey, null) === 'true';
65
+ }
66
+
67
+ setVimModeEnabled(enabled) {
68
+ writeStorage(this.storage, this.vimModeKey, String(enabled));
69
+ }
70
+
61
71
  getRecentFiles() {
62
72
  const stored = readStorage(this.storage, this.recentFilesKey, '[]');
63
73
  try {
@@ -83,15 +83,21 @@ function normalizeSelectionAnchorPayload(payload, state) {
83
83
 
84
84
  export class CommentThreadStore {
85
85
  constructor({
86
+ canWrite = () => true,
87
+ createAnchor = null,
86
88
  getDoc,
87
89
  getEditorState,
88
90
  getLocalUser,
89
91
  onCommentsChange = null,
92
+ resolveThread = null,
90
93
  }) {
94
+ this.canWrite = canWrite;
95
+ this.createAnchor = createAnchor;
91
96
  this.getDoc = getDoc;
92
97
  this.getEditorState = getEditorState;
93
98
  this.getLocalUser = getLocalUser;
94
99
  this.onCommentsChange = onCommentsChange;
100
+ this.resolveThread = resolveThread;
95
101
  this.commentThreads = null;
96
102
  this.ydoc = null;
97
103
  this.ytext = null;
@@ -144,33 +150,44 @@ export class CommentThreadStore {
144
150
  }
145
151
 
146
152
  return serializeCommentThreads(this.commentThreads)
147
- .map((thread) => this.resolveCommentThread(thread))
153
+ .map((thread) => this.resolveThread
154
+ ? this.resolveThread(thread)
155
+ : this.resolveCommentThread(thread))
148
156
  .filter(Boolean);
149
157
  }
150
158
 
151
159
  createCommentThread({ anchor, body }) {
152
- const state = this.getEditorState();
153
- if (!state || !this.commentThreads || !this.ytext || !this.ydoc) {
160
+ if (!this.canWrite() || !this.commentThreads || !this.ydoc) {
154
161
  return null;
155
162
  }
156
163
 
164
+ const state = this.getEditorState?.();
157
165
  const normalizedBody = normalizeCommentBody(body);
158
- const normalizedAnchor = normalizeSelectionAnchorPayload(anchor, state);
166
+ if (!this.createAnchor && (!state || !this.ytext)) {
167
+ return null;
168
+ }
169
+
170
+ const normalizedAnchor = this.createAnchor
171
+ ? this.createAnchor(anchor)
172
+ : normalizeSelectionAnchorPayload(anchor, state);
159
173
  if (!normalizedBody || !normalizedAnchor) {
160
174
  return null;
161
175
  }
162
176
 
163
177
  const thread = createCommentThreadSharedType({
164
- anchorEnd: Y.relativePositionToJSON(
165
- Y.createRelativePositionFromTypeIndex(this.ytext, normalizedAnchor.endIndex),
166
- ),
167
- anchorEndLine: normalizedAnchor.anchorEndLine,
178
+ ...normalizedAnchor,
179
+ ...(this.createAnchor ? {} : {
180
+ anchorEnd: Y.relativePositionToJSON(
181
+ Y.createRelativePositionFromTypeIndex(this.ytext, normalizedAnchor.endIndex),
182
+ ),
183
+ }),
168
184
  anchorKind: normalizedAnchor.anchorKind,
169
185
  anchorQuote: normalizedAnchor.anchorQuote,
170
- anchorStart: Y.relativePositionToJSON(
171
- Y.createRelativePositionFromTypeIndex(this.ytext, normalizedAnchor.startIndex),
172
- ),
173
- anchorStartLine: normalizedAnchor.anchorStartLine,
186
+ ...(this.createAnchor ? {} : {
187
+ anchorStart: Y.relativePositionToJSON(
188
+ Y.createRelativePositionFromTypeIndex(this.ytext, normalizedAnchor.startIndex),
189
+ ),
190
+ }),
174
191
  createdAt: Date.now(),
175
192
  createdByColor: this.getLocalUser()?.color ?? '',
176
193
  createdByName: this.getLocalUser()?.name ?? 'Anonymous',
@@ -194,6 +211,10 @@ export class CommentThreadStore {
194
211
  }
195
212
 
196
213
  replyToCommentThread(threadId, body) {
214
+ if (!this.canWrite()) {
215
+ return null;
216
+ }
217
+
197
218
  const normalizedBody = normalizeCommentBody(body);
198
219
  if (!normalizedBody || !this.ydoc) {
199
220
  return null;
@@ -291,7 +312,7 @@ export class CommentThreadStore {
291
312
  }
292
313
 
293
314
  deleteCommentThread(threadId) {
294
- if (!this.commentThreads || !this.ydoc) {
315
+ if (!this.canWrite() || !this.commentThreads || !this.ydoc) {
295
316
  return false;
296
317
  }
297
318
 
@@ -8,6 +8,7 @@ export class EditorSession {
8
8
  constructor({
9
9
  editorContainer,
10
10
  lineWrappingEnabled = true,
11
+ vimModeEnabled = false,
11
12
  initialTheme,
12
13
  lineInfoElement,
13
14
  onAwarenessChange,
@@ -44,6 +45,7 @@ export class EditorSession {
44
45
  initialTheme,
45
46
  lineInfoElement,
46
47
  lineWrappingEnabled,
48
+ vimModeEnabled,
47
49
  onDocChanged: () => {
48
50
  this.emitContentChange();
49
51
  },
@@ -210,6 +212,14 @@ export class EditorSession {
210
212
  return this.viewAdapter.isLineWrappingEnabled();
211
213
  }
212
214
 
215
+ isVimModeEnabled() {
216
+ return this.viewAdapter.isVimModeEnabled();
217
+ }
218
+
219
+ setVimMode(enabled) {
220
+ return this.viewAdapter.setVimMode(enabled);
221
+ }
222
+
213
223
  setLineWrapping(enabled) {
214
224
  return this.viewAdapter.setLineWrapping(enabled);
215
225
  }
@@ -1,3 +1,4 @@
1
+ import { vim } from '@replit/codemirror-vim';
1
2
  import { autocompletion, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
2
3
  import {
3
4
  defaultKeymap,
@@ -45,15 +46,26 @@ import {
45
46
  } from '@codemirror/view';
46
47
  import { yCollab } from 'y-codemirror.next';
47
48
 
48
- import { isBaseFilePath, isMermaidFilePath, isPlantUmlFilePath } from '../../domain/file-kind.js';
49
+ import {
50
+ isBaseFilePath,
51
+ isMermaidFilePath,
52
+ isPlantUmlFilePath,
53
+ isStructurizrFilePath,
54
+ } from '../../domain/file-kind.js';
49
55
  import { normalizeCommentQuote } from '../../domain/comment-threads.js';
50
56
  import { createMarkdownToolbarEdit } from '../domain/markdown-formatting.js';
51
57
  import { mermaidLanguage, mermaidLanguageDescription } from '../domain/mermaid-language.js';
52
58
  import { wikiLinkCompletions } from '../domain/wiki-link-completions.js';
53
59
  import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantuml-language.js';
60
+ import { structurizrLanguage, structurizrLanguageDescription } from '../domain/structurizr-language.js';
54
61
  import { handleImagePasteEvent } from './editor-paste-utils.js';
55
62
 
56
- const markdownCodeLanguages = [...languages, mermaidLanguageDescription, plantUmlLanguageDescription];
63
+ const markdownCodeLanguages = [
64
+ ...languages,
65
+ mermaidLanguageDescription,
66
+ plantUmlLanguageDescription,
67
+ structurizrLanguageDescription,
68
+ ];
57
69
  const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
58
70
  const nonmatchingBracketMark = Decoration.mark({ class: 'cm-nonmatchingBracket' });
59
71
  const remoteUpdateMark = Decoration.mark({ class: 'cm-remoteUpdateFlash' });
@@ -387,6 +399,10 @@ export function createLanguageExtension(filePath) {
387
399
  return plantUmlLanguage;
388
400
  }
389
401
 
402
+ if (isStructurizrFilePath(filePath)) {
403
+ return structurizrLanguage;
404
+ }
405
+
390
406
  if (isBaseFilePath(filePath)) {
391
407
  return yaml();
392
408
  }
@@ -408,6 +424,7 @@ export class EditorViewAdapter {
408
424
  initialTheme,
409
425
  lineInfoElement,
410
426
  lineWrappingEnabled = true,
427
+ vimModeEnabled = false,
411
428
  onDocChanged = null,
412
429
  onImagePaste = null,
413
430
  onSelectionChanged = null,
@@ -418,6 +435,7 @@ export class EditorViewAdapter {
418
435
  this.initialTheme = initialTheme;
419
436
  this.lineInfoElement = lineInfoElement;
420
437
  this.lineWrappingEnabled = lineWrappingEnabled;
438
+ this.vimModeEnabled = Boolean(vimModeEnabled);
421
439
  this.onDocChanged = onDocChanged;
422
440
  this.onImagePaste = onImagePaste;
423
441
  this.onSelectionChanged = onSelectionChanged;
@@ -426,6 +444,7 @@ export class EditorViewAdapter {
426
444
  this.themeCompartment = new Compartment();
427
445
  this.syntaxThemeCompartment = new Compartment();
428
446
  this.lineWrappingCompartment = new Compartment();
447
+ this.vimModeCompartment = new Compartment();
429
448
  this.viewportFrame = 0;
430
449
  this.remoteUpdateFlashTimer = 0;
431
450
  this.lastLocalInputAt = 0;
@@ -478,6 +497,7 @@ export class EditorViewAdapter {
478
497
  highlightActiveLine(),
479
498
  highlightSelectionMatches(),
480
499
  createSearchNavigationListener(),
500
+ this.vimModeCompartment.of(!readOnly && this.vimModeEnabled ? vim({ status: true }) : []),
481
501
  keymap.of([
482
502
  ...closeBracketsKeymap,
483
503
  ...defaultKeymap,
@@ -645,6 +665,30 @@ export class EditorViewAdapter {
645
665
  return this.lineWrappingEnabled;
646
666
  }
647
667
 
668
+ isVimModeEnabled() {
669
+ return this.vimModeEnabled;
670
+ }
671
+
672
+ setVimMode(enabled) {
673
+ const nextEnabled = Boolean(enabled);
674
+ if (nextEnabled === this.vimModeEnabled) {
675
+ return nextEnabled;
676
+ }
677
+
678
+ this.vimModeEnabled = nextEnabled;
679
+ if (!this.editorView || this.editorView.state.readOnly) {
680
+ return nextEnabled;
681
+ }
682
+
683
+ this.editorView.dispatch({
684
+ effects: this.vimModeCompartment.reconfigure(
685
+ nextEnabled ? vim({ status: true }) : [],
686
+ ),
687
+ });
688
+ this.editorView.focus();
689
+ return nextEnabled;
690
+ }
691
+
648
692
  setLineWrapping(enabled) {
649
693
  this.lineWrappingEnabled = Boolean(enabled);
650
694
 
@@ -24,6 +24,10 @@ import {
24
24
  tryParseSceneJson,
25
25
  } from '../domain/excalidraw-scene.js';
26
26
  import { resolveWsBaseUrl } from '../domain/runtime-paths.js';
27
+ import {
28
+ normalizeCommentQuote,
29
+ } from '../../domain/comment-threads.js';
30
+ import { CommentThreadStore } from './comment-thread-store.js';
27
31
  import { stopReconnectOnControlledClose } from './yjs-provider-reset-guard.js';
28
32
 
29
33
  const DEFAULT_EMPTY_SCENE_GUARD_MS = 250;
@@ -39,16 +43,21 @@ export const EXCALIDRAW_ROOM_CONNECTION_STATE = Object.freeze({
39
43
  RECONNECTING_READONLY: 'reconnecting-readonly',
40
44
  });
41
45
 
42
- function stableJson(value) {
43
- if (Array.isArray(value)) {
44
- return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
46
+ function areExcalidrawFilesEqual(left, right) {
47
+ if (left === right) {
48
+ return true;
45
49
  }
46
50
 
47
- if (value && typeof value === 'object') {
48
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
51
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
52
+ return false;
49
53
  }
50
54
 
51
- return JSON.stringify(value);
55
+ return left.id === right.id
56
+ && left.mimeType === right.mimeType
57
+ && left.dataURL === right.dataURL
58
+ && left.created === right.created
59
+ && left.lastRetrieved === right.lastRetrieved
60
+ && left.version === right.version;
52
61
  }
53
62
 
54
63
  function normalizeElementVersion(value) {
@@ -81,6 +90,35 @@ function shouldSyncElementToRoom(localElement, roomElement) {
81
90
  return localVersionNonce < roomVersionNonce;
82
91
  }
83
92
 
93
+ function createDiagramCommentAnchor(element) {
94
+ const elementId = typeof element?.id === 'string' ? element.id.trim() : '';
95
+ const x = Number(element?.x);
96
+ const y = Number(element?.y);
97
+ const width = Number(element?.width);
98
+ const height = Number(element?.height);
99
+ if (!elementId || ![x, y, width, height].every(Number.isFinite)) {
100
+ return null;
101
+ }
102
+
103
+ return {
104
+ anchorKind: 'diagram-element',
105
+ anchorPoint: {
106
+ x: x + (width / 2),
107
+ y: y + (height / 2),
108
+ },
109
+ anchorQuote: normalizeCommentQuote(element.text),
110
+ anchorSnapshot: {
111
+ height,
112
+ text: normalizeCommentQuote(element.text),
113
+ type: typeof element.type === 'string' ? element.type : 'element',
114
+ width,
115
+ x,
116
+ y,
117
+ },
118
+ elementId,
119
+ };
120
+ }
121
+
84
122
  export class ExcalidrawRoomClient {
85
123
  constructor({
86
124
  cancelAnimationFrameFn = (frameId) => cancelAnimationFrame(frameId),
@@ -89,6 +127,7 @@ export class ExcalidrawRoomClient {
89
127
  filePath = '',
90
128
  now = () => Date.now(),
91
129
  onCollaboratorsChange = () => {},
130
+ onCommentThreadsChange = () => {},
92
131
  onConnectionStateChange = () => {},
93
132
  onRemoteSceneJson = () => {},
94
133
  requestAnimationFrameFn = (callback) => requestAnimationFrame(callback),
@@ -106,6 +145,7 @@ export class ExcalidrawRoomClient {
106
145
  this.filePath = filePath;
107
146
  this.now = now;
108
147
  this.onCollaboratorsChange = onCollaboratorsChange;
148
+ this.onCommentThreadsChange = onCommentThreadsChange;
109
149
  this.onConnectionStateChange = onConnectionStateChange;
110
150
  this.onRemoteSceneJson = onRemoteSceneJson;
111
151
  this.requestAnimationFrameFn = requestAnimationFrameFn;
@@ -138,12 +178,22 @@ export class ExcalidrawRoomClient {
138
178
  this.lastViewportSignature = '';
139
179
  this.lastSelectedIdsSignature = '';
140
180
  this.pendingEmptySceneCandidate = null;
181
+ this.structuredObserversAttached = false;
141
182
  this.canWriteToRoom = false;
142
183
  this.waitingForAuthoritativeSync = false;
143
184
  this.connectionState = EXCALIDRAW_ROOM_CONNECTION_STATE.CLOSED;
144
185
  this.hasAuthoritativeSync = false;
145
186
  this.applyingSharedSnapshotDepth = 0;
146
187
  this.suppressStructuredSceneUpdateDepth = 0;
188
+ this.commentThreadStore = new CommentThreadStore({
189
+ canWrite: () => this.canWriteToRoom,
190
+ createAnchor: createDiagramCommentAnchor,
191
+ getDoc: () => this.ydoc,
192
+ getEditorState: () => null,
193
+ getLocalUser: () => this.getLocalUser(),
194
+ onCommentsChange: (threads) => this.onCommentThreadsChange(threads),
195
+ resolveThread: (thread) => thread,
196
+ });
147
197
 
148
198
  this.handleAwarenessChange = () => {
149
199
  this.onCollaboratorsChange(buildCollaboratorsMap(this.awareness));
@@ -177,6 +227,25 @@ export class ExcalidrawRoomClient {
177
227
  return this.localUser;
178
228
  }
179
229
 
230
+ getCommentThreads() {
231
+ return this.commentThreadStore.getCommentThreads();
232
+ }
233
+
234
+ createCommentThread({ body, element } = {}) {
235
+ return this.commentThreadStore.createCommentThread({
236
+ anchor: element,
237
+ body,
238
+ });
239
+ }
240
+
241
+ replyToCommentThread(threadId, body) {
242
+ return this.commentThreadStore.replyToCommentThread(threadId, body);
243
+ }
244
+
245
+ deleteCommentThread(threadId) {
246
+ return this.commentThreadStore.deleteCommentThread(threadId);
247
+ }
248
+
180
249
  getConnectionState() {
181
250
  return this.connectionState;
182
251
  }
@@ -268,7 +337,16 @@ export class ExcalidrawRoomClient {
268
337
  return this.localUser;
269
338
  }
270
339
 
271
- async connect({ initialUser = null } = {}) {
340
+ async connect(options = {}) {
341
+ try {
342
+ return await this.connectInternal(options);
343
+ } catch (error) {
344
+ this.disconnect();
345
+ throw error;
346
+ }
347
+ }
348
+
349
+ async connectInternal({ initialUser = null } = {}) {
272
350
  this.localUser = initialUser;
273
351
  this.setConnectionState(EXCALIDRAW_ROOM_CONNECTION_STATE.CONNECTING);
274
352
 
@@ -277,6 +355,7 @@ export class ExcalidrawRoomClient {
277
355
  this.lastSceneJson = JSON.stringify(scene);
278
356
  this.hasAuthoritativeSync = true;
279
357
  this.setConnectionState(EXCALIDRAW_ROOM_CONNECTION_STATE.AUTHORITATIVE);
358
+ this.onCommentThreadsChange([]);
280
359
  return scene;
281
360
  }
282
361
 
@@ -367,6 +446,11 @@ export class ExcalidrawRoomClient {
367
446
  this.roomElements.observeDeep(this.handleStructuredSceneUpdate);
368
447
  this.roomFiles.observeDeep(this.handleStructuredSceneUpdate);
369
448
  this.roomAppState.observe(this.handleStructuredSceneUpdate);
449
+ this.structuredObserversAttached = true;
450
+ this.commentThreadStore.bind({
451
+ commentThreads: this.ydoc.getArray('comments'),
452
+ ydoc: this.ydoc,
453
+ });
370
454
  if (usedApiFallback && this.canWriteToRoom) {
371
455
  this.commitSceneJson(this.lastSceneJson, {
372
456
  origin: 'excalidraw-api-fallback',
@@ -501,7 +585,7 @@ export class ExcalidrawRoomClient {
501
585
 
502
586
  mergeLocallyChangedAppState(appState, baseSceneJson) {
503
587
  const baseScene = parseSceneJson(baseSceneJson);
504
- const roomScene = parseSceneJson(this.getStructuredSceneJson() || this.lastSceneJson);
588
+ const roomScene = parseSceneJson(this.lastSceneJson || this.getStructuredSceneJson());
505
589
  const nextAppState = {
506
590
  ...roomScene.appState,
507
591
  };
@@ -523,7 +607,7 @@ export class ExcalidrawRoomClient {
523
607
  elements,
524
608
  files,
525
609
  } = {}) {
526
- const roomSceneJson = this.getStructuredSceneJson() || this.lastSceneJson || baseSceneJson;
610
+ const roomSceneJson = this.lastSceneJson || this.getStructuredSceneJson() || baseSceneJson;
527
611
  const roomScene = parseSceneJson(roomSceneJson);
528
612
  const nextAppState = this.mergeLocallyChangedAppState(appState, baseSceneJson);
529
613
  const roomElementsById = new Map(roomScene.elements.map((element) => [element.id, element]));
@@ -533,7 +617,7 @@ export class ExcalidrawRoomClient {
533
617
  const nextFiles = files && typeof files === 'object' ? files : {};
534
618
  const changedFiles = Object.fromEntries(
535
619
  Object.entries(nextFiles).filter(([fileId, file]) => (
536
- stableJson(file) !== stableJson(roomScene.files?.[fileId])
620
+ !areExcalidrawFilesEqual(file, roomScene.files?.[fileId])
537
621
  )),
538
622
  );
539
623
  const appStateChanged = (
@@ -869,10 +953,15 @@ export class ExcalidrawRoomClient {
869
953
  this.lastSelectedIdsSignature = '';
870
954
  this.pendingEmptySceneCandidate = null;
871
955
 
872
- this.roomMeta?.unobserve(this.handleStructuredSceneUpdate);
873
- this.roomElements?.unobserveDeep(this.handleStructuredSceneUpdate);
874
- this.roomFiles?.unobserveDeep(this.handleStructuredSceneUpdate);
875
- this.roomAppState?.unobserve(this.handleStructuredSceneUpdate);
956
+ if (this.structuredObserversAttached) {
957
+ this.roomMeta?.unobserve(this.handleStructuredSceneUpdate);
958
+ this.roomElements?.unobserveDeep(this.handleStructuredSceneUpdate);
959
+ this.roomFiles?.unobserveDeep(this.handleStructuredSceneUpdate);
960
+ this.roomAppState?.unobserve(this.handleStructuredSceneUpdate);
961
+ this.structuredObserversAttached = false;
962
+ }
963
+
964
+ this.commentThreadStore.unbind();
876
965
 
877
966
  if (this.awareness) {
878
967
  this.awareness.off('change', this.handleAwarenessChange);
@@ -74,6 +74,11 @@ export class GitApiClient {
74
74
  return parseApiResponse(response, 'Failed to load historical file preview');
75
75
  }
76
76
 
77
+ getFileAttachmentUrl({ hash, path } = {}) {
78
+ const params = createSearchParams({ hash, path });
79
+ return resolveApiUrl(`/git/file-attachment?${params.toString()}`);
80
+ }
81
+
77
82
  async stageFile({ path, requestId = null } = {}) {
78
83
  return this.#postJson('/git/stage', { path }, { requestId, fallbackError: 'Failed to stage file' });
79
84
  }
@@ -51,6 +51,20 @@ export class VaultApiClient {
51
51
  return Array.isArray(data.backlinks) ? data.backlinks : [];
52
52
  }
53
53
 
54
+ async syncStructurizrWorkspace({ path, source }) {
55
+ const response = await fetch(resolveApiUrl('/structurizr/sync'), {
56
+ body: JSON.stringify({
57
+ path,
58
+ source,
59
+ }),
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ },
63
+ method: 'POST',
64
+ });
65
+ return parseApiResponse(response, 'Failed to sync Structurizr workspace');
66
+ }
67
+
54
68
  async renderSvg(source) {
55
69
  const response = await fetch(resolveApiUrl('/plantuml/render'), {
56
70
  body: JSON.stringify({ source }),
@@ -261,6 +275,18 @@ export class VaultApiClient {
261
275
  return parseApiResponse(response, 'Failed to upload image');
262
276
  }
263
277
 
278
+ async uploadFile({ file, path, requestId = null }) {
279
+ const response = await fetch(resolveApiUrl('/file/upload'), {
280
+ body: file,
281
+ headers: createRequestHeaders(requestId, {
282
+ 'Content-Type': file?.type || 'application/octet-stream',
283
+ 'X-CollabMD-File-Path': encodeHeaderMetadata(path),
284
+ }),
285
+ method: 'POST',
286
+ });
287
+ return parseApiResponse(response, 'Failed to upload file');
288
+ }
289
+
264
290
  async downloadFile(path) {
265
291
  const fallbackFileName = getVaultPathLeaf(path) || 'download';
266
292
  return triggerDownload(resolveApiUrl(`/download/file?path=${encodeURIComponent(path)}`), {
@@ -51,6 +51,10 @@ export function areAnchorsEqual(left, right) {
51
51
  }
52
52
 
53
53
  export function formatAnchorLabel(anchor) {
54
+ if (getAnchorKind(anchor) === 'diagram-element') {
55
+ return 'Diagram element';
56
+ }
57
+
54
58
  const startLine = Number(anchor?.startLine || 0);
55
59
  const endLine = Number(anchor?.endLine || startLine);
56
60
  if (!Number.isFinite(startLine) || startLine <= 0) {
@@ -1,6 +1,7 @@
1
1
  import { createDrawioLeaseRoomName } from '../../domain/drawio-room.js';
2
2
  import { setDiagramActionButtonIcon } from '../domain/diagram-action-icons.js';
3
3
  import { resolveAppUrl } from '../domain/runtime-paths.js';
4
+ import { renderDrawioViewer } from './drawio-viewer.js';
4
5
  import {
5
6
  cancelIdleRender,
6
7
  isNearViewport,
@@ -8,62 +9,6 @@ import {
8
9
  } from '../browser-utils.js';
9
10
 
10
11
  const HYDRATE_VIEWPORT_MARGIN_PX = 360;
11
- const DRAWIO_VIEWER_SCRIPT_URL = 'https://viewer.diagrams.net/js/viewer-static.min.js';
12
-
13
- let drawioViewerLoadPromise = null;
14
-
15
- function ensureDrawioViewerLoaded() {
16
- if (window.GraphViewer?.processElements) {
17
- return Promise.resolve(window.GraphViewer);
18
- }
19
-
20
- if (drawioViewerLoadPromise) {
21
- return drawioViewerLoadPromise;
22
- }
23
-
24
- drawioViewerLoadPromise = new Promise((resolve, reject) => {
25
- const existingScript = document.querySelector('script[data-collabmd-drawio-viewer]');
26
- const script = existingScript instanceof HTMLScriptElement
27
- ? existingScript
28
- : document.createElement('script');
29
-
30
- const cleanup = () => {
31
- script.removeEventListener('error', handleError);
32
- script.removeEventListener('load', handleLoad);
33
- };
34
-
35
- const handleError = () => {
36
- cleanup();
37
- drawioViewerLoadPromise = null;
38
- reject(new Error('Failed to load draw.io viewer'));
39
- };
40
-
41
- const handleLoad = () => {
42
- cleanup();
43
- if (!window.GraphViewer?.processElements) {
44
- drawioViewerLoadPromise = null;
45
- reject(new Error('draw.io viewer did not initialize'));
46
- return;
47
- }
48
-
49
- resolve(window.GraphViewer);
50
- };
51
-
52
- script.addEventListener('error', handleError, { once: true });
53
- script.addEventListener('load', handleLoad, { once: true });
54
-
55
- if (!existingScript) {
56
- script.src = DRAWIO_VIEWER_SCRIPT_URL;
57
- script.async = true;
58
- script.dataset.collabmdDrawioViewer = 'true';
59
- document.head.append(script);
60
- } else if (window.GraphViewer?.processElements) {
61
- handleLoad();
62
- }
63
- });
64
-
65
- return drawioViewerLoadPromise;
66
- }
67
12
 
68
13
  export class DrawioEmbedController {
69
14
  constructor({
@@ -350,59 +295,21 @@ export class DrawioEmbedController {
350
295
  }
351
296
 
352
297
  async renderViewerEntry(entry) {
353
- const [{ content }, viewer] = await Promise.all([
354
- this.vaultApiClient.readFile(entry.filePath),
355
- ensureDrawioViewerLoaded(),
356
- ]);
298
+ const { content } = await this.vaultApiClient.readFile(entry.filePath);
357
299
 
358
300
  if (!entry.wrapper?.isConnected || !entry.viewerElement?.isConnected) {
359
301
  return;
360
302
  }
361
303
 
362
304
  const theme = this.getTheme?.() === 'light' ? 'light' : 'dark';
363
- const graphElement = document.createElement('div');
364
- graphElement.className = 'mxgraph drawio-viewer-frame';
365
- graphElement.dataset.action = 'open-file';
366
- graphElement.dataset.filePath = entry.filePath;
367
- graphElement.setAttribute('role', 'button');
368
- graphElement.setAttribute('tabindex', '0');
369
- graphElement.setAttribute('aria-label', `Open ${entry.label.replace(/\.drawio$/i, '')}`);
370
- graphElement.addEventListener('click', (event) => {
371
- event.preventDefault();
372
- this.onOpenFile?.(entry.filePath);
373
- });
374
- graphElement.addEventListener('keydown', (event) => {
375
- if (event.key !== 'Enter' && event.key !== ' ') {
376
- return;
377
- }
378
-
379
- event.preventDefault();
380
- this.onOpenFile?.(entry.filePath);
381
- });
382
- graphElement.dataset.mxgraph = JSON.stringify({
383
- 'check-visible-state': false,
384
- center: true,
385
- border: 0,
386
- 'dark-mode': theme,
387
- editable: false,
388
- fit: 1,
389
- lightbox: false,
390
- nav: false,
391
- resize: false,
392
- tooltips: false,
393
- xml: String(content ?? ''),
394
- });
395
-
396
305
  const viewerHost = entry.wrapper.querySelector('.drawio-viewer-shell') ?? entry.viewerElement;
397
- viewerHost.replaceChildren(graphElement);
398
- entry.viewerElement = graphElement;
399
-
400
- if (typeof viewer.createViewerForElement === 'function') {
401
- viewer.createViewerForElement(graphElement);
402
- return;
403
- }
404
-
405
- viewer.processElements();
306
+ entry.viewerElement = await renderDrawioViewer(viewerHost, {
307
+ ariaLabel: `Open ${entry.label.replace(/\.drawio$/i, '')}`,
308
+ className: 'mxgraph drawio-viewer-frame',
309
+ onActivate: () => this.onOpenFile?.(entry.filePath),
310
+ source: String(content ?? ''),
311
+ theme,
312
+ });
406
313
  }
407
314
 
408
315
  buildIframeUrl(entry) {