collabmd 0.1.36 → 0.1.38

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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{drawio-editor-D4Qoluwz.js → drawio-editor-BHI0pLby.js} +1 -1
  3. package/dist/client/assets/{drawioEditor-B65FWq-b.js → drawioEditor-DOaXwaY2.js} +2 -2
  4. package/dist/client/assets/{editor-session-DV80PhYL.js → editor-session-BFsA-WRc.js} +1 -1
  5. package/dist/client/assets/{excalidraw-editor-hiJQt1cX.js → excalidraw-editor-6pgH2kf5.js} +1 -1
  6. package/dist/client/assets/{excalidrawEditor-BnbblxK4.js → excalidrawEditor-Ck9hgrTa.js} +2 -2
  7. package/dist/client/assets/export-page-4M8Zo-AL.js +39 -0
  8. package/dist/client/assets/exportDocument-BAqaHlIn.css +1 -0
  9. package/dist/client/assets/{exportDocument-CULEGKmb.js → exportDocument-mlXOym-R.js} +2 -2
  10. package/dist/client/assets/{index-BjdYZiBM.js → index-CCfHrm_9.js} +2 -2
  11. package/dist/client/assets/{index-BryqP9QY.css → index-maPm_Kvk.css} +1 -1
  12. package/dist/client/assets/main-BQMPQ4CW.js +1410 -0
  13. package/dist/client/assets/preview-render-compiler-9SmLUP5V.js +1 -0
  14. package/dist/client/assets/{preview-render-compiler-Tkc6JX1O.js → preview-render-compiler-IUtgIlOM.js} +1 -1
  15. package/dist/client/assets/{preview-render-worker-1LBLqgKu.js → preview-render-worker-BwQ5sPy9.js} +1 -1
  16. package/dist/client/assets/runtime-config-BcUbZKho.js +1 -0
  17. package/dist/client/assets/{vault-api-client-DSx3mWkH.js → vault-api-client-BDKKvtOF.js} +1 -1
  18. package/dist/client/drawio-editor.html +1 -1
  19. package/dist/client/excalidraw-editor.html +1 -1
  20. package/dist/client/export-document.html +2 -2
  21. package/dist/client/index.html +2 -2
  22. package/docker-compose.yml +1 -0
  23. package/package.json +12 -9
  24. package/src/client/application/app-shell/ui-feature-shell.js +237 -0
  25. package/src/client/application/preview-render-compiler.js +120 -1
  26. package/src/client/application/preview-render-executor.js +4 -0
  27. package/src/client/application/preview-render-worker.js +2 -0
  28. package/src/client/application/preview-renderer.js +5 -2
  29. package/src/client/application/wiki-link-file-controller.js +7 -0
  30. package/src/client/application/workspace-route-controller.js +9 -0
  31. package/src/client/bootstrap/collabmd-app-shell.js +13 -0
  32. package/src/client/domain/hash-routes.js +49 -0
  33. package/src/client/domain/runtime-paths.js +1 -0
  34. package/src/client/export/export-pipeline.js +100 -26
  35. package/src/client/infrastructure/runtime-config.js +22 -24
  36. package/src/client/presentation/outline-controller.js +28 -7
  37. package/src/client/styles/features/preview-markdown.css +51 -0
  38. package/src/server/config/env.js +1 -0
  39. package/src/server/infrastructure/http/create-static-handler.js +2 -0
  40. package/dist/client/assets/export-page-BKDarp9x.js +0 -39
  41. package/dist/client/assets/exportDocument-BbkN85c4.css +0 -1
  42. package/dist/client/assets/main-QJcxyvDa.js +0 -1404
  43. package/dist/client/assets/preview-render-compiler-BCB8u6iM.js +0 -1
  44. package/dist/client/assets/runtime-config-DWTejofP.js +0 -1
