collabmd 0.1.47 → 0.1.48

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 (100) hide show
  1. package/README.md +9 -6
  2. package/dist/client/assets/JetBrainsMono-Regular-BQaDgvhP.woff2 +0 -0
  3. package/dist/client/assets/babel-CY5KOv-I.js +16 -0
  4. package/dist/client/assets/comment-ui-shared-DuoyznXW.js +7 -0
  5. package/dist/client/assets/dist-ChRoo3IB.js +9 -0
  6. package/dist/client/assets/document-formatter-Dxl_a8XO.js +2 -0
  7. package/dist/client/assets/{drawio-editor-BjrxTe3l.js → drawio-editor-BVaNNtYp.js} +2 -2
  8. package/dist/client/assets/{drawioEditor-DUVZjSSr.js → drawioEditor-BkIBlXx0.js} +2 -2
  9. package/dist/client/assets/editor-session-DMyVdHLj.js +54 -0
  10. package/dist/client/assets/embedded-editor-base-B3l6Xy65.css +1 -0
  11. package/dist/client/assets/estree-CuT_pB8K.js +44 -0
  12. package/dist/client/assets/{excalidraw-editor-BlcN6umJ.css → excalidraw-editor-BbSbI80a.css} +1 -1
  13. package/dist/client/assets/excalidraw-editor-BjYaNEMY.js +9 -0
  14. package/dist/client/assets/{excalidrawEditor-B0jOK3lv.js → excalidrawEditor-ChqF_d82.js} +2 -2
  15. package/dist/client/assets/export-page-Dyu0XCbu.js +57 -0
  16. package/dist/client/assets/exportDocument-CJBx4Xjx.css +1 -0
  17. package/dist/client/assets/{exportDocument-Dg9MjkIk.js → exportDocument-Dgvfa2Sd.js} +2 -2
  18. package/dist/client/assets/{file-icon-svg-CQAjlWjK.js → file-icon-svg-Y_kpF957.js} +1 -1
  19. package/dist/client/assets/file-kind-BwMIcZbi.js +1 -0
  20. package/dist/client/assets/file-search-DDLyyLDU.js +1 -0
  21. package/dist/client/assets/html-Cdnr2qzU.js +25 -0
  22. package/dist/client/assets/index-CSNIMyOP.css +1 -0
  23. package/dist/client/assets/index-DG3N2Ps-.js +2 -0
  24. package/dist/client/assets/main-D0wCxpVn.js +903 -0
  25. package/dist/client/assets/markdown-CByV-4CU.js +70 -0
  26. package/dist/client/assets/mermaid.core-BJRLUQZE.js +11 -0
  27. package/dist/client/assets/{preview-render-compiler-BApvlLLo.js → preview-render-compiler-CrJIooVD.js} +2 -2
  28. package/dist/client/assets/preview-render-compiler-O0JxBVNF.js +1 -0
  29. package/dist/client/assets/{preview-render-profile-CE-5rVTC.js → preview-render-profile-B4RWYWHt.js} +1 -1
  30. package/dist/client/assets/{preview-render-worker-BmN79bVc.js → preview-render-worker-_QTxqKvP.js} +22 -22
  31. package/dist/client/assets/quick-switcher-controller-CaEjEtq_.js +1 -0
  32. package/dist/client/assets/standalone-6z5x8CWe.js +29 -0
  33. package/dist/client/assets/{vault-api-client-_LpV1aIn.js → vault-api-client-BkwdFjx0.js} +2 -2
  34. package/dist/client/assets/{es-CAsWR6hG.js → vault-utils-DvoSftJT.js} +1 -1
  35. package/dist/client/assets/yaml-9ltHPDNO.js +154 -0
  36. package/dist/client/drawio-editor.html +1 -1
  37. package/dist/client/excalidraw-editor.html +1 -1
  38. package/dist/client/export-document.html +2 -2
  39. package/dist/client/index.html +41 -19
  40. package/package.json +4 -1
  41. package/src/client/app/index.html +39 -17
  42. package/src/client/application/app-shell/export-feature.js +17 -1
  43. package/src/client/application/app-shell/git-feature.js +28 -9
  44. package/src/client/application/app-shell/ui-feature-identity.js +13 -0
  45. package/src/client/application/app-shell/ui-feature-shell.js +164 -9
  46. package/src/client/application/app-shell-elements.js +4 -0
  47. package/src/client/application/workspace-coordinator.js +21 -4
  48. package/src/client/application/workspace-preview-controller.js +120 -2
  49. package/src/client/application/workspace-route-controller.js +4 -1
  50. package/src/client/assets/vendor/jetbrains-mono/JetBrainsMono-Regular.woff2 +0 -0
  51. package/src/client/assets/vendor/jetbrains-mono/OFL.txt +93 -0
  52. package/src/client/bootstrap/collabmd-app-shell.js +21 -5
  53. package/src/client/domain/document-formatter.js +193 -0
  54. package/src/client/domain/file-search.js +73 -0
  55. package/src/client/domain/git-labels.js +5 -0
  56. package/src/client/domain/vault-paths.js +6 -4
  57. package/src/client/domain/wiki-link-completions.js +20 -20
  58. package/src/client/drawio-editor.js +8 -3
  59. package/src/client/excalidraw-editor.js +152 -6
  60. package/src/client/export/export-host.js +56 -35
  61. package/src/client/export/export-page.js +14 -4
  62. package/src/client/export/export-pipeline.js +266 -25
  63. package/src/client/infrastructure/editor-session.js +4 -0
  64. package/src/client/infrastructure/editor-view-adapter.js +60 -14
  65. package/src/client/presentation/bases-preview-controller.js +35 -11
  66. package/src/client/presentation/file-action-controller.js +15 -2
  67. package/src/client/presentation/file-explorer-controller.js +3 -0
  68. package/src/client/presentation/file-explorer-view.js +2 -1
  69. package/src/client/presentation/file-history-view-controller.js +6 -1
  70. package/src/client/presentation/file-tree-state.js +1 -11
  71. package/src/client/presentation/git-diff-view-controller.js +36 -11
  72. package/src/client/presentation/git-panel-controller.js +10 -6
  73. package/src/client/presentation/quick-switcher-controller.js +54 -154
  74. package/src/client/styles/components/scrollbars.css +10 -0
  75. package/src/client/styles/export-document.css +44 -29
  76. package/src/client/styles/features/collaboration-presence.css +13 -1
  77. package/src/client/styles/features/preview-markdown.css +54 -0
  78. package/src/client/styles/foundation/tokens.css +8 -0
  79. package/src/client/styles/layout/shell.css +76 -24
  80. package/src/client/styles/layout/view-modes.css +2 -0
  81. package/src/client/styles/overrides/codemirror.css +31 -19
  82. package/src/client/styles/surfaces/excalidraw-editor.css +42 -0
  83. package/src/domain/file-kind.js +12 -1
  84. package/src/server/domain/ripgrep-search-service.js +110 -37
  85. package/src/server/infrastructure/persistence/path-utils.js +10 -4
  86. package/src/server/infrastructure/persistence/vault-file-store.js +2 -0
  87. package/dist/client/assets/comment-ui-shared-DWkk9Vh3.js +0 -7
  88. package/dist/client/assets/editor-session-DfZuRgB_.js +0 -54
  89. package/dist/client/assets/embedded-editor-base-DvMynhKu.css +0 -1
  90. package/dist/client/assets/excalidraw-editor-D7pnUCLH.js +0 -9
  91. package/dist/client/assets/export-page-Bs3xE1fg.js +0 -57
  92. package/dist/client/assets/exportDocument-PnYU3v1s.css +0 -1
  93. package/dist/client/assets/file-kind-BbqgGzpc.js +0 -1
  94. package/dist/client/assets/index-BIpgxyGZ.css +0 -1
  95. package/dist/client/assets/index-Z5sI0Bj-.js +0 -2
  96. package/dist/client/assets/main-Dwnj9Gaq.js +0 -903
  97. package/dist/client/assets/mermaid.core-CLZBlpIW.js +0 -11
  98. package/dist/client/assets/preview-render-compiler-BQ_3h9vD.js +0 -1
  99. package/dist/client/assets/quick-switcher-controller-ButWPmCI.js +0 -12
  100. package/dist/client/assets/vault-utils-wdQ0RNR9.js +0 -1
