collabmd 0.1.29 → 0.1.30

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 (166) hide show
  1. package/README.md +5 -0
  2. package/dist/client/assets/{CodeMirrorEditor-UCMRAMCV-9k1Shd4H.js → CodeMirrorEditor-UCMRAMCV-tj0_ycIx.js} +1 -1
  3. package/dist/client/assets/architectureDiagram-2XIMDMQ5-BPG5b65m.js +36 -0
  4. package/dist/client/assets/base-CaIEJUF9.css +1 -0
  5. package/dist/client/assets/{blockDiagram-WCTKOSBZ-0rR9ivwb.js → blockDiagram-WCTKOSBZ-F38TRzI3.js} +1 -1
  6. package/dist/client/assets/{chunk-ETSVUAFD-CN-gPl_j.js → chunk-AXZTUZBK-Cxfm7hmm.js} +1 -1
  7. package/dist/client/assets/{chunk-GLR3WWYH-ZnuaBvXp.js → chunk-GLR3WWYH-CQ1LryrI.js} +2 -2
  8. package/dist/client/assets/{chunk-NQ4KR5QH-BCjGNd0g.js → chunk-NQ4KR5QH-BBX89uUa.js} +1 -1
  9. package/dist/client/assets/chunk-NU2UP6EN-CGeKI3BV.js +35 -0
  10. package/dist/client/assets/{chunk-WL4C6EOR-OuH0lN6M.js → chunk-WL4C6EOR-zSV-8h7F.js} +1 -1
  11. package/dist/client/assets/classDiagram-VBA2DB6C-BQqG4q1b.js +1 -0
  12. package/dist/client/assets/classDiagram-v2-RAHNMMFH-BUKbprX1.js +1 -0
  13. package/dist/client/assets/dagre-KLK3FWXG-CAG8WeES.js +4 -0
  14. package/dist/client/assets/diagram-E7M64L7V-Bm5FD0I5.js +24 -0
  15. package/dist/client/assets/diagram-IFDJBPK2-pqicH-av.js +43 -0
  16. package/dist/client/assets/diagram-P4PSJMXO-DwIXO27H.js +24 -0
  17. package/dist/client/assets/dist-3bQB_we3.js +1 -0
  18. package/dist/client/assets/dist-3uAMa5w5.js +1 -0
  19. package/dist/client/assets/{dist-D9c0A4Uh.js → dist-B-02vKOA.js} +1 -1
  20. package/dist/client/assets/{dist-A0eSMIyy.js → dist-BLAlZer_.js} +1 -1
  21. package/dist/client/assets/dist-BVB8rn7o.js +1 -0
  22. package/dist/client/assets/{dist-DveOCUUJ.js → dist-Bc2ND6i3.js} +1 -1
  23. package/dist/client/assets/{dist-BNcU46Ik.js → dist-BqQjnEWr.js} +1 -1
  24. package/dist/client/assets/{dist-Bay3vk5z.js → dist-BtnXjRx0.js} +1 -1
  25. package/dist/client/assets/{dist-CEYpWxod.js → dist-BzbYwa8m.js} +1 -1
  26. package/dist/client/assets/{dist-Ct1bmQnL.js → dist-BzpfHRaE.js} +1 -1
  27. package/dist/client/assets/{dist-BbKpzsTB.js → dist-CGV1S_as.js} +1 -1
  28. package/dist/client/assets/dist-CL-jjdvG.js +1 -0
  29. package/dist/client/assets/{dist-D0e6Qsad.js → dist-CXpKYW9s.js} +1 -1
  30. package/dist/client/assets/{dist-BchrxlnW.js → dist-CjII8VDS.js} +1 -1
  31. package/dist/client/assets/{dist-DnrO-JZd.js → dist-CxfohGIV.js} +1 -1
  32. package/dist/client/assets/{dist-CMHIjMVe.js → dist-D5QOYy7y.js} +1 -1
  33. package/dist/client/assets/{dist-CEtbxXlI.js → dist-DIi0uqLJ.js} +1 -1
  34. package/dist/client/assets/dist-DJ16N7ol.js +1 -0
  35. package/dist/client/assets/dist-DUvaQ-Bj.js +1 -0
  36. package/dist/client/assets/{dist-DnxPQ0-w.js → dist-DWJL3_bF.js} +6 -6
  37. package/dist/client/assets/dist-DsL7sqZR.js +1 -0
  38. package/dist/client/assets/{dist-DbhQ5ANS.js → dist-Dyt7n5Ui.js} +1 -1
  39. package/dist/client/assets/{dist-BOrlJDrR.js → dist-E_jPb93e.js} +1 -1
  40. package/dist/client/assets/{dist-D-gRgZ6e.js → dist-O1z0Ayg0.js} +1 -1
  41. package/dist/client/assets/dist-OQE68wIX.js +1 -0
  42. package/dist/client/assets/dist-k47-Io7D.js +1 -0
  43. package/dist/client/assets/{dist-Cdzvi8cf.js → dist-lDhnP-K1.js} +1 -1
  44. package/dist/client/assets/{dist-DS6mEQYy.js → dist-rMhng5DK.js} +1 -1
  45. package/dist/client/assets/dist-xO1ImhKz.js +1 -0
  46. package/dist/client/assets/{drawio-editor-CgbaOvpF.js → drawio-editor-CTZtMo1J.js} +1 -1
  47. package/dist/client/assets/{drawioEditor-Eb2yYhjE.js → drawioEditor-keSJVTcv.js} +2 -2
  48. package/dist/client/assets/editor-session-C9l9XwVg.js +16 -0
  49. package/dist/client/assets/{embedded-editor-base-DXRHYz6R.css → embedded-editor-base-DgmRcsR8.css} +1 -1
  50. package/dist/client/assets/{en-CTDJDZ6K-BHbXrqU0.js → en-IQZW2TQM-CZeIyvu2.js} +1 -1
  51. package/dist/client/assets/{erDiagram-INFDFZHY-DP3f20BF.js → erDiagram-INFDFZHY-DlhPv4az.js} +1 -1
  52. package/dist/client/assets/{excalidraw-editor-Dj0c0PO6.js → excalidraw-editor-CBEWfuhb.js} +2 -2
  53. package/dist/client/assets/excalidraw-editor-Dj3i59LW.css +1 -0
  54. package/dist/client/assets/excalidraw-scene-BL-OtvCd.js +176 -0
  55. package/dist/client/assets/excalidrawEditor-Ws-fwGOc.js +2 -0
  56. package/dist/client/assets/{export-page-CUOMczdp.js → export-page-B6xYnwy7.js} +2 -2
  57. package/dist/client/assets/exportDocument-8JBw0p8O.js +2 -0
  58. package/dist/client/assets/{exportDocument-CLPYN_gg.css → exportDocument-CtX_4PSk.css} +1 -1
  59. package/dist/client/assets/{flowDiagram-PKNHOUZH-CRbK5_5h.js → flowDiagram-PKNHOUZH-BU4MOSq8.js} +1 -1
  60. package/dist/client/assets/ganttDiagram-A5KZAMGK-CbX67lZu.js +292 -0
  61. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-DhyvWcW1.js +65 -0
  62. package/dist/client/assets/image-XLNPCCAD-B4wZgh-Y.js +1 -0
  63. package/dist/client/assets/{index-CVgVzuh6.js → index-CywRKxsk.js} +2 -2
  64. package/dist/client/assets/index-R4OArFku.css +1 -0
  65. package/dist/client/assets/infoDiagram-LFFYTUFH-Bap6MyHX.js +2 -0
  66. package/dist/client/assets/{ishikawaDiagram-PHBUUO56-BDVPgkv4.js → ishikawaDiagram-PHBUUO56-DbftskqN.js} +1 -1
  67. package/dist/client/assets/{journeyDiagram-4ABVD52K-DtNnwL48.js → journeyDiagram-4ABVD52K-165YoX6B.js} +1 -1
  68. package/dist/client/assets/{jsx-runtime-B8dyIONw.js → jsx-runtime-DiaMmmG6.js} +1 -1
  69. package/dist/client/assets/{kanban-definition-K7BYSVSG-C565sUNo.js → kanban-definition-K7BYSVSG-MlixoNyx.js} +1 -1
  70. package/dist/client/assets/{main-C8adLQnt.js → main-CB97rY6B.js} +172 -133
  71. package/dist/client/assets/mermaid-parser.core-lf87rdG-.js +4 -0
  72. package/dist/client/assets/{mermaid-runtime-DqncQoYK.js → mermaid-runtime-C4a7TrTS.js} +3 -3
  73. package/dist/client/assets/{mindmap-definition-YRQLILUH-BbYzGfTD.js → mindmap-definition-YRQLILUH-_YbQSc4Q.js} +1 -1
  74. package/dist/client/assets/pieDiagram-SKSYHLDU-CqCgXFom.js +30 -0
  75. package/dist/client/assets/preview-render-compiler-TmHpBDlh.js +1 -0
  76. package/dist/client/assets/preview-render-compiler-aYpnXmNm.js +35 -0
  77. package/dist/client/assets/preview-render-worker-BVbVXXPS.js +59 -0
  78. package/dist/client/assets/{quadrantDiagram-337W2JSQ-DhlKLozd.js → quadrantDiagram-337W2JSQ-B8eMuhg4.js} +1 -1
  79. package/dist/client/assets/{requirementDiagram-Z7DCOOCP-B7ZOMsIb.js → requirementDiagram-Z7DCOOCP-B6POvqz4.js} +1 -1
  80. package/dist/client/assets/{room-B0UGbzQj.js → room-COWSP--i.js} +1 -1
  81. package/dist/client/assets/stateDiagram-RAJIS63D-DbewhqwJ.js +1 -0
  82. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-DbW-jVkS.js +1 -0
  83. package/dist/client/assets/{vennDiagram-LZ73GAT5-Cs9aQmVM.js → vennDiagram-LZ73GAT5-C1hrSbf2.js} +1 -1
  84. package/dist/client/assets/{xychartDiagram-JWTSCODW-B40ybbzC.js → xychartDiagram-JWTSCODW-DhpWp8LV.js} +1 -1
  85. package/dist/client/assets/{yjs-provider-reset-guard-DUP9XupO.js → yjs-provider-reset-guard-DWGhQClh.js} +1 -1
  86. package/dist/client/drawio-editor.html +1 -1
  87. package/dist/client/excalidraw-editor.html +1 -1
  88. package/dist/client/export-document.html +4 -4
  89. package/dist/client/index.html +4 -4
  90. package/docker-compose.yml +1 -0
  91. package/package.json +23 -21
  92. package/src/client/application/app-shell/ui-feature-shell.js +47 -1
  93. package/src/client/application/app-shell/ui-feature-toolbar.js +25 -3
  94. package/src/client/application/app-shell-elements.js +2 -0
  95. package/src/client/application/diagram-preview-hydrator.js +1 -1
  96. package/src/client/application/markdown-frontmatter.js +152 -0
  97. package/src/client/application/preview-render-compiler.js +10 -1
  98. package/src/client/application/preview-render-executor.js +8 -1
  99. package/src/client/application/preview-render-worker.js +4 -0
  100. package/src/client/application/preview-renderer.js +51 -1
  101. package/src/client/application/workspace-preview-controller.js +1 -0
  102. package/src/client/bootstrap/collabmd-app-shell.js +5 -0
  103. package/src/client/domain/markdown-formatting.js +1 -1
  104. package/src/client/domain/markdown-toolbar-actions.js +36 -0
  105. package/src/client/export/export-pipeline.js +3 -1
  106. package/src/client/infrastructure/editor-session.js +4 -0
  107. package/src/client/infrastructure/editor-view-adapter.js +36 -2
  108. package/src/client/infrastructure/excalidraw-room-client.js +12 -4
  109. package/src/client/presentation/app-shell-template.js +15 -0
  110. package/src/client/presentation/file-action-controller.js +1 -2
  111. package/src/client/presentation/git-diff-view-controller.js +7 -10
  112. package/src/client/styles/base.css +5 -0
  113. package/src/client/styles/features/preview-markdown.css +138 -0
  114. package/src/client/styles/layout/editor-page.css +6 -0
  115. package/src/client/styles/layout/responsive.css +89 -0
  116. package/src/client/styles/layout/shell.css +6 -0
  117. package/src/client/styles/overrides/codemirror.css +74 -0
  118. package/src/server/auth/create-auth-service.js +32 -6
  119. package/src/server/config/env.js +27 -0
  120. package/src/server/config/perf-logging.js +39 -0
  121. package/src/server/create-app-server.js +102 -5
  122. package/src/server/domain/backlink-index.js +31 -7
  123. package/src/server/domain/collaboration/collaboration-room.js +28 -5
  124. package/src/server/infrastructure/git/history-service.js +1 -1
  125. package/src/server/infrastructure/persistence/pull-backup-store.js +1 -1
  126. package/src/server/infrastructure/persistence/vault-file-store.js +74 -31
  127. package/src/server/infrastructure/workspace/file-system-sync-service.js +46 -12
  128. package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +32 -7
  129. package/src/server/shared/async-utils.js +25 -0
  130. package/dist/client/assets/architectureDiagram-2XIMDMQ5-Bhh8YQHn.js +0 -36
  131. package/dist/client/assets/base-B5gONpJK.css +0 -1
  132. package/dist/client/assets/chunk-27N2SPOM-DbHKsQ_j.js +0 -34
  133. package/dist/client/assets/classDiagram-VBA2DB6C-Bpdym3dj.js +0 -1
  134. package/dist/client/assets/classDiagram-v2-RAHNMMFH-CRAsdVDB.js +0 -1
  135. package/dist/client/assets/dagre-KLK3FWXG-CXSnjrX-.js +0 -4
  136. package/dist/client/assets/diagram-E7M64L7V-De0StFef.js +0 -24
  137. package/dist/client/assets/diagram-IFDJBPK2-B2m6AJbU.js +0 -43
  138. package/dist/client/assets/diagram-P4PSJMXO-YBWLVENw.js +0 -24
  139. package/dist/client/assets/dist-BUEzOS23.js +0 -1
  140. package/dist/client/assets/dist-BflL-_8D.js +0 -1
  141. package/dist/client/assets/dist-CI_qEj5K.js +0 -1
  142. package/dist/client/assets/dist-CtFNDttZ.js +0 -1
  143. package/dist/client/assets/dist-DS_4VpEd.js +0 -1
  144. package/dist/client/assets/dist-DwsFUbuB.js +0 -1
  145. package/dist/client/assets/dist-VCYAp958.js +0 -1
  146. package/dist/client/assets/dist-Yxt8txtM.js +0 -1
  147. package/dist/client/assets/dist-npxgF_6V.js +0 -1
  148. package/dist/client/assets/dist-u0zDRMQt.js +0 -1
  149. package/dist/client/assets/editor-session-DFzBn4RY.js +0 -16
  150. package/dist/client/assets/excalidraw-editor-Bat1FRoT.css +0 -1
  151. package/dist/client/assets/excalidraw-scene-CixfM-jL.js +0 -174
  152. package/dist/client/assets/excalidrawEditor-diK4VITg.js +0 -2
  153. package/dist/client/assets/exportDocument-BM4_f2n8.js +0 -2
  154. package/dist/client/assets/ganttDiagram-A5KZAMGK-R-AMIgxR.js +0 -292
  155. package/dist/client/assets/gitGraphDiagram-K3NZZRJ6-CIPit_Sh.js +0 -65
  156. package/dist/client/assets/image-32MOCVDX-BB6utPzr.js +0 -1
  157. package/dist/client/assets/index-BOOhEiLo.css +0 -1
  158. package/dist/client/assets/infoDiagram-LFFYTUFH-CF8jXFTm.js +0 -2
  159. package/dist/client/assets/mermaid-parser.core-By_5k2SF.js +0 -4
  160. package/dist/client/assets/pieDiagram-SKSYHLDU-CAU_gC7U.js +0 -30
  161. package/dist/client/assets/preview-render-compiler-CVfDHnqM.js +0 -1
  162. package/dist/client/assets/preview-render-compiler-CapHJDHT.js +0 -1
  163. package/dist/client/assets/preview-render-worker-wMS710Bv.js +0 -23
  164. package/dist/client/assets/stateDiagram-RAJIS63D-CPoSrNwn.js +0 -1
  165. package/dist/client/assets/stateDiagram-v2-FVOUBMTO-5GW0Y21a.js +0 -1
  166. /package/dist/client/assets/{base-r5Zg2n3k.js → base-h1uscjTi.js} +0 -0