@@ -116,6 +116,9 @@ export class CollabMdAppShell {
116
116
  bindEvents(...args) { return uiFeature.bindEvents.apply(this, args); }
117
117
  clearInitialFileBootstrap(...args) { return uiFeature.clearInitialFileBootstrap.apply(this, args); }
118
118
  closeToolbarOverflowMenu(...args) { return uiFeature.closeToolbarOverflowMenu.apply(this, args); }
119
+ applyPendingPreviewRouteAnchor(...args) { return uiFeature.applyPendingPreviewRouteAnchor.apply(this, args); }
120
+ copyPreviewHeadingLink(...args) { return uiFeature.copyPreviewHeadingLink.apply(this, args); }
121
+ createPreviewHeadingLinkUrl(...args) { return uiFeature.createPreviewHeadingLinkUrl.apply(this, args); }
119
122
  getStoredLineWrapping(...args) { return uiFeature.getStoredLineWrapping.apply(this, args); }
120
123
  handleConnectionChange(...args) { return uiFeature.handleConnectionChange.apply(this, args); }
121
124
  handleDocumentKeydown(...args) { return uiFeature.handleDocumentKeydown.apply(this, args); }
@@ -126,11 +129,14 @@ export class CollabMdAppShell {
126
129
  initialize(...args) { return uiFeature.initialize.apply(this, args); }
127
130
  initializeVisualViewportBinding(...args) { return uiFeature.initializeVisualViewportBinding.apply(this, args); }
128
131
  initializeVersionMonitoring(...args) { return uiFeature.initializeVersionMonitoring.apply(this, args); }
132
+ navigatePreviewHeading(...args) { return uiFeature.navigatePreviewHeading.apply(this, args); }
129
133
  promptForVersionReload(...args) { return uiFeature.promptForVersionReload.apply(this, args); }
134
+ requestPreviewRouteAnchor(...args) { return uiFeature.requestPreviewRouteAnchor.apply(this, args); }
130
135
  scheduleBacklinkRefresh(...args) { return uiFeature.scheduleBacklinkRefresh.apply(this, args); }
131
136
  setToolbarOverflowOpen(...args) { return uiFeature.setToolbarOverflowOpen.apply(this, args); }
132
137
  showEditorLoadError(...args) { return uiFeature.showEditorLoadError.apply(this, args); }
133
138
  showEditorLoading(...args) { return uiFeature.showEditorLoading.apply(this, args); }
139
+ syncPreviewHeadingLinkButtons(...args) { return uiFeature.syncPreviewHeadingLinkButtons.apply(this, args); }
134
140
  syncVisualViewportBounds(...args) { return uiFeature.syncVisualViewportBounds.apply(this, args); }
135
141
  syncToolbarOverflowVisibility(...args) { return uiFeature.syncToolbarOverflowVisibility.apply(this, args); }
136
142
  syncWrapToggle(...args) { return uiFeature.syncWrapToggle.apply(this, args); }
@@ -331,6 +337,7 @@ export class CollabMdAppShell {
331
337
  this.previewRenderer = new PreviewRenderer({
332
338
  getContent: () => this.getPreviewSource(),
333
339
  getFileList: () => this.fileExplorer.flatDocumentFiles,
340
+ getWikiLinkAutoCreate: () => this.runtimeConfig.wikiLinkAutoCreate !== false,
334
341
  loadFileSource: async (filePath) => {
335
342
  const payload = await this.vaultApiClient.readFile(filePath);
336
343
  return String(payload?.content ?? '');
@@ -349,6 +356,8 @@ export class CollabMdAppShell {
349
356
  this.excalidrawEmbed.reconcileEmbeds(this.elements.previewContent, { isLargeDocument: stats.isLargeDocument });
350
357
  this.excalidrawEmbed.syncLayout();
351
358
  this.scrollSyncController.setLargeDocumentMode(stats.isLargeDocument);
359
+ this.syncPreviewHeadingLinkButtons();
360
+ this.applyPendingPreviewRouteAnchor({ behavior: 'auto', clearMissing: true });
352
361
  this.schedulePreviewLayoutSync({ delayMs: 0 });
353
362
  this.refreshCommentUiLayout();
354
363
  },
@@ -359,6 +368,7 @@ export class CollabMdAppShell {
359
368
  },
360
369
  onPreviewLayoutChange: () => {
361
370
  this.scrollSyncController.invalidatePreviewBlocks();
371
+ this.applyPendingPreviewRouteAnchor({ behavior: 'auto', clearMissing: false });
362
372
  this.schedulePreviewLayoutSync({ delayMs: 0 });
363
373
  this.refreshCommentUiLayout();
364
374
  },
@@ -366,6 +376,7 @@ export class CollabMdAppShell {
366
376
  this.videoEmbed.syncLayout();
367
377
  this.drawioEmbed.syncLayout();
368
378
  this.excalidrawEmbed.syncLayout();
379
+ this.applyPendingPreviewRouteAnchor({ allowExpired: true, behavior: 'auto', clearMissing: true });
369
380
  this.schedulePreviewLayoutSync({ delayMs: 0 });
370
381
  this.refreshCommentUiLayout();
371
382
  },
@@ -464,6 +475,7 @@ export class CollabMdAppShell {
464
475
  refreshExplorer: () => this.fileExplorer.refresh(),
465
476
  toastController: this.toastController,
466
477
  vaultApiClient: this.vaultApiClient,
478
+ wikiLinkAutoCreate: this.runtimeConfig.wikiLinkAutoCreate !== false,
467
479
  });
468
480
  this.gitDiffView = new GitDiffViewController({
469
481
  gitApiClient: this.gitApiClient,
@@ -635,6 +647,7 @@ export class CollabMdAppShell {
635
647
  lobby: this.lobby,
636
648
  navigation: this.navigation,
637
649
  previewRenderer: this.previewRenderer,
650
+ requestPreviewRouteAnchor: (anchorId, filePath) => this.requestPreviewRouteAnchor(anchorId, filePath),
638
651
  renderAvatars: () => this.renderAvatars(),
639
652
  renderPresence: () => this.renderPresence(),
640
653
  resetPreviewMode: () => this.resetPreviewMode(),
@@ -0,0 +1,49 @@
1
+ export const HASH_ROUTE_KEYS = Object.freeze({
2
+ authError: 'auth_error',
3
+ authPassword: 'auth_password',
4
+ file: 'file',
5
+ gitCommit: 'git-commit',
6
+ gitDiff: 'git-diff',
7
+ gitFileHistory: 'git-file-history',
8
+ gitFilePreview: 'git-file-preview',
9
+ gitHistory: 'git-history',
10
+ });
11
+
12
+ export function getHashParamsFromRaw(hash = '') {
13
+ const rawHash = String(hash ?? '');
14
+ const normalizedHash = rawHash.startsWith('#')
15
+ ? rawHash.slice(1)
16
+ : rawHash;
17
+ return new URLSearchParams(normalizedHash);
18
+ }
19
+
20
+ function getNormalizedHash(hash = '') {
21
+ const rawHash = String(hash ?? '');
22
+ return rawHash.startsWith('#')
23
+ ? rawHash.slice(1)
24
+ : rawHash;
25
+ }
26
+
27
+ export function isCollabMdHashRoute(hash = '') {
28
+ const normalizedHash = getNormalizedHash(hash).trim();
29
+ if (!normalizedHash.includes('=')) {
30
+ return false;
31
+ }
32
+
33
+ const params = getHashParamsFromRaw(hash);
34
+ return Object.values(HASH_ROUTE_KEYS).some((key) => params.has(key));
35
+ }
36
+
37
+ export function createFileRouteHash(filePath, { drawioMode = null, anchor = null } = {}) {
38
+ const params = new URLSearchParams();
39
+ if (filePath) {
40
+ params.set(HASH_ROUTE_KEYS.file, filePath);
41
+ }
42
+ if (drawioMode) {
43
+ params.set('drawio', drawioMode);
44
+ }
45
+ if (anchor) {
46
+ params.set('anchor', String(anchor).trim());
47
+ }
48
+ return params.toString();
49
+ }
@@ -79,6 +79,7 @@ export function getClientRuntimeConfig() {
79
79
  environment: 'development',
80
80
  gitEnabled: true,
81
81
  publicWsBaseUrl: '',
82
+ wikiLinkAutoCreate: true,
82
83
  wsBasePath: '/ws',
83
84
  ...(window.__COLLABMD_CONFIG__ ?? {}),
84
85
  };
@@ -26,6 +26,20 @@ const EXPORT_ASSET_MAX_BYTES = 10 * 1024 * 1024;
26
26
  const EXPORT_RENDER_SETTLE_TIMEOUT_MS = 10_000;
27
27
  const VIDEO_POSTER_CAPTURE_TIMEOUT_MS = 10_000;
28
28
  const VIDEO_POSTER_CAPTURE_SEEK_SECONDS = 1;
29
+ const DOCX_COMPATIBLE_IMAGE_MIME_TYPES = new Set([
30
+ 'image/jpeg',
31
+ 'image/jpg',
32
+ 'image/png',
33
+ 'image/svg+xml',
34
+ ]);
35
+ const DOCX_PNG_VARIANT_IMAGE_MIME_TYPES = new Set([
36
+ 'image/gif',
37
+ 'image/webp',
38
+ ]);
39
+ const DOCX_IMAGE_TYPE_LABELS = new Map([
40
+ ['image/gif', 'GIF'],
41
+ ['image/webp', 'WebP'],
42
+ ]);
29
43
 
30
44
  let mermaidLoaderPromise = null;
31
45
  let assetCounter = 0;
@@ -49,6 +63,16 @@ function encodeSvgDataUrl(svgMarkup) {
49
63
  return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
50
64
  }
51
65
 
66
+ function encodeSvgBase64DataUrl(svgMarkup) {
67
+ const bytes = new TextEncoder().encode(String(svgMarkup ?? ''));
68
+ const chunkSize = 0x8000;
69
+ let binary = '';
70
+ for (let index = 0; index < bytes.length; index += chunkSize) {
71
+ binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
72
+ }
73
+ return `data:image/svg+xml;base64,${btoa(binary)}`;
74
+ }
75
+
52
76
  function waitForAnimationFrame() {
53
77
  return new Promise((resolve) => requestAnimationFrame(() => resolve()));
54
78
  }
@@ -144,28 +168,40 @@ function normalizeExportSvgMarkup(svgMarkup, options = {}) {
144
168
  }
145
169
 
146
170
  async function svgMarkupToPngDataUrl(svgMarkup) {
147
- const svgDataUrl = encodeSvgDataUrl(svgMarkup);
148
- const image = await loadImage(svgDataUrl);
171
+ const svgBlobUrl = URL.createObjectURL(new Blob([svgMarkup], {
172
+ type: 'image/svg+xml;charset=utf-8',
173
+ }));
149
174
  const svgProbe = document.createElement('div');
150
175
  svgProbe.innerHTML = svgMarkup;
151
176
  const svgElement = svgProbe.querySelector('svg');
152
- const { width, height } = svgElement ? resolveSvgDimensions(svgElement) : {
153
- height: image.naturalHeight || 800,
154
- width: image.naturalWidth || 1200,
155
- };
156
177
 
157
- const canvas = document.createElement('canvas');
158
- canvas.width = Math.max(1, Math.ceil(width));
159
- canvas.height = Math.max(1, Math.ceil(height));
160
- const context = canvas.getContext('2d');
161
- if (!context) {
162
- throw new Error('Canvas is unavailable');
163
- }
178
+ try {
179
+ let image;
180
+ try {
181
+ image = await loadImage(svgBlobUrl);
182
+ } catch {
183
+ image = await loadImage(encodeSvgBase64DataUrl(svgMarkup));
184
+ }
185
+ const { width, height } = svgElement ? resolveSvgDimensions(svgElement) : {
186
+ height: image.naturalHeight || 800,
187
+ width: image.naturalWidth || 1200,
188
+ };
164
189
 
165
- context.fillStyle = '#ffffff';
166
- context.fillRect(0, 0, canvas.width, canvas.height);
167
- context.drawImage(image, 0, 0, canvas.width, canvas.height);
168
- return canvas.toDataURL('image/png');
190
+ const canvas = document.createElement('canvas');
191
+ canvas.width = Math.max(1, Math.ceil(width));
192
+ canvas.height = Math.max(1, Math.ceil(height));
193
+ const context = canvas.getContext('2d');
194
+ if (!context) {
195
+ throw new Error('Canvas is unavailable');
196
+ }
197
+
198
+ context.fillStyle = '#ffffff';
199
+ context.fillRect(0, 0, canvas.width, canvas.height);
200
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
201
+ return canvas.toDataURL('image/png');
202
+ } finally {
203
+ URL.revokeObjectURL(svgBlobUrl);
204
+ }
169
205
  }
170
206
 
171
207
  async function imageDataUrlToPngDataUrl(dataUrl) {
@@ -182,6 +218,33 @@ async function imageDataUrlToPngDataUrl(dataUrl) {
182
218
  return canvas.toDataURL('image/png');
183
219
  }
184
220
 
221
+ function getDataUrlMimeType(dataUrl = '') {
222
+ const match = String(dataUrl ?? '').match(/^data:([^;,]+)/iu);
223
+ return match?.[1]?.toLowerCase() || '';
224
+ }
225
+
226
+ async function createDocxImageDataUrl(dataUrl, {
227
+ warnings = [],
228
+ } = {}) {
229
+ const mimeType = getDataUrlMimeType(dataUrl);
230
+ if (DOCX_COMPATIBLE_IMAGE_MIME_TYPES.has(mimeType)) {
231
+ return dataUrl;
232
+ }
233
+
234
+ if (!DOCX_PNG_VARIANT_IMAGE_MIME_TYPES.has(mimeType)) {
235
+ return dataUrl;
236
+ }
237
+
238
+ const imageTypeLabel = DOCX_IMAGE_TYPE_LABELS.get(mimeType) || mimeType;
239
+ try {
240
+ return await imageDataUrlToPngDataUrl(dataUrl);
241
+ } catch (error) {
242
+ const message = error instanceof Error ? error.message : `Failed to convert ${imageTypeLabel} image for DOCX`;
243
+ warnings.push(`Failed to convert ${imageTypeLabel} image for DOCX: ${message}`);
244
+ return dataUrl;
245
+ }
246
+ }
247
+
185
248
  async function waitForImageElement(image, {
186
249
  timeoutMs = EXPORT_RENDER_SETTLE_TIMEOUT_MS,
187
250
  } = {}) {
@@ -781,7 +844,7 @@ async function resolveDiagramShell(snapshot, shell, { label, prefix, render }) {
781
844
 
782
845
  const svgMarkup = await render(source, target);
783
846
  const svgDataUrl = encodeSvgDataUrl(svgMarkup);
784
- const pngDataUrl = await svgMarkupToPngDataUrl(svgMarkup);
847
+ const docxDataUrl = await svgMarkupToPngDataUrl(svgMarkup).catch(() => svgDataUrl);
785
848
  const assetId = registerAsset(snapshot, {
786
849
  dataUrl: svgDataUrl,
787
850
  id: createAssetId(prefix),
@@ -789,7 +852,7 @@ async function resolveDiagramShell(snapshot, shell, { label, prefix, render }) {
789
852
  mimeType: 'image/svg+xml',
790
853
  text: svgMarkup,
791
854
  variants: {
792
- docx: pngDataUrl,
855
+ docx: docxDataUrl,
793
856
  },
794
857
  });
795
858
 
@@ -798,7 +861,7 @@ async function resolveDiagramShell(snapshot, shell, { label, prefix, render }) {
798
861
  assetId,
799
862
  className: `export-diagram--${prefix}`,
800
863
  dataUrl: svgDataUrl,
801
- docxDataUrl: pngDataUrl,
864
+ docxDataUrl,
802
865
  svgMarkup,
803
866
  }));
804
867
  }
@@ -811,7 +874,7 @@ async function resolveExcalidrawEmbed(snapshot, placeholder) {
811
874
 
812
875
  const svgMarkup = await renderExcalidrawToSvgMarkup(target);
813
876
  const svgDataUrl = encodeSvgDataUrl(svgMarkup);
814
- const pngDataUrl = await svgMarkupToPngDataUrl(svgMarkup);
877
+ const docxDataUrl = await svgMarkupToPngDataUrl(svgMarkup).catch(() => svgDataUrl);
815
878
  const assetId = registerAsset(snapshot, {
816
879
  dataUrl: svgDataUrl,
817
880
  id: createAssetId('excalidraw'),
@@ -819,7 +882,7 @@ async function resolveExcalidrawEmbed(snapshot, placeholder) {
819
882
  mimeType: 'image/svg+xml',
820
883
  text: svgMarkup,
821
884
  variants: {
822
- docx: pngDataUrl,
885
+ docx: docxDataUrl,
823
886
  },
824
887
  });
825
888
 
@@ -828,7 +891,7 @@ async function resolveExcalidrawEmbed(snapshot, placeholder) {
828
891
  assetId,
829
892
  className: 'export-diagram--excalidraw',
830
893
  dataUrl: svgDataUrl,
831
- docxDataUrl: pngDataUrl,
894
+ docxDataUrl,
832
895
  svgMarkup,
833
896
  }));
834
897
  }
@@ -860,22 +923,33 @@ async function resolveImageAssets(snapshot, container) {
860
923
  const imageNodes = Array.from(container.querySelectorAll('img'));
861
924
  for (const imageNode of imageNodes) {
862
925
  const src = imageNode.getAttribute('src') || '';
863
- if (!src || src.startsWith('data:')) {
926
+ if (!src) {
927
+ continue;
928
+ }
929
+
930
+ if (src.startsWith('data:')) {
931
+ const docxDataUrl = await createDocxImageDataUrl(src, {
932
+ warnings: snapshot.warnings,
933
+ });
934
+ imageNode.setAttribute('data-export-docx-src', docxDataUrl);
864
935
  continue;
865
936
  }
866
937
 
867
938
  try {
868
939
  const imageUrl = new URL(src, window.location.href);
869
940
  const dataUrl = await fetchAsDataUrl(imageUrl.toString());
941
+ const docxDataUrl = await createDocxImageDataUrl(dataUrl, {
942
+ warnings: snapshot.warnings,
943
+ });
870
944
  const assetId = registerAsset(snapshot, {
871
945
  dataUrl,
872
946
  id: createAssetId('image'),
873
947
  kind: 'image',
874
- mimeType: dataUrl.startsWith('data:image/svg+xml') ? 'image/svg+xml' : 'image/png',
948
+ mimeType: getDataUrlMimeType(dataUrl) || 'application/octet-stream',
875
949
  });
876
950
  imageNode.src = dataUrl;
877
951
  imageNode.setAttribute('data-export-asset-id', assetId);
878
- imageNode.setAttribute('data-export-docx-src', dataUrl);
952
+ imageNode.setAttribute('data-export-docx-src', docxDataUrl);
879
953
  } catch (error) {
880
954
  const message = error instanceof Error ? error.message : `Failed to inline ${src}`;
881
955
  snapshot.warnings.push(message);
@@ -5,18 +5,22 @@ import {
5
5
  resolveAppUrl,
6
6
  resolveWsBaseUrl,
7
7
  } from '../domain/runtime-paths.js';
8
+ import {
9
+ createFileRouteHash,
10
+ getHashParamsFromRaw,
11
+ HASH_ROUTE_KEYS,
12
+ isCollabMdHashRoute,
13
+ } from '../domain/hash-routes.js';
8
14
 
9
15
  export function getRuntimeConfig() {
10
16
  return getClientRuntimeConfig();
11
17
  }
12
18
 
13
19
  export { resolveApiUrl, resolveAppPath, resolveAppUrl, resolveWsBaseUrl };
20
+ export { createFileRouteHash, isCollabMdHashRoute };
14
21
 
15
22
  function getHashParams() {
16
- const rawHash = window.location.hash.startsWith('#')
17
- ? window.location.hash.slice(1)
18
- : window.location.hash;
19
- return new URLSearchParams(rawHash);
23
+ return getHashParamsFromRaw(window.location.hash);
20
24
  }
21
25
 
22
26
  function normalizeDiffScope(scope) {
@@ -30,8 +34,8 @@ function normalizeDiffScope(scope) {
30
34
  export function getHashRoute() {
31
35
  const params = getHashParams();
32
36
 
33
- if (params.has('git-file-preview')) {
34
- const historicalFilePath = params.get('git-file-preview') || null;
37
+ if (params.has(HASH_ROUTE_KEYS.gitFilePreview)) {
38
+ const historicalFilePath = params.get(HASH_ROUTE_KEYS.gitFilePreview) || null;
35
39
  return {
36
40
  currentFilePath: params.get('current') || historicalFilePath,
37
41
  filePath: historicalFilePath,
@@ -40,28 +44,28 @@ export function getHashRoute() {
40
44
  };
41
45
  }
42
46
 
43
- if (params.has('git-file-history')) {
47
+ if (params.has(HASH_ROUTE_KEYS.gitFileHistory)) {
44
48
  return {
45
- filePath: params.get('git-file-history') || null,
49
+ filePath: params.get(HASH_ROUTE_KEYS.gitFileHistory) || null,
46
50
  type: 'git-file-history',
47
51
  };
48
52
  }
49
53
 
50
- if (params.has('git-history')) {
54
+ if (params.has(HASH_ROUTE_KEYS.gitHistory)) {
51
55
  return { type: 'git-history' };
52
56
  }
53
57
 
54
- if (params.has('git-commit')) {
58
+ if (params.has(HASH_ROUTE_KEYS.gitCommit)) {
55
59
  return {
56
- hash: params.get('git-commit') || null,
60
+ hash: params.get(HASH_ROUTE_KEYS.gitCommit) || null,
57
61
  historyFilePath: params.get('history') || null,
58
62
  path: params.get('path') || null,
59
63
  type: 'git-commit',
60
64
  };
61
65
  }
62
66
 
63
- if (params.has('git-diff')) {
64
- const filePath = params.get('git-diff') || null;
67
+ if (params.has(HASH_ROUTE_KEYS.gitDiff)) {
68
+ const filePath = params.get(HASH_ROUTE_KEYS.gitDiff) || null;
65
69
  return {
66
70
  filePath,
67
71
  scope: normalizeDiffScope(params.get('scope') || (filePath ? 'working-tree' : 'all')),
@@ -69,10 +73,11 @@ export function getHashRoute() {
69
73
  };
70
74
  }
71
75
 
72
- if (params.has('file')) {
76
+ if (params.has(HASH_ROUTE_KEYS.file)) {
73
77
  return {
78
+ anchor: params.get('anchor') || null,
74
79
  drawioMode: params.get('drawio') || null,
75
- filePath: params.get('file'),
80
+ filePath: params.get(HASH_ROUTE_KEYS.file),
76
81
  type: 'file',
77
82
  };
78
83
  }
@@ -80,15 +85,8 @@ export function getHashRoute() {
80
85
  return { type: 'empty' };
81
86
  }
82
87
 
83
- export function navigateToFile(filePath, { drawioMode = null } = {}) {
84
- const params = new URLSearchParams();
85
- if (filePath) {
86
- params.set('file', filePath);
87
- }
88
- if (drawioMode) {
89
- params.set('drawio', drawioMode);
90
- }
91
- window.location.hash = params.toString();
88
+ export function navigateToFile(filePath, { drawioMode = null, anchor = null } = {}) {
89
+ window.location.hash = createFileRouteHash(filePath, { drawioMode, anchor });
92
90
  }
93
91
 
94
92
  export function navigateToGitDiff({ filePath = null, scope = 'all' } = {}) {
@@ -109,10 +109,7 @@ export class OutlineController {
109
109
  this.navigation.querySelectorAll('.outline-item').forEach((button) => {
110
110
  button.addEventListener('click', () => {
111
111
  const target = document.getElementById(button.dataset.target);
112
- this.pinnedHeadingId = button.dataset.target;
113
- this.notifyHeadingNavigation(target, button.dataset.target);
114
- this.scrollHeadingIntoView(target, { behavior: 'auto' });
115
- this.setActiveItem(button.dataset.target, { behavior: 'auto' });
112
+ this.navigateToHeading(target, button.dataset.target, { behavior: 'auto' });
116
113
 
117
114
  if (this.mobileBreakpointQuery.matches) {
118
115
  this.close();
@@ -123,7 +120,7 @@ export class OutlineController {
123
120
  this.observeHeadings(Array.from(headings));
124
121
  }
125
122
 
126
- cleanup() {
123
+ cleanup({ preservePinnedHeading = false } = {}) {
127
124
  if (this.activeHeadingFrame) {
128
125
  cancelAnimationFrame(this.activeHeadingFrame);
129
126
  this.activeHeadingFrame = null;
@@ -131,7 +128,9 @@ export class OutlineController {
131
128
 
132
129
  this.previewContainer?.removeEventListener('scroll', this.handlePreviewScroll);
133
130
  this.headings = [];
134
- this.pinnedHeadingId = null;
131
+ if (!preservePinnedHeading) {
132
+ this.pinnedHeadingId = null;
133
+ }
135
134
  }
136
135
 
137
136
  setActiveItem(id, { scrollIntoView = true, behavior = 'smooth' } = {}) {
@@ -150,7 +149,7 @@ export class OutlineController {
150
149
  }
151
150
 
152
151
  observeHeadings(headings) {
153
- this.cleanup();
152
+ this.cleanup({ preservePinnedHeading: true });
154
153
 
155
154
  if (!this.previewContainer || headings.length === 0) {
156
155
  return;
@@ -158,6 +157,16 @@ export class OutlineController {
158
157
 
159
158
  this.headings = headings;
160
159
  this.previewContainer.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
160
+
161
+ const pinnedHeading = this.getPinnedHeading();
162
+ if (pinnedHeading) {
163
+ this.setActiveItem(pinnedHeading.id, {
164
+ behavior: 'auto',
165
+ scrollIntoView: true,
166
+ });
167
+ return;
168
+ }
169
+
161
170
  this.updateActiveHeading();
162
171
  }
163
172
 
@@ -247,6 +256,18 @@ export class OutlineController {
247
256
  });
248
257
  }
249
258
 
259
+ navigateToHeading(target, headingId, { behavior = 'auto' } = {}) {
260
+ if (!target || !headingId) {
261
+ return false;
262
+ }
263
+
264
+ this.pinnedHeadingId = headingId;
265
+ this.notifyHeadingNavigation(target, headingId);
266
+ this.scrollHeadingIntoView(target, { behavior });
267
+ this.setActiveItem(headingId, { behavior });
268
+ return true;
269
+ }
270
+
250
271
  scrollHeadingIntoView(target, { behavior = 'smooth' } = {}) {
251
272
  if (!target || !this.previewContainer) {
252
273
  return;
@@ -52,6 +52,57 @@
52
52
  font-weight: 600;
53
53
  }
54
54
 
55
+ .preview-content .preview-heading-link-button {
56
+ display: inline-flex;
57
+ width: 1.18em;
58
+ min-width: 1.18em;
59
+ height: 1.18em;
60
+ margin-inline-start: 0.28em;
61
+ border: 0;
62
+ border-radius: var(--radius-full);
63
+ background: transparent;
64
+ color: color-mix(in oklab, var(--color-text-muted) 78%, transparent);
65
+ transform: translateY(-0.04em);
66
+ transition:
67
+ transform var(--transition-interactive),
68
+ background var(--transition-interactive),
69
+ box-shadow var(--transition-interactive),
70
+ color var(--transition-interactive),
71
+ opacity var(--transition-interactive);
72
+ vertical-align: middle;
73
+ opacity: 0;
74
+ }
75
+
76
+ .preview-content .preview-heading-link-button > svg {
77
+ width: 0.74em;
78
+ height: 0.74em;
79
+ stroke-width: 2;
80
+ }
81
+
82
+ .preview-content .preview-heading-link-button:hover {
83
+ background: color-mix(in oklab, var(--color-primary) 12%, transparent);
84
+ box-shadow: var(--shadow-accent-outline);
85
+ color: var(--color-primary);
86
+ transform: translateY(-0.04em) scale(1.04);
87
+ }
88
+
89
+ .preview-content h1:hover .preview-heading-link-button,
90
+ .preview-content h2:hover .preview-heading-link-button,
91
+ .preview-content h3:hover .preview-heading-link-button,
92
+ .preview-content h4:hover .preview-heading-link-button,
93
+ .preview-content h5:hover .preview-heading-link-button,
94
+ .preview-content h6:hover .preview-heading-link-button,
95
+ .preview-content .preview-heading-link-button:focus-visible {
96
+ opacity: 1;
97
+ }
98
+
99
+ .preview-content .preview-heading-link-button:focus-visible {
100
+ background: color-mix(in oklab, var(--color-primary) 14%, transparent);
101
+ box-shadow: var(--shadow-focus-compact);
102
+ color: var(--color-primary);
103
+ outline: 0;
104
+ }
105
+
55
106
  .preview-content p {
56
107
  max-width: 72ch;
57
108
  margin-bottom: var(--space-4);
@@ -334,6 +334,7 @@ export function loadConfig(overrides = {}) {
334
334
  vaultDir,
335
335
  publicWsBaseUrl: process.env.PUBLIC_WS_BASE_URL || '',
336
336
  testWsRoomHydrateDelayMs: parsePositiveInt(process.env.TEST_WS_ROOM_HYDRATE_DELAY_MS, 0),
337
+ wikiLinkAutoCreate: process.env.COLLABMD_WIKI_LINK_AUTO_CREATE !== 'false',
337
338
  wsHeartbeatIntervalMs: parsePositiveInt(process.env.WS_HEARTBEAT_INTERVAL_MS, 30_000),
338
339
  wsRoomIdleGraceMs: parsePositiveInt(process.env.WS_ROOM_IDLE_GRACE_MS, 60_000),
339
340
  wsBasePath: normalizeWsBasePath(process.env.WS_BASE_PATH || '/ws'),
@@ -51,6 +51,7 @@ function buildRuntimeConfig({
51
51
  gitEnabled,
52
52
  nodeEnv,
53
53
  publicWsBaseUrl,
54
+ wikiLinkAutoCreate,
54
55
  wsBasePath,
55
56
  }) {
56
57
  return `window.__COLLABMD_CONFIG__ = ${JSON.stringify({
@@ -61,6 +62,7 @@ function buildRuntimeConfig({
61
62
  environment: nodeEnv,
62
63
  gitEnabled,
63
64
  publicWsBaseUrl,
65
+ wikiLinkAutoCreate,
64
66
  wsBasePath,
65
67
  })};\n`;
66
68
  }