@@ -1,4 +1,4 @@
1
- import { supportsBacklinksForFilePath } from '../../../domain/file-kind.js';
1
+ import { getVaultFileKind, supportsBacklinksForFilePath } from '../../../domain/file-kind.js';
2
2
  import { isPlainQuickSwitcherShortcut } from '../../domain/keyboard-shortcuts.js';
3
3
  import { createFileRouteHash, isCollabMdHashRoute } from '../../domain/hash-routes.js';
4
4
 
@@ -189,6 +189,13 @@ function bindToolbarEvents() {
189
189
  this.closeToolbarOverflowMenu?.();
190
190
  });
191
191
 
192
+ this.elements.reviewFileChangesButton?.addEventListener('click', () => {
193
+ this.handleGitDiffSelection(this.currentFilePath, {
194
+ closeSidebarOnMobile: true,
195
+ scope: 'all',
196
+ });
197
+ });
198
+
192
199
  this.elements.fileHistoryButton?.addEventListener('click', () => {
193
200
  const route = this.navigation.getHashRoute();
194
201
  if (route.type === 'git-file-preview') {
@@ -210,7 +217,7 @@ function bindToolbarEvents() {
210
217
  /** @this {UiShellContext} */
211
218
  function bindDialogEvents() {
212
219
  this.elements.displayNameCancel?.addEventListener('click', () => {
213
- this.elements.displayNameDialog?.close();
220
+ this.handleDisplayNameCancel();
214
221
  });
215
222
 
216
223
  this.elements.gitCommitCancel?.addEventListener('click', () => {
@@ -230,7 +237,7 @@ function bindDialogEvents() {
230
237
  this.elements.gitCommitInput.value = '';
231
238
  }
232
239
  if (this.elements.gitCommitSubmit) {
233
- this.elements.gitCommitSubmit.textContent = 'Commit staged changes';
240
+ this.elements.gitCommitSubmit.textContent = 'Commit included files';
234
241
  }
235
242
  });
236
243
 
@@ -255,6 +262,19 @@ function bindDialogEvents() {
255
262
  });
256
263
  }
257
264
 
265
+ /** @this {UiShellContext} */
266
+ function focusEditor(event) {
267
+ event?.preventDefault?.();
268
+ const container = this.elements.editorContainer;
269
+ if (!container) return;
270
+
271
+ const target = container.querySelector('.cm-content, iframe, [contenteditable="true"]') ?? container;
272
+ if (target === container && !container.hasAttribute('tabindex')) {
273
+ container.setAttribute('tabindex', '-1');
274
+ }
275
+ target.focus();
276
+ }
277
+
258
278
  /** @this {UiShellContext} */
259
279
  function bindEvents() {
260
280
  const stopFollowing = () => this.stopFollowingUser?.();
@@ -263,6 +283,7 @@ function bindEvents() {
263
283
  this.elements.editorContainer?.addEventListener('wheel', stopFollowing, { passive: true });
264
284
  this.elements.previewContainer?.addEventListener('pointerdown', stopFollowing);
265
285
  this.elements.previewContainer?.addEventListener('wheel', stopFollowing, { passive: true });
286
+ this.elements.skipToEditor?.addEventListener('click', (event) => this.focusEditor(event));
266
287
 
267
288
  this.elements.emptyStateNewFileBtn?.addEventListener('click', () => {
268
289
  this.fileExplorer.actionController.openRootCreateMenu({
@@ -329,6 +350,10 @@ function bindEvents() {
329
350
  this.runEditorCommand?.('openSearch');
330
351
  });
331
352
 
353
+ this.elements.editorFormatButton?.addEventListener('click', () => {
354
+ void this.formatCurrentDocument();
355
+ });
356
+
332
357
  this.elements.toolbarOverflowToggle?.addEventListener('click', (event) => {
333
358
  event.preventDefault();
334
359
  this.closePresencePanel?.();
@@ -508,7 +533,7 @@ function getHeadingLinkLabel(heading) {
508
533
  return heading.textContent.trim();
509
534
  }
510
535
 
511
- headingClone.querySelectorAll('.preview-heading-link-button').forEach((button) => button.remove());
536
+ headingClone.querySelectorAll('.preview-heading-fold-button, .preview-heading-link-button').forEach((button) => button.remove());
512
537
  return headingClone.textContent.replace(/\s+/g, ' ').trim();
513
538
  }
514
539
 
@@ -549,6 +574,7 @@ function createPreviewHeadingLinkUrl(anchorId) {
549
574
  return '';
550
575
  }
551
576
 
577
+ // pi-lens-ignore: unchecked-throwing-call-js
552
578
  const url = new URL(window.location.href);
553
579
  url.hash = createFileRouteHash(this.currentFilePath, {
554
580
  anchor: anchorId,
@@ -594,6 +620,7 @@ function syncPreviewCodeCopyButtons() {
594
620
  const pre = code.parentElement;
595
621
  const wrapper = document.createElement('div');
596
622
  wrapper.className = 'preview-code-block';
623
+ // pi-lens-ignore: no-inner-html-js
597
624
  wrapper.innerHTML = `
598
625
  <div class="preview-code-actions">
599
626
  <button type="button" class="ui-button ui-button--secondary ui-button--compact preview-code-copy-button" aria-label="Copy code" title="Copy code">
@@ -605,6 +632,90 @@ function syncPreviewCodeCopyButtons() {
605
632
  });
606
633
  }
607
634
 
635
+ function applyPreviewHeadingFolds(previewContent) {
636
+ const parents = new Set(Array.from(previewContent?.querySelectorAll?.('.preview-heading-fold-button') ?? [])
637
+ .map((button) => button.closest('h1, h2, h3, h4, h5, h6')?.parentElement)
638
+ .filter(Boolean));
639
+
640
+ parents.forEach((parent) => {
641
+ const collapsedLevels = [];
642
+ Array.from(parent.children).forEach((child) => {
643
+ const level = /^H([1-6])$/.exec(child.tagName)?.[1];
644
+ if (level) {
645
+ const headingLevel = Number(level);
646
+ while (collapsedLevels.at(-1) >= headingLevel) {
647
+ collapsedLevels.pop();
648
+ }
649
+ child.hidden = collapsedLevels.length > 0;
650
+ if (child.dataset.previewHeadingCollapsed === 'true') {
651
+ collapsedLevels.push(headingLevel);
652
+ }
653
+ } else {
654
+ child.hidden = collapsedLevels.length > 0;
655
+ }
656
+ });
657
+ });
658
+ }
659
+
660
+ /** @this {UiShellContext} */
661
+ function unfoldPreviewHeading(target) {
662
+ if (!(target instanceof HTMLElement) || !/^H[1-6]$/.test(target.tagName)) {
663
+ return false;
664
+ }
665
+
666
+ const collapsedHeadings = [];
667
+ let ancestorLevel = Number(target.tagName[1]) + 1;
668
+ for (let heading = target; heading; heading = heading.previousElementSibling) {
669
+ const level = Number(/^H([1-6])$/.exec(heading.tagName)?.[1]);
670
+ if (!level || level >= ancestorLevel) {
671
+ continue;
672
+ }
673
+
674
+ ancestorLevel = level;
675
+ if (heading.dataset.previewHeadingCollapsed === 'true') {
676
+ collapsedHeadings.push(heading);
677
+ }
678
+ }
679
+
680
+ if (collapsedHeadings.length === 0) {
681
+ return false;
682
+ }
683
+
684
+ collapsedHeadings.forEach((heading) => delete heading.dataset.previewHeadingCollapsed);
685
+ this.syncPreviewHeadingFoldButtons();
686
+ this.scrollSyncController?.invalidatePreviewBlocks?.();
687
+ this.schedulePreviewLayoutSync?.({ delayMs: 0 });
688
+ this.refreshCommentUiLayout?.();
689
+ return true;
690
+ }
691
+
692
+ /** @this {UiShellContext} */
693
+ function syncPreviewHeadingFoldButtons() {
694
+ const headings = this.elements.previewContent?.querySelectorAll?.('h1, h2, h3, h4, h5, h6') ?? [];
695
+
696
+ Array.from(headings).forEach((heading) => {
697
+ if (!(heading instanceof HTMLElement)) {
698
+ return;
699
+ }
700
+
701
+ const headingLabel = getHeadingLinkLabel(heading);
702
+ let button = heading.querySelector(':scope > .preview-heading-fold-button');
703
+ if (!(button instanceof HTMLButtonElement)) {
704
+ button = document.createElement('button');
705
+ button.type = 'button';
706
+ button.className = 'ui-icon-button preview-heading-fold-button';
707
+ heading.prepend(button);
708
+ }
709
+
710
+ const collapsed = heading.dataset.previewHeadingCollapsed === 'true';
711
+ button.setAttribute('aria-expanded', String(!collapsed));
712
+ button.setAttribute('aria-label', `${collapsed ? 'Expand' : 'Collapse'} ${headingLabel}`);
713
+ button.setAttribute('title', `${collapsed ? 'Expand' : 'Collapse'} section`);
714
+ });
715
+
716
+ applyPreviewHeadingFolds(this.elements.previewContent);
717
+ }
718
+
608
719
  /** @this {UiShellContext} */
609
720
  function syncPreviewHeadingLinkButtons() {
610
721
  const headings = this.elements.previewContent?.querySelectorAll?.('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]') ?? [];
@@ -620,6 +731,7 @@ function syncPreviewHeadingLinkButtons() {
620
731
  button = document.createElement('button');
621
732
  button.type = 'button';
622
733
  button.className = 'ui-icon-button preview-heading-link-button';
734
+ // pi-lens-ignore: no-inner-html-js
623
735
  button.innerHTML = PREVIEW_HEADING_LINK_ICON;
624
736
  heading.appendChild(button);
625
737
  }
@@ -693,6 +805,18 @@ function handlePreviewContentClick(event) {
693
805
  return;
694
806
  }
695
807
 
808
+ const foldButton = event.target.closest('button.preview-heading-fold-button');
809
+ const heading = foldButton?.closest('h1, h2, h3, h4, h5, h6');
810
+ if (foldButton instanceof HTMLButtonElement && heading instanceof HTMLElement) {
811
+ event.preventDefault();
812
+ heading.dataset.previewHeadingCollapsed = String(heading.dataset.previewHeadingCollapsed !== 'true');
813
+ this.syncPreviewHeadingFoldButtons();
814
+ this.scrollSyncController?.invalidatePreviewBlocks?.();
815
+ this.schedulePreviewLayoutSync?.({ delayMs: 0 });
816
+ this.refreshCommentUiLayout?.();
817
+ return;
818
+ }
819
+
696
820
  const codeCopyButton = event.target.closest('button.preview-code-copy-button');
697
821
  if (codeCopyButton instanceof HTMLButtonElement) {
698
822
  event.preventDefault();
@@ -846,6 +970,29 @@ function getStoredLineWrapping() {
846
970
  return this.preferences.getLineWrappingEnabled();
847
971
  }
848
972
 
973
+ /** @this {UiShellContext} */
974
+ async function formatCurrentDocument() {
975
+ const button = this.elements.editorFormatButton;
976
+ button?.setAttribute('disabled', '');
977
+
978
+ try {
979
+ const result = await this.session?.formatDocument?.(this.currentFilePath);
980
+ const messages = {
981
+ changed: 'Document changed while formatting. Try again.',
982
+ formatted: getVaultFileKind(this.currentFilePath) === 'plantuml'
983
+ ? 'PlantUML indentation applied'
984
+ : 'Document formatted',
985
+ unchanged: 'Document is already formatted',
986
+ unsupported: 'Formatting is unavailable for this file type',
987
+ };
988
+ this.toastController.show(messages[result] ?? messages.unsupported);
989
+ } catch (error) {
990
+ this.toastController.show(`Formatting failed: ${error?.message || 'Unknown error'}`, 5000);
991
+ } finally {
992
+ button?.removeAttribute('disabled');
993
+ }
994
+ }
995
+
849
996
  /** @this {UiShellContext} */
850
997
  function toggleVimMode() {
851
998
  const currentState = this.session?.isVimModeEnabled?.() ?? this.getStoredVimMode();
@@ -916,13 +1063,17 @@ function hideEditorLoading() {
916
1063
  }
917
1064
 
918
1065
  /** @this {UiShellContext} */
919
- function showEditorLoadError() {
1066
+ function showEditorLoadError(message = 'Failed to load file') {
920
1067
  if (!this.elements.editorContainer) return;
1068
+ const loading = document.createElement('div');
1069
+ const text = document.createElement('span');
1070
+ loading.className = 'editor-loading';
1071
+ loading.id = 'editorLoading';
1072
+ text.className = 'loading-text';
1073
+ text.textContent = message;
1074
+ loading.appendChild(text);
921
1075
  this.elements.editorContainer.classList.remove('is-loading-editor');
922
- this.elements.editorContainer.innerHTML = `
923
- <div class="editor-loading" id="editorLoading">
924
- <span class="loading-text">Failed to load file</span>
925
- </div>`;
1076
+ this.elements.editorContainer.replaceChildren(loading);
926
1077
  }
927
1078
 
928
1079
  /** @this {UiShellContext} */
@@ -938,6 +1089,8 @@ export const uiFeatureShellMethods = {
938
1089
  copyPreviewCodeBlock,
939
1090
  copyPreviewHeadingLink,
940
1091
  createPreviewHeadingLinkUrl,
1092
+ focusEditor,
1093
+ formatCurrentDocument,
941
1094
  getStoredLineWrapping,
942
1095
  getStoredVimMode,
943
1096
  handleConnectionChange,
@@ -958,6 +1111,7 @@ export const uiFeatureShellMethods = {
958
1111
  showEditorLoadError,
959
1112
  showEditorLoading,
960
1113
  syncPreviewCodeCopyButtons,
1114
+ syncPreviewHeadingFoldButtons,
961
1115
  syncPreviewHeadingLinkButtons,
962
1116
  syncVisualViewportBounds,
963
1117
  syncToolbarOverflowVisibility,
@@ -965,5 +1119,6 @@ export const uiFeatureShellMethods = {
965
1119
  syncWrapToggle,
966
1120
  toggleToolbarOverflowMenu,
967
1121
  toggleLineWrapping,
1122
+ unfoldPreviewHeading,
968
1123
  toggleVimMode,
969
1124
  };
@@ -37,6 +37,7 @@ export function bindAppShellElements(doc = document) {
37
37
  displayNameTitle: doc.getElementById('displayNameTitle'),
38
38
  editNameButton: doc.getElementById('editNameBtn'),
39
39
  editorFindButton: doc.getElementById('editorFindBtn'),
40
+ editorFormatButton: doc.getElementById('editorFormatBtn'),
40
41
  editorContainer: doc.getElementById('editorContainer'),
41
42
  exportDocxButton: doc.getElementById('exportDocxBtn'),
42
43
  exportHtmlButton: doc.getElementById('exportHtmlBtn'),
@@ -55,12 +56,15 @@ export function bindAppShellElements(doc = document) {
55
56
  gitOperationStatus: doc.getElementById('gitOperationStatus'),
56
57
  lineInfo: doc.getElementById('lineInfo'),
57
58
  mobileViewToggle: doc.getElementById('mobileViewToggle'),
59
+ htmlPreviewMaximizeButton: doc.getElementById('htmlPreviewMaximize'),
58
60
  previewContent: doc.getElementById('previewContent'),
59
61
  previewContainer: doc.getElementById('previewContainer'),
62
+ reviewFileChangesButton: doc.getElementById('reviewFileChangesBtn'),
60
63
  presencePanel: doc.getElementById('presencePanel'),
61
64
  presencePanelList: doc.getElementById('presencePanelList'),
62
65
  presencePanelStatus: doc.getElementById('presencePanelStatus'),
63
66
  searchFilesButton: doc.getElementById('searchFilesBtn'),
67
+ skipToEditor: doc.getElementById('skipToEditor'),
64
68
  toolbarSearchButton: doc.getElementById('toolbarSearchBtn'),
65
69
  shareButton: doc.getElementById('shareBtn'),
66
70
  toastContainer: doc.getElementById('toastContainer'),
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isBaseFilePath,
3
+ isHtmlFilePath,
3
4
  isMarkdownFilePath,
4
5
  supportsBacklinksForFilePath,
5
6
  } from '../../domain/file-kind.js';
@@ -51,6 +52,7 @@ export class WorkspaceCoordinator {
51
52
  onRenderBasePreview,
52
53
  onRenderExcalidrawPreview,
53
54
  onRenderDrawioPreview,
55
+ onRenderHtmlPreview,
54
56
  onRenderImagePreview,
55
57
  onRenderPdfPreview,
56
58
  onRenderStructurizrPreview,
@@ -103,6 +105,7 @@ export class WorkspaceCoordinator {
103
105
  this.onRenderBasePreview = onRenderBasePreview;
104
106
  this.onRenderDrawioPreview = onRenderDrawioPreview;
105
107
  this.onRenderExcalidrawPreview = onRenderExcalidrawPreview;
108
+ this.onRenderHtmlPreview = onRenderHtmlPreview;
106
109
  this.onRenderImagePreview = onRenderImagePreview;
107
110
  this.onRenderPdfPreview = onRenderPdfPreview;
108
111
  this.onRenderStructurizrPreview = onRenderStructurizrPreview;
@@ -180,6 +183,7 @@ export class WorkspaceCoordinator {
180
183
  isDrawio = false,
181
184
  filePath,
182
185
  isExcalidraw = false,
186
+ isHtml = false,
183
187
  isImage = false,
184
188
  isPdf = false,
185
189
  supportsBacklinks,
@@ -187,6 +191,7 @@ export class WorkspaceCoordinator {
187
191
  if (isExcalidraw) this.onRenderExcalidrawPreview(filePath);
188
192
  if (isBase || isBaseFilePath(filePath)) this.onRenderBasePreview(filePath);
189
193
  if (isDrawio) this.onRenderDrawioPreview(filePath);
194
+ if (isHtml) this.onRenderHtmlPreview({ content: this.session?.getText?.() ?? '' });
190
195
  if (isImage) this.onRenderImagePreview(filePath);
191
196
  if (isPdf) this.onRenderPdfPreview(filePath);
192
197
  if (this.isStructurizrWorkspaceFile(filePath)) {
@@ -200,7 +205,14 @@ export class WorkspaceCoordinator {
200
205
 
201
206
  async openFile(filePath, { drawioMode = null } = {}) {
202
207
  if (!this.isTabActive()) {
203
- return;
208
+ return false;
209
+ }
210
+
211
+ if (!filePath || !this.getFileList().includes(filePath)) {
212
+ this.cleanupSession();
213
+ this.stateStore.sessionLoadToken += 1;
214
+ this.onFileOpenError({ code: 'not-found', filePath });
215
+ return false;
204
216
  }
205
217
 
206
218
  const normalizedDrawioMode = drawioMode ?? null;
@@ -210,6 +222,7 @@ export class WorkspaceCoordinator {
210
222
  const isBase = this.isBaseFile(filePath);
211
223
  const isImage = this.isImageFile(filePath);
212
224
  const isPdf = this.isPdfFile(filePath);
225
+ const isHtml = isHtmlFilePath(filePath);
213
226
  const isMermaid = this.isMermaidFile(filePath);
214
227
  const isPlantUml = this.isPlantUmlFile(filePath);
215
228
  const isStructurizrWorkspace = this.isStructurizrWorkspaceFile(filePath);
@@ -221,7 +234,7 @@ export class WorkspaceCoordinator {
221
234
  ) {
222
235
  this.onUpdateActiveFile(filePath);
223
236
  this.onUpdateLobbyCurrentFile(filePath);
224
- return;
237
+ return true;
225
238
  }
226
239
 
227
240
  const loadToken = ++this.stateStore.sessionLoadToken;
@@ -252,7 +265,7 @@ export class WorkspaceCoordinator {
252
265
  session: null,
253
266
  supportsBacklinks: chromeState.supportsBacklinks,
254
267
  });
255
- return;
268
+ return true;
256
269
  }
257
270
 
258
271
  const EditorSession = await this.loadEditorSessionClass();
@@ -272,6 +285,7 @@ export class WorkspaceCoordinator {
272
285
 
273
286
  this.onContentChange({
274
287
  isBase,
288
+ isHtml,
275
289
  isMermaid,
276
290
  isPlantUml,
277
291
  isStructurizrWorkspace,
@@ -313,6 +327,7 @@ export class WorkspaceCoordinator {
313
327
  filePath,
314
328
  isBase,
315
329
  isExcalidraw,
330
+ isHtml,
316
331
  session,
317
332
  supportsBacklinks: chromeState.supportsBacklinks,
318
333
  });
@@ -426,6 +441,7 @@ export class WorkspaceCoordinator {
426
441
  session.ensureInitialContent?.();
427
442
  await readySession('post-initialize');
428
443
  }
444
+ return true;
429
445
  } catch (error) {
430
446
  console.error('[app] Failed to initialize editor:', error);
431
447
  session.destroy();
@@ -438,7 +454,8 @@ export class WorkspaceCoordinator {
438
454
  return;
439
455
  }
440
456
 
441
- this.onFileOpenError();
457
+ this.onFileOpenError({ code: 'load-failed', filePath });
458
+ return false;
442
459
  }
443
460
  }
444
461
  }
@@ -1,11 +1,40 @@
1
1
  import {
2
2
  isBaseFilePath,
3
3
  isDiagramFilePath,
4
+ isHtmlFilePath,
4
5
  isMarkdownFilePath,
5
6
  isStructurizrFilePath,
6
7
  } from '../../domain/file-kind.js';
8
+ import { canFormatDocument } from '../domain/document-formatter.js';
9
+ import { setDiagramActionButtonIcon } from '../domain/diagram-action-icons.js';
7
10
  import { resolveApiUrl } from '../domain/runtime-paths.js';
8
11
 
12
+ const HTML_PREVIEW_CSP = "default-src 'none'; base-uri about:; form-action 'none'; object-src 'none'; frame-src 'none'; worker-src 'none'; connect-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:";
13
+
14
+ function createHtmlPreviewDocument(content, allowScripts = false) {
15
+ const scriptSource = allowScripts ? "'unsafe-inline'" : "'none'";
16
+ return `<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}; script-src ${scriptSource};"><base href="about:srcdoc">${String(content ?? '')}`;
17
+ }
18
+
19
+ function hasHtmlScripts(content) {
20
+ const preview = new DOMParser().parseFromString(content, 'text/html');
21
+ if (preview.scripts.length > 0) return true;
22
+ return [...preview.querySelectorAll('*')].some((element) => [...element.attributes].some(({ name, value }) => (
23
+ (name.startsWith('on') && name in element)
24
+ || ((name === 'href' || name === 'xlink:href') && /^\s*javascript:/iu.test(value))
25
+ )));
26
+ }
27
+
28
+ function syncFormatDocumentButton(button, filePath, isPlantUml) {
29
+ if (!button) return;
30
+ const actionLabel = isPlantUml ? 'Indent PlantUML document' : 'Format document';
31
+ button.classList.toggle('hidden', !canFormatDocument(filePath));
32
+ button.setAttribute('aria-label', actionLabel);
33
+ button.setAttribute('title', actionLabel);
34
+ const label = button.querySelector('.ui-action-label');
35
+ if (label) label.textContent = isPlantUml ? 'Indent' : 'Format';
36
+ }
37
+
9
38
  export class WorkspacePreviewController {
10
39
  constructor({
11
40
  backlinksPanel,
@@ -43,6 +72,14 @@ export class WorkspacePreviewController {
43
72
  updateTheme() {},
44
73
  };
45
74
  this.elements = elements;
75
+ this.htmlPreviewShell = null;
76
+ if (this.elements.htmlPreviewMaximizeButton) {
77
+ this.elements.htmlPreviewMaximizeButton.addEventListener('click', () => {
78
+ const isMaximized = this.htmlPreviewShell?.classList.contains('is-maximized');
79
+ this.setHtmlPreviewMaximized(!isMaximized);
80
+ });
81
+ setDiagramActionButtonIcon(this.elements.htmlPreviewMaximizeButton, 'maximize');
82
+ }
46
83
  this.excalidrawEmbed = excalidrawEmbed;
47
84
  this.getDisplayName = getDisplayName;
48
85
  this.getSession = getSession;
@@ -91,13 +128,29 @@ export class WorkspacePreviewController {
91
128
  return source;
92
129
  }
93
130
 
131
+ setHtmlPreviewMaximized(maximized) {
132
+ const isMaximized = Boolean(maximized && this.htmlPreviewShell);
133
+ this.htmlPreviewShell?.classList.toggle('is-maximized', isMaximized);
134
+ globalThis.document?.body?.classList.toggle('html-preview-maximized-open', isMaximized);
135
+
136
+ const button = this.elements.htmlPreviewMaximizeButton;
137
+ if (button) {
138
+ setDiagramActionButtonIcon(button, isMaximized ? 'restore' : 'maximize');
139
+ button.title = isMaximized ? 'Restore HTML preview' : 'Maximize HTML preview';
140
+ button.setAttribute('aria-label', button.title);
141
+ }
142
+ }
143
+
94
144
  resetPreviewMode() {
145
+ this.setHtmlPreviewMaximized(false);
146
+ this.htmlPreviewShell = null;
95
147
  this.pdfPreview.cancel();
96
148
  this.elements.previewContent?.classList.remove('is-drawio-file-preview');
97
149
  this.elements.previewContent?.classList.remove('is-excalidraw-file-preview');
98
150
  this.elements.previewContent?.classList.remove('is-base-file-preview');
99
151
  this.elements.previewContent?.classList.remove('is-image-file-preview');
100
152
  this.elements.previewContent?.classList.remove('is-pdf-file-preview');
153
+ this.elements.previewContent?.classList.remove('is-html-file-preview');
101
154
  this.elements.previewContent?.classList.remove('is-mermaid-file-preview');
102
155
  this.elements.previewContent?.classList.remove('is-plantuml-file-preview');
103
156
  this.elements.previewContent?.classList.remove('is-structurizr-file-preview');
@@ -110,6 +163,7 @@ export class WorkspacePreviewController {
110
163
  const isBase = this.isBaseFile(filePath);
111
164
  const isImage = this.isImageFile(filePath);
112
165
  const isPdf = this.isPdfFile(filePath);
166
+ const isHtml = isHtmlFilePath(filePath);
113
167
  const isMarkdown = isMarkdownFilePath(filePath);
114
168
  const isMermaid = this.isMermaidFile(filePath);
115
169
  const isPlantUml = this.isPlantUmlFile(filePath);
@@ -120,9 +174,11 @@ export class WorkspacePreviewController {
120
174
  this.backlinksPanel.setDisplayMode?.(usesHeaderBacklinks ? 'header' : 'dock');
121
175
 
122
176
  this.elements.editorFindButton?.classList.toggle('hidden', !isMarkdown);
177
+ syncFormatDocumentButton(this.elements.editorFormatButton, filePath, isPlantUml);
123
178
  this.elements.markdownToolbar?.classList.toggle('hidden', !isMarkdown);
124
179
  this.elements.exportMenuGroup?.classList.toggle('hidden', !isMarkdown);
125
- this.elements.outlineToggle?.classList.toggle('hidden', isDiagramFile || isImage || isPdf || isBase);
180
+ this.elements.htmlPreviewMaximizeButton?.classList.toggle('hidden', !isHtml);
181
+ this.elements.outlineToggle?.classList.toggle('hidden', isDiagramFile || isImage || isPdf || isHtml || isBase);
126
182
  this.elements.previewContent?.classList.toggle('is-mermaid-file-preview', isMermaid);
127
183
  this.elements.previewContent?.classList.toggle('is-plantuml-file-preview', isPlantUml);
128
184
  this.elements.previewContent?.classList.toggle('is-structurizr-file-preview', isStructurizr);
@@ -132,7 +188,7 @@ export class WorkspacePreviewController {
132
188
  this.backlinksPanel.clear();
133
189
  }
134
190
 
135
- if ((isDrawio && drawioMode !== 'text') || isExcalidraw || isImage || isPdf || (isBase && preferPreviewForBase)) {
191
+ if ((isDrawio && drawioMode !== 'text') || isExcalidraw || isHtml || isImage || isPdf || (isBase && preferPreviewForBase)) {
136
192
  this.layoutController.setView('preview', { persist: false });
137
193
  this.outlineController.close();
138
194
  this.backlinksPanel.clear();
@@ -292,6 +348,68 @@ export class WorkspacePreviewController {
292
348
  this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
293
349
  }
294
350
 
351
+ renderHtmlFilePreview({ content = '' } = {}) {
352
+ const previewElement = this.elements.previewContent;
353
+ if (!previewElement) {
354
+ return;
355
+ }
356
+
357
+ const wasMaximized = this.htmlPreviewShell?.classList.contains('is-maximized') ?? false;
358
+ this.videoEmbed?.detachForCommit();
359
+ this.drawioEmbed.detachForCommit();
360
+ this.excalidrawEmbed.detachForCommit();
361
+ this.resetPreviewMode();
362
+ previewElement.classList.add('is-html-file-preview');
363
+ const renderHost = this.previewRenderer.ensureRenderHost();
364
+ this.previewRenderer.normalizePreviewChildren(renderHost);
365
+
366
+ const source = String(content ?? '');
367
+ const iframe = document.createElement('iframe');
368
+ iframe.className = 'html-file-preview-frame';
369
+ iframe.title = 'HTML preview';
370
+ iframe.referrerPolicy = 'no-referrer';
371
+ iframe.setAttribute('allow', "camera 'none'; microphone 'none'; geolocation 'none'; clipboard-read 'none'; clipboard-write 'none'");
372
+ iframe.setAttribute('sandbox', '');
373
+ // ponytail: full iframe replacement resets script consent whenever the content changes.
374
+ iframe.srcdoc = createHtmlPreviewDocument(source);
375
+
376
+ const shell = document.createElement('div');
377
+ shell.className = 'html-file-preview-shell';
378
+
379
+ if (hasHtmlScripts(source)) {
380
+ const scriptGate = document.createElement('div');
381
+ scriptGate.className = 'html-file-preview-script-gate';
382
+ scriptGate.textContent = 'Scripts are disabled by default. Run them only if you trust this HTML file.';
383
+
384
+ const runScriptsButton = document.createElement('button');
385
+ runScriptsButton.type = 'button';
386
+ runScriptsButton.className = 'ui-button ui-button--secondary ui-button--compact';
387
+ runScriptsButton.textContent = 'Run scripts';
388
+ runScriptsButton.addEventListener('click', () => {
389
+ iframe.setAttribute('sandbox', 'allow-scripts');
390
+ iframe.srcdoc = createHtmlPreviewDocument(source, true);
391
+ scriptGate.remove();
392
+ });
393
+ scriptGate.append(runScriptsButton);
394
+ shell.append(scriptGate);
395
+ }
396
+ shell.append(iframe);
397
+ this.htmlPreviewShell = shell;
398
+ this.setHtmlPreviewMaximized(wasMaximized);
399
+
400
+ if (renderHost) {
401
+ renderHost.replaceChildren(shell);
402
+ renderHost.style.minHeight = '';
403
+ }
404
+
405
+ previewElement.dataset.renderPhase = 'ready';
406
+ this.outlineController.close();
407
+ this.backlinksPanel.clear();
408
+ this.scrollSyncController.setLargeDocumentMode(false);
409
+ this.scrollSyncController.invalidatePreviewBlocks();
410
+ this.schedulePreviewLayoutSyncCallback({ delayMs: 0 });
411
+ }
412
+
295
413
  async renderBaseFilePreview(filePath, { source = null } = {}) {
296
414
  const previewElement = this.elements.previewContent;
297
415
  if (!previewElement) {
@@ -285,8 +285,11 @@ export class WorkspaceRouteController {
285
285
  this.fileHistoryView?.hide?.();
286
286
  this.clearStaticPreviewDocument?.();
287
287
  this.syncMainChrome({ mode: 'editor' });
288
- await this.workspaceCoordinator.openFile(filePath, options);
288
+ const didOpen = await this.workspaceCoordinator.openFile(filePath, options);
289
289
  this.setSession(this.workspaceCoordinator.getSession());
290
+ if (didOpen === false) {
291
+ return false;
292
+ }
290
293
 
291
294
  if (shouldRevealInTree) {
292
295
  this.revealFileInTree(filePath, { clearSearch: true });