@@ -29,7 +29,9 @@ function normalizeToolbarAction(action) {
29
29
 
30
30
  function renderToolbarButtons(actions) {
31
31
  return actions.map((item) => (
32
- `<button type="button" class="ui-icon-button ui-icon-button--toolbar ui-toolbar-action" data-markdown-action="${escapeHtml(item.action)}" aria-label="${escapeHtml(item.label)}" title="${escapeHtml(item.title)}">${item.icon}</button>`
32
+ `<button type="button" class="ui-icon-button ui-icon-button--toolbar ui-toolbar-action" ${item.commandId
33
+ ? `data-editor-command="${escapeHtml(item.commandId)}"`
34
+ : `data-markdown-action="${escapeHtml(item.action)}"`} aria-label="${escapeHtml(item.label)}" title="${escapeHtml(item.title)}">${item.icon}</button>`
33
35
  )).join('');
34
36
  }
35
37
 
@@ -80,7 +82,7 @@ function renderMarkdownToolbarMarkup(activeAction) {
80
82
  }
81
83
 
82
84
  return `
83
- <div class="markdown-toolbar-group" role="group" aria-label="${escapeHtml(group.groupLabel)}">
85
+ <div class="markdown-toolbar-group${group.mobileOnly ? ' markdown-toolbar-group--mobile-only' : ''}" role="group" aria-label="${escapeHtml(group.groupLabel)}">
84
86
  ${renderToolbarButtons(group.actions)}
85
87
  </div>
86
88
  `;
@@ -95,7 +97,7 @@ function renderMarkdownToolbarMarkup(activeAction) {
95
97
  * @property {{ refresh(): Promise<void> }} fileExplorer
96
98
  * @property {{ show(message: string): void }} toastController
97
99
  * @property {{ uploadImageAttachment(payload: { file: File, fileName: string, sourcePath: string }): Promise<{ markdown?: string, path?: string }>} } vaultApiClient
98
- * @property {{ applyMarkdownToolbarAction(action: string): boolean, insertText(text: string): void } | null} session
100
+ * @property {{ applyMarkdownToolbarAction(action: string): boolean, insertText(text: string): void, runEditorCommand?(commandId: string): boolean } | null} session
99
101
  * @property {() => Promise<File | null>} pickImageFile
100
102
  * @property {(file: File) => Promise<boolean>} handleEditorImageInsert
101
103
  * @property {() => Promise<void>} handleToolbarImageInsert
@@ -273,6 +275,14 @@ function handleMarkdownToolbarClick(event) {
273
275
  return;
274
276
  }
275
277
 
278
+ const editorCommandButton = target.closest('[data-editor-command]');
279
+ const editorCommandId = editorCommandButton?.getAttribute('data-editor-command');
280
+ if (editorCommandId) {
281
+ event.preventDefault();
282
+ this.runEditorCommand(editorCommandId);
283
+ return;
284
+ }
285
+
276
286
  const blockMenuToggle = target.closest('[data-markdown-block-menu-toggle]');
277
287
  if (blockMenuToggle) {
278
288
  event.preventDefault();
@@ -379,6 +389,17 @@ function applyMarkdownToolbarAction(action) {
379
389
  }
380
390
  }
381
391
 
392
+ /** @this {UiToolbarContext} */
393
+ function runEditorCommand(commandId) {
394
+ const ran = this.session?.runEditorCommand?.(commandId);
395
+ if (!ran) {
396
+ this.toastController.show('Editor action is unavailable');
397
+ return false;
398
+ }
399
+
400
+ return true;
401
+ }
402
+
382
403
  /** @this {UiToolbarContext} */
383
404
  async function handleToolbarImageInsert() {
384
405
  const file = await this.pickImageFile();
@@ -514,6 +535,7 @@ export const uiFeatureToolbarMethods = {
514
535
  renderMarkdownBlockMenuPopover,
515
536
  pickImageFile,
516
537
  renderMarkdownToolbar,
538
+ runEditorCommand,
517
539
  setActiveMarkdownBlockAction,
518
540
  syncMarkdownToolbarBlockUi,
519
541
  toggleMarkdownBlockMenu,
@@ -33,6 +33,7 @@ export function bindAppShellElements(doc = document) {
33
33
  displayNameSubmit: doc.getElementById('displayNameSubmit'),
34
34
  displayNameTitle: doc.getElementById('displayNameTitle'),
35
35
  editNameButton: doc.getElementById('editNameBtn'),
36
+ editorFindButton: doc.getElementById('editorFindBtn'),
36
37
  editorContainer: doc.getElementById('editorContainer'),
37
38
  exportDocxButton: doc.getElementById('exportDocxBtn'),
38
39
  exportMenuGroup: doc.getElementById('exportMenuGroup'),
@@ -52,6 +53,7 @@ export function bindAppShellElements(doc = document) {
52
53
  mobileViewToggle: doc.getElementById('mobileViewToggle'),
53
54
  previewContent: doc.getElementById('previewContent'),
54
55
  previewContainer: doc.getElementById('previewContainer'),
56
+ searchFilesButton: doc.getElementById('searchFilesBtn'),
55
57
  shareButton: doc.getElementById('shareBtn'),
56
58
  toastContainer: doc.getElementById('toastContainer'),
57
59
  toggleWrapButton: doc.getElementById('toggleWrapBtn'),
@@ -291,7 +291,7 @@ export class DiagramPreviewHydrator {
291
291
  this.hydrationInProgress = true;
292
292
  this.renderer.setPhase('hydrating');
293
293
 
294
- let batchContext = null;
294
+ let batchContext;
295
295
  try {
296
296
  batchContext = await this.prepareHydrationBatch(shells);
297
297
  } catch (error) {
@@ -0,0 +1,152 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ import { escapeHtml } from '../domain/vault-utils.js';
4
+
5
+ function getDocumentNewline(markdownText = '') {
6
+ return String(markdownText).includes('\r\n') ? '\r\n' : '\n';
7
+ }
8
+
9
+ function normalizeLine(line = '') {
10
+ return String(line).replace(/\r$/, '');
11
+ }
12
+
13
+ function isPlainObject(value) {
14
+ return Object.prototype.toString.call(value) === '[object Object]';
15
+ }
16
+
17
+ function isScalarValue(value) {
18
+ return (
19
+ value == null
20
+ || typeof value === 'string'
21
+ || typeof value === 'number'
22
+ || typeof value === 'boolean'
23
+ || value instanceof Date
24
+ );
25
+ }
26
+
27
+ function formatScalarValue(value) {
28
+ if (value instanceof Date) {
29
+ return value.toISOString();
30
+ }
31
+
32
+ if (value == null) {
33
+ return 'null';
34
+ }
35
+
36
+ return String(value);
37
+ }
38
+
39
+ function serializeComplexValue(value) {
40
+ return yaml.dump(value, {
41
+ lineWidth: -1,
42
+ noRefs: true,
43
+ }).trim();
44
+ }
45
+
46
+ function createFrontmatterValueMarkup(value) {
47
+ if (isScalarValue(value)) {
48
+ return `<span class="frontmatter-value-text">${escapeHtml(formatScalarValue(value))}</span>`;
49
+ }
50
+
51
+ if (Array.isArray(value) && value.every((item) => isScalarValue(item))) {
52
+ return `<div class="frontmatter-value-list">${value
53
+ .map((item) => `<span class="frontmatter-value-pill">${escapeHtml(formatScalarValue(item))}</span>`)
54
+ .join('')}</div>`;
55
+ }
56
+
57
+ return `<pre class="frontmatter-value-code"><code>${escapeHtml(serializeComplexValue(value))}</code></pre>`;
58
+ }
59
+
60
+ function createFrontmatterEntries(data) {
61
+ if (isPlainObject(data)) {
62
+ return Object.entries(data);
63
+ }
64
+
65
+ return [['value', data]];
66
+ }
67
+
68
+ function createHiddenSummary(entries) {
69
+ if (entries.length === 0) {
70
+ return 'No properties';
71
+ }
72
+
73
+ return `${entries.length} propert${entries.length === 1 ? 'y' : 'ies'} hidden`;
74
+ }
75
+
76
+ export function extractYamlFrontmatter(markdownText = '') {
77
+ const normalizedMarkdown = String(markdownText ?? '');
78
+ if (!normalizedMarkdown.startsWith('---')) {
79
+ return null;
80
+ }
81
+
82
+ const newline = getDocumentNewline(normalizedMarkdown);
83
+ const lines = normalizedMarkdown.split(/\r?\n/);
84
+ if (normalizeLine(lines[0]) !== '---') {
85
+ return null;
86
+ }
87
+
88
+ let closingIndex = -1;
89
+ for (let index = 1; index < lines.length; index += 1) {
90
+ if (normalizeLine(lines[index]) === '---') {
91
+ closingIndex = index;
92
+ break;
93
+ }
94
+ }
95
+
96
+ if (closingIndex < 0) {
97
+ return null;
98
+ }
99
+
100
+ const rawFrontmatter = lines.slice(0, closingIndex + 1).join(newline);
101
+ const frontmatterSource = lines.slice(1, closingIndex).join(newline);
102
+
103
+ let parsedData;
104
+ try {
105
+ parsedData = yaml.load(frontmatterSource || '{}');
106
+ } catch {
107
+ return null;
108
+ }
109
+
110
+ const lineCount = closingIndex + 1;
111
+ const bodyMarkdown = [
112
+ ...Array.from({ length: lineCount }, () => ''),
113
+ ...lines.slice(closingIndex + 1),
114
+ ].join(newline);
115
+
116
+ return {
117
+ bodyMarkdown,
118
+ data: parsedData ?? {},
119
+ endLine: lineCount,
120
+ rawFrontmatter,
121
+ startLine: 1,
122
+ };
123
+ }
124
+
125
+ export function renderFrontmatterBlock(frontmatter, {
126
+ collapsed = false,
127
+ interactive = false,
128
+ } = {}) {
129
+ if (!frontmatter) {
130
+ return '';
131
+ }
132
+
133
+ const entries = createFrontmatterEntries(frontmatter.data);
134
+ const collapsedState = interactive && collapsed;
135
+ const sourceAttributes = ` data-source-line="${frontmatter.startLine}" data-source-line-end="${frontmatter.endLine}"${interactive ? ` data-collapsed="${collapsedState ? 'true' : 'false'}"` : ''}`;
136
+ const bodyId = `frontmatter-body-${frontmatter.startLine}-${frontmatter.endLine}`;
137
+ const emptyState = entries.length === 0
138
+ ? '<div class="frontmatter-empty">No properties</div>'
139
+ : '';
140
+ const items = entries.map(([key, value]) => (
141
+ `<div class="frontmatter-property"><dt class="frontmatter-key">${escapeHtml(key)}</dt><dd class="frontmatter-value">${createFrontmatterValueMarkup(value)}</dd></div>`
142
+ )).join('');
143
+ const header = interactive
144
+ ? `<div class="frontmatter-header"><div class="frontmatter-label">Properties</div><button type="button" class="frontmatter-toggle" aria-controls="${bodyId}" aria-expanded="${collapsedState ? 'false' : 'true'}">${collapsedState ? 'Show' : 'Hide'}</button></div>`
145
+ : '<div class="frontmatter-label">Properties</div>';
146
+ const summary = interactive
147
+ ? `<div class="frontmatter-summary"${collapsedState ? '' : ' hidden'}>${escapeHtml(createHiddenSummary(entries))}</div>`
148
+ : '';
149
+ const bodyAttributes = ` class="frontmatter-content"${interactive ? ` id="${bodyId}"` : ''}${collapsedState ? ' hidden' : ''}`;
150
+
151
+ return `<section class="frontmatter-block"${sourceAttributes}>${header}${summary}<div${bodyAttributes}><dl class="frontmatter-properties">${items}</dl>${emptyState}</div></section>`;
152
+ }
@@ -3,6 +3,7 @@ import hljs from 'highlight.js';
3
3
 
4
4
  import { isImageAttachmentFilePath } from '../../domain/file-kind.js';
5
5
  import { escapeHtml, resolveVaultRelativePath, resolveWikiTarget } from '../domain/vault-utils.js';
6
+ import { extractYamlFrontmatter, renderFrontmatterBlock } from './markdown-frontmatter.js';
6
7
  import { analyzeMarkdownComplexity } from './preview-render-profile.js';
7
8
 
8
9
  const DIRECT_VIDEO_MIME_TYPES = Object.freeze({
@@ -480,17 +481,25 @@ function createMarkdownRenderer(fileList = [], {
480
481
  export function compilePreviewDocument({
481
482
  attachmentApiPath = '/api/attachment',
482
483
  fileList = [],
484
+ frontmatterCollapsed = false,
485
+ frontmatterInteractive = false,
483
486
  markdownText = '',
484
487
  sourceFilePath = '',
485
488
  } = {}) {
486
489
  const normalizedMarkdown = String(markdownText);
490
+ const frontmatter = extractYamlFrontmatter(normalizedMarkdown);
487
491
  const renderer = createMarkdownRenderer(fileList, {
488
492
  attachmentApiPath,
489
493
  sourceFilePath,
490
494
  });
495
+ const renderedMarkdown = frontmatter ? frontmatter.bodyMarkdown : normalizedMarkdown;
496
+ const frontmatterHtml = renderFrontmatterBlock(frontmatter, {
497
+ collapsed: frontmatterCollapsed,
498
+ interactive: frontmatterInteractive,
499
+ });
491
500
 
492
501
  return {
493
- html: renderer.render(normalizedMarkdown),
502
+ html: `${frontmatterHtml}${renderer.render(renderedMarkdown)}`,
494
503
  stats: analyzeMarkdownComplexity(normalizedMarkdown),
495
504
  };
496
505
  }
@@ -97,7 +97,10 @@ export class PreviewRenderExecutor {
97
97
  }
98
98
  }
99
99
 
100
- async compile(markdownText, renderVersion) {
100
+ async compile(markdownText, renderVersion, {
101
+ frontmatterCollapsed = false,
102
+ frontmatterInteractive = false,
103
+ } = {}) {
101
104
  const worker = this.ensureWorker();
102
105
 
103
106
  if (worker) {
@@ -111,6 +114,8 @@ export class PreviewRenderExecutor {
111
114
  activeWorker.postMessage({
112
115
  attachmentApiPath: this.attachmentApiPath,
113
116
  fileList: this.getFileList?.() ?? [],
117
+ frontmatterCollapsed,
118
+ frontmatterInteractive,
114
119
  markdownText,
115
120
  renderVersion,
116
121
  sourceFilePath: this.getSourceFilePath?.() ?? '',
@@ -122,6 +127,8 @@ export class PreviewRenderExecutor {
122
127
  return compilePreviewDocument({
123
128
  attachmentApiPath: this.attachmentApiPath,
124
129
  fileList: this.getFileList?.() ?? [],
130
+ frontmatterCollapsed,
131
+ frontmatterInteractive,
125
132
  markdownText,
126
133
  sourceFilePath: this.getSourceFilePath?.() ?? '',
127
134
  });
@@ -4,6 +4,8 @@ self.onmessage = (event) => {
4
4
  const {
5
5
  attachmentApiPath,
6
6
  fileList,
7
+ frontmatterCollapsed,
8
+ frontmatterInteractive,
7
9
  markdownText,
8
10
  renderVersion,
9
11
  sourceFilePath,
@@ -13,6 +15,8 @@ self.onmessage = (event) => {
13
15
  const result = compilePreviewDocument({
14
16
  attachmentApiPath,
15
17
  fileList,
18
+ frontmatterCollapsed,
19
+ frontmatterInteractive,
16
20
  markdownText,
17
21
  sourceFilePath,
18
22
  });
@@ -13,6 +13,7 @@ export class PreviewRenderer {
13
13
  getSourceFilePath,
14
14
  onAfterRenderCommit,
15
15
  onBeforeRenderCommit,
16
+ onPreviewLayoutChange,
16
17
  onRenderComplete,
17
18
  outlineController,
18
19
  previewContainer,
@@ -23,6 +24,7 @@ export class PreviewRenderer {
23
24
  this.getSourceFilePath = getSourceFilePath;
24
25
  this.onAfterRenderCommit = onAfterRenderCommit;
25
26
  this.onBeforeRenderCommit = onBeforeRenderCommit;
27
+ this.onPreviewLayoutChange = onPreviewLayoutChange;
26
28
  this.onRenderComplete = onRenderComplete;
27
29
  this.outlineController = outlineController;
28
30
  this.previewContainer = previewContainer;
@@ -35,8 +37,18 @@ export class PreviewRenderer {
35
37
  this.currentStats = null;
36
38
  this.isLargeDocument = false;
37
39
  this.hydrationPaused = false;
40
+ this.frontmatterCollapsed = false;
38
41
 
39
42
  this.handlePreviewClick = (event) => {
43
+ const frontmatterToggle = event.target.closest('.frontmatter-toggle');
44
+ if (frontmatterToggle) {
45
+ event.preventDefault();
46
+ this.frontmatterCollapsed = !this.frontmatterCollapsed;
47
+ this.applyFrontmatterState();
48
+ this.handleFrontmatterLayoutChange();
49
+ return;
50
+ }
51
+
40
52
  const mermaidButton = event.target.closest('.mermaid-placeholder-btn');
41
53
  if (mermaidButton) {
42
54
  const shell = mermaidButton.closest('.mermaid-shell');
@@ -134,6 +146,7 @@ export class PreviewRenderer {
134
146
  this.readyRenderVersion = 0;
135
147
  this.currentStats = null;
136
148
  this.isLargeDocument = false;
149
+ this.frontmatterCollapsed = false;
137
150
  if (this.renderExecutor.hasPendingJob()) {
138
151
  this.resetWorker('Document changed');
139
152
  }
@@ -272,7 +285,10 @@ export class PreviewRenderer {
272
285
  }
273
286
 
274
287
  async compilePreview(markdownText, renderVersion) {
275
- return this.renderExecutor.compile(markdownText, renderVersion);
288
+ return this.renderExecutor.compile(markdownText, renderVersion, {
289
+ frontmatterCollapsed: this.frontmatterCollapsed,
290
+ frontmatterInteractive: true,
291
+ });
276
292
  }
277
293
 
278
294
  commitBaseRender({ html, stats }, renderVersion) {
@@ -294,6 +310,7 @@ export class PreviewRenderer {
294
310
  if (renderHost) {
295
311
  renderHost.innerHTML = html;
296
312
  }
313
+ this.applyFrontmatterState();
297
314
  this.reconcileHydratedMermaids();
298
315
  this.reconcileHydratedPlantUmls();
299
316
  this.setPhase('base');
@@ -313,6 +330,39 @@ export class PreviewRenderer {
313
330
  }
314
331
  }
315
332
 
333
+ applyFrontmatterState() {
334
+ const block = this.renderHost?.querySelector?.('.frontmatter-block');
335
+ if (!(block instanceof HTMLElement)) {
336
+ return;
337
+ }
338
+
339
+ const collapsed = this.frontmatterCollapsed;
340
+ const toggle = block.querySelector('.frontmatter-toggle');
341
+ const summary = block.querySelector('.frontmatter-summary');
342
+ const content = block.querySelector('.frontmatter-content');
343
+
344
+ block.dataset.collapsed = collapsed ? 'true' : 'false';
345
+ if (toggle instanceof HTMLButtonElement) {
346
+ toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
347
+ toggle.textContent = collapsed ? 'Show' : 'Hide';
348
+ }
349
+ if (summary instanceof HTMLElement) {
350
+ summary.hidden = !collapsed;
351
+ }
352
+ if (content instanceof HTMLElement) {
353
+ content.hidden = collapsed;
354
+ }
355
+ }
356
+
357
+ handleFrontmatterLayoutChange() {
358
+ this.outlineController.refresh();
359
+ this.onPreviewLayoutChange?.({
360
+ isLargeDocument: this.isLargeDocument,
361
+ renderVersion: this.activeRenderVersion,
362
+ stats: this.currentStats,
363
+ });
364
+ }
365
+
316
366
  preserveHydratedMermaidsForCommit() {
317
367
  this.mermaidHydrator.preserveHydratedShellsForCommit();
318
368
  }
@@ -92,6 +92,7 @@ export class WorkspacePreviewController {
92
92
  const isPlantUml = this.isPlantUmlFile(filePath);
93
93
  const isDiagramFile = isDiagramFilePath(filePath);
94
94
 
95
+ this.elements.editorFindButton?.classList.toggle('hidden', !isMarkdown);
95
96
  this.elements.markdownToolbar?.classList.toggle('hidden', !isMarkdown);
96
97
  this.elements.exportMenuGroup?.classList.toggle('hidden', !isMarkdown);
97
98
  this.elements.outlineToggle?.classList.toggle('hidden', isDiagramFile || isImage);
@@ -212,6 +212,11 @@ export class CollabMdAppShell {
212
212
  this.drawioEmbed.detachForCommit();
213
213
  this.excalidrawEmbed.detachForCommit();
214
214
  },
215
+ onPreviewLayoutChange: () => {
216
+ this.scrollSyncController.invalidatePreviewBlocks();
217
+ this.schedulePreviewLayoutSync({ delayMs: 0 });
218
+ this.refreshCommentUiLayout();
219
+ },
215
220
  onRenderComplete: () => {
216
221
  this.videoEmbed.syncLayout();
217
222
  this.drawioEmbed.syncLayout();
@@ -434,7 +434,7 @@ function formatHorizontalRule(text, range) {
434
434
  export function createMarkdownToolbarEdit(documentText, selectionRange, action) {
435
435
  const text = String(documentText ?? '');
436
436
  const range = normalizeRange(selectionRange, text.length);
437
- let edit = null;
437
+ let edit;
438
438
 
439
439
  switch (action) {
440
440
  case 'bold':
@@ -59,6 +59,34 @@ const ICONS = Object.freeze({
59
59
  <path d="M9 16h6"/>
60
60
  </svg>
61
61
  `,
62
+ indent: `
63
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64
+ <path d="M4 6h16"/>
65
+ <path d="M10 12h10"/>
66
+ <path d="M10 18h10"/>
67
+ <path d="m4 9 4 3-4 3"/>
68
+ </svg>
69
+ `,
70
+ outdent: `
71
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
72
+ <path d="M4 6h16"/>
73
+ <path d="M10 12h10"/>
74
+ <path d="M10 18h10"/>
75
+ <path d="m8 9-4 3 4 3"/>
76
+ </svg>
77
+ `,
78
+ redo: `
79
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
80
+ <path d="M21 7v6h-6"/>
81
+ <path d="M20 13a8 8 0 0 0-14-3"/>
82
+ </svg>
83
+ `,
84
+ undo: `
85
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
86
+ <path d="M3 7v6h6"/>
87
+ <path d="M4 13a8 8 0 0 1 14-3"/>
88
+ </svg>
89
+ `,
62
90
  chevronDown: `
63
91
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
64
92
  <path d="m6 9 6 6 6-6"/>
@@ -148,6 +176,13 @@ const INLINE_ACTIONS = Object.freeze([
148
176
  { action: 'code', label: 'Inline code', title: 'Inline code', icon: ICONS.code },
149
177
  ]);
150
178
 
179
+ const MOBILE_EDIT_COMMANDS = Object.freeze([
180
+ { commandId: 'undo', label: 'Undo', title: 'Undo', icon: ICONS.undo },
181
+ { commandId: 'redo', label: 'Redo', title: 'Redo', icon: ICONS.redo },
182
+ { commandId: 'indentMore', label: 'Indent', title: 'Indent', icon: ICONS.indent },
183
+ { commandId: 'indentLess', label: 'Outdent', title: 'Outdent', icon: ICONS.outdent },
184
+ ]);
185
+
151
186
  const MEDIA_ACTIONS = Object.freeze([
152
187
  { action: 'link', label: 'Insert link', title: 'Insert link', icon: ICONS.link },
153
188
  { action: 'image', label: 'Insert image', title: 'Insert image', icon: ICONS.image },
@@ -160,6 +195,7 @@ const INSERT_ACTIONS = Object.freeze([
160
195
  ]);
161
196
 
162
197
  export const markdownToolbarLayout = Object.freeze([
198
+ { kind: 'buttons', groupLabel: 'Edit', actions: MOBILE_EDIT_COMMANDS, mobileOnly: true },
163
199
  { kind: 'block-menu', actions: BLOCK_MENU_ACTIONS },
164
200
  { kind: 'buttons', groupLabel: 'Inline formatting', actions: INLINE_ACTIONS },
165
201
  { kind: 'buttons', groupLabel: 'Media and links', actions: MEDIA_ACTIONS },
@@ -217,7 +217,9 @@ async function fetchAsDataUrl(src, {
217
217
  });
218
218
  } catch (error) {
219
219
  if (error instanceof DOMException && error.name === 'AbortError') {
220
- throw new Error(`Timed out while fetching asset: ${assetUrl.toString()}`);
220
+ throw new Error(`Timed out while fetching asset: ${assetUrl.toString()}`, {
221
+ cause: error,
222
+ });
221
223
  }
222
224
  throw error;
223
225
  } finally {
@@ -258,6 +258,10 @@ export class EditorSession {
258
258
  this.viewAdapter.requestMeasure();
259
259
  }
260
260
 
261
+ runEditorCommand(commandId) {
262
+ return this.viewAdapter.runEditorCommand(commandId);
263
+ }
264
+
261
265
  applyMarkdownToolbarAction(action) {
262
266
  return this.viewAdapter.applyMarkdownToolbarAction(action);
263
267
  }
@@ -1,5 +1,14 @@
1
1
  import { autocompletion, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
2
- import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
2
+ import {
3
+ defaultKeymap,
4
+ history,
5
+ historyKeymap,
6
+ indentLess,
7
+ indentMore,
8
+ indentWithTab,
9
+ redo,
10
+ undo,
11
+ } from '@codemirror/commands';
3
12
  import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
4
13
  import {
5
14
  bracketMatching,
@@ -10,7 +19,7 @@ import {
10
19
  syntaxHighlighting,
11
20
  } from '@codemirror/language';
12
21
  import { languages } from '@codemirror/language-data';
13
- import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
22
+ import { highlightSelectionMatches, openSearchPanel, searchKeymap } from '@codemirror/search';
14
23
  import { Compartment, EditorSelection, EditorState, Prec, StateEffect, StateField } from '@codemirror/state';
15
24
  import { oneDark } from '@codemirror/theme-one-dark';
16
25
  import {
@@ -42,6 +51,13 @@ const REMOTE_UPDATE_FLASH_DURATION_MS = 1350;
42
51
  const REMOTE_UPDATE_CARET_MAX_LENGTH = 160;
43
52
  const RECENT_LOCAL_INPUT_WINDOW_MS = 900;
44
53
  const TASK_LIST_MARKER_PATTERN = /^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[)( |x|X)(\])(\s.*)?$/;
54
+ const EDITOR_COMMANDS = Object.freeze({
55
+ indentLess,
56
+ indentMore,
57
+ openSearch: openSearchPanel,
58
+ redo,
59
+ undo,
60
+ });
45
61
 
46
62
  class RemoteUpdateCaretWidget extends WidgetType {
47
63
  toDOM() {
@@ -557,6 +573,24 @@ export class EditorViewAdapter {
557
573
  this.editorView?.requestMeasure();
558
574
  }
559
575
 
576
+ runEditorCommand(commandId) {
577
+ if (!this.editorView) {
578
+ return false;
579
+ }
580
+
581
+ const command = EDITOR_COMMANDS[String(commandId ?? '')];
582
+ if (typeof command !== 'function') {
583
+ return false;
584
+ }
585
+
586
+ const applied = command(this.editorView);
587
+ if (applied && commandId !== 'openSearch') {
588
+ this.editorView.focus();
589
+ }
590
+
591
+ return applied;
592
+ }
593
+
560
594
  flashRemoteRange({ from = 0, to = 0 } = {}, durationMs = REMOTE_UPDATE_FLASH_DURATION_MS) {
561
595
  const state = this.editorView?.state;
562
596
  if (!state || state.doc.length === 0) {
@@ -258,10 +258,14 @@ export class ExcalidrawRoomClient {
258
258
  } catch (readError) {
259
259
  if (readError?.status !== 404 || !createIfMissing) {
260
260
  if (readError?.status === 404) {
261
- throw new Error(readError.message || 'Excalidraw file not found');
261
+ throw new Error(readError.message || 'Excalidraw file not found', {
262
+ cause: readError,
263
+ });
262
264
  }
263
265
 
264
- throw new Error(readError?.message || 'Failed to load Excalidraw file');
266
+ throw new Error(readError?.message || 'Failed to load Excalidraw file', {
267
+ cause: readError,
268
+ });
265
269
  }
266
270
  }
267
271
 
@@ -278,11 +282,15 @@ export class ExcalidrawRoomClient {
278
282
  const existingData = await this.vaultClient.readFile(this.filePath);
279
283
  return parseSceneJson(existingData.content);
280
284
  } catch (conflictReadError) {
281
- throw new Error(conflictReadError?.message || 'Failed to load existing Excalidraw file after create conflict');
285
+ throw new Error(conflictReadError?.message || 'Failed to load existing Excalidraw file after create conflict', {
286
+ cause: conflictReadError,
287
+ });
282
288
  }
283
289
  }
284
290
 
285
- throw new Error(createError?.message || 'Failed to create Excalidraw file');
291
+ throw new Error(createError?.message || 'Failed to create Excalidraw file', {
292
+ cause: createError,
293
+ });
286
294
  }
287
295
  }
288
296