collabmd 0.1.18 → 0.1.20

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 (64) hide show
  1. package/README.md +107 -25
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
  6. package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
  7. package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
  8. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +22 -0
  9. package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
  10. package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
  11. package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +100 -73
  14. package/public/assets/js/preview-render-worker.js +19 -19
  15. package/public/index.html +14 -4
  16. package/src/client/application/app-shell/git-feature.js +29 -1
  17. package/src/client/application/app-shell/presence-feature.js +2 -1
  18. package/src/client/application/app-shell/ui-feature.js +113 -1
  19. package/src/client/application/app-shell/workspace-feature.js +9 -0
  20. package/src/client/application/app-shell-elements.js +1 -0
  21. package/src/client/application/preview-render-compiler.js +55 -5
  22. package/src/client/application/preview-render-executor.js +8 -0
  23. package/src/client/application/preview-render-worker.js +13 -2
  24. package/src/client/application/preview-renderer.js +5 -0
  25. package/src/client/application/workspace-chrome-controller.js +12 -1
  26. package/src/client/application/workspace-coordinator.js +16 -7
  27. package/src/client/application/workspace-preview-controller.js +45 -5
  28. package/src/client/application/workspace-route-controller.js +4 -0
  29. package/src/client/bootstrap/collabmd-app-shell.js +19 -3
  30. package/src/client/domain/room.js +37 -0
  31. package/src/client/domain/vault-utils.js +46 -0
  32. package/src/client/infrastructure/comment-thread-store.js +98 -0
  33. package/src/client/infrastructure/editor-paste-utils.js +45 -0
  34. package/src/client/infrastructure/editor-session.js +10 -0
  35. package/src/client/infrastructure/editor-view-adapter.js +98 -7
  36. package/src/client/infrastructure/vault-api-client.js +17 -0
  37. package/src/client/presentation/backlinks-panel.js +157 -101
  38. package/src/client/presentation/comment-markdown-renderer.js +68 -0
  39. package/src/client/presentation/comment-ui-controller.js +352 -20
  40. package/src/client/presentation/file-explorer-controller.js +5 -0
  41. package/src/client/presentation/file-explorer-view.js +10 -2
  42. package/src/client/presentation/file-tree-state.js +7 -1
  43. package/src/client/presentation/git-panel-controller.js +73 -0
  44. package/src/client/presentation/image-lightbox-controller.js +394 -0
  45. package/src/client/presentation/outline-controller.js +25 -0
  46. package/src/client/styles/style.css +677 -21
  47. package/src/domain/comment-threads.js +95 -13
  48. package/src/domain/file-kind.js +16 -1
  49. package/src/server/infrastructure/git/errors.js +4 -1
  50. package/src/server/infrastructure/git/git-service.js +215 -1
  51. package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
  52. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  53. package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
  54. package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
  55. package/src/server/infrastructure/http/request-body.js +11 -2
  56. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  57. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  58. package/src/server/infrastructure/persistence/vault-file-store.js +203 -2
  59. package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
  60. package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
  61. package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
  62. package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
  63. package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
  64. package/public/assets/js/chunks/quick-switcher-controller-JYDIJVAJ.js +0 -5
@@ -11,9 +11,10 @@ import {
11
11
  } from '@codemirror/language';
12
12
  import { languages } from '@codemirror/language-data';
13
13
  import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
14
- import { Compartment, EditorSelection, EditorState } from '@codemirror/state';
14
+ import { Compartment, EditorSelection, EditorState, Prec } from '@codemirror/state';
15
15
  import { oneDark } from '@codemirror/theme-one-dark';
16
16
  import {
17
+ Decoration,
17
18
  EditorView,
18
19
  crosshairCursor,
19
20
  drawSelection,
@@ -30,8 +31,41 @@ import { normalizeCommentQuote } from '../../domain/comment-threads.js';
30
31
  import { createMarkdownToolbarEdit } from '../domain/markdown-formatting.js';
31
32
  import { wikiLinkCompletions } from '../domain/wiki-link-completions.js';
32
33
  import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantuml-language.js';
34
+ import { handleImagePasteEvent } from './editor-paste-utils.js';
33
35
 
34
36
  const markdownCodeLanguages = [...languages, plantUmlLanguageDescription];
37
+ const pairedMatchingBracketMark = Decoration.mark({ class: 'cm-matchingBracket cm-matchingBracket-paired' });
38
+ const nonmatchingBracketMark = Decoration.mark({ class: 'cm-nonmatchingBracket' });
39
+
40
+ function isBracketBeforeCaret(range, state) {
41
+ return state.selection.ranges.some((selectionRange) =>
42
+ selectionRange.empty && range.to === selectionRange.head
43
+ );
44
+ }
45
+
46
+ function renderBracketMatch(match, state) {
47
+ const decorations = [];
48
+
49
+ if (!match.matched) {
50
+ decorations.push(nonmatchingBracketMark.range(match.start.from, match.start.to));
51
+ if (match.end) {
52
+ decorations.push(nonmatchingBracketMark.range(match.end.from, match.end.to));
53
+ }
54
+ return decorations;
55
+ }
56
+
57
+ const shouldHideStartBracket = isBracketBeforeCaret(match.start, state);
58
+ const shouldHideEndBracket = match.end ? isBracketBeforeCaret(match.end, state) : false;
59
+
60
+ if (!shouldHideStartBracket) {
61
+ decorations.push(pairedMatchingBracketMark.range(match.start.from, match.start.to));
62
+ }
63
+ if (match.end && !shouldHideEndBracket) {
64
+ decorations.push(pairedMatchingBracketMark.range(match.end.from, match.end.to));
65
+ }
66
+
67
+ return decorations;
68
+ }
35
69
 
36
70
  function createEditorTheme(theme) {
37
71
  const activeLineBackground = theme === 'dark'
@@ -46,6 +80,21 @@ function createEditorTheme(theme) {
46
80
  const selectionBorder = theme === 'dark'
47
81
  ? 'oklch(from var(--color-primary) l c h / 0.65)'
48
82
  : 'oklch(from var(--color-primary) l c h / 0.5)';
83
+ const caretColor = theme === 'dark'
84
+ ? 'color-mix(in oklab, var(--color-primary) 78%, white)'
85
+ : 'color-mix(in oklab, var(--color-primary) 84%, black)';
86
+ const bracketPairBackground = theme === 'dark'
87
+ ? 'oklch(from var(--color-primary) l c h / 0.2)'
88
+ : 'oklch(from var(--color-primary) l c h / 0.12)';
89
+ const bracketPairOutline = theme === 'dark'
90
+ ? 'oklch(from var(--color-primary) calc(l + 0.08) c h / 0.85)'
91
+ : 'oklch(from var(--color-primary) calc(l - 0.04) c h / 0.7)';
92
+ const nonmatchingBracketBackground = theme === 'dark'
93
+ ? 'oklch(from var(--color-error) l c h / 0.12)'
94
+ : 'oklch(from var(--color-error) l c h / 0.1)';
95
+ const nonmatchingBracketOutline = theme === 'dark'
96
+ ? 'oklch(from var(--color-error) calc(l + 0.08) c h / 0.8)'
97
+ : 'oklch(from var(--color-error) calc(l - 0.04) c h / 0.7)';
49
98
 
50
99
  return EditorView.theme({
51
100
  '&': {
@@ -53,13 +102,14 @@ function createEditorTheme(theme) {
53
102
  color: 'var(--color-text)',
54
103
  },
55
104
  '.cm-content': {
56
- caretColor: 'var(--color-primary)',
105
+ caretColor,
57
106
  fontFamily: 'var(--font-mono)',
58
107
  padding: '16px 0',
59
108
  },
60
109
  '.cm-cursor, .cm-dropCursor': {
61
- borderLeftColor: 'var(--color-primary)',
62
- borderLeftWidth: '2px',
110
+ borderLeftColor: caretColor,
111
+ borderLeftWidth: '3px',
112
+ marginLeft: '-1px',
63
113
  },
64
114
  '.cm-foldPlaceholder': {
65
115
  backgroundColor: 'var(--color-surface-dynamic)',
@@ -85,8 +135,17 @@ function createEditorTheme(theme) {
85
135
  color: 'var(--color-text-muted)',
86
136
  },
87
137
  '.cm-matchingBracket': {
88
- backgroundColor: 'var(--color-primary-highlight)',
89
- outline: '1px solid var(--color-primary)',
138
+ borderRadius: '2px',
139
+ color: 'inherit',
140
+ },
141
+ '.cm-matchingBracket.cm-matchingBracket-paired': {
142
+ backgroundColor: bracketPairBackground,
143
+ outline: `1px solid ${bracketPairOutline}`,
144
+ },
145
+ '.cm-nonmatchingBracket': {
146
+ backgroundColor: nonmatchingBracketBackground,
147
+ outline: `1px solid ${nonmatchingBracketOutline}`,
148
+ borderRadius: '2px',
90
149
  },
91
150
  '.cm-selectionMatch': {
92
151
  backgroundColor: 'var(--color-primary-highlight)',
@@ -127,6 +186,7 @@ export class EditorViewAdapter {
127
186
  lineInfoElement,
128
187
  lineWrappingEnabled = true,
129
188
  onDocChanged = null,
189
+ onImagePaste = null,
130
190
  onSelectionChanged = null,
131
191
  onViewportChanged = null,
132
192
  }) {
@@ -136,6 +196,7 @@ export class EditorViewAdapter {
136
196
  this.lineInfoElement = lineInfoElement;
137
197
  this.lineWrappingEnabled = lineWrappingEnabled;
138
198
  this.onDocChanged = onDocChanged;
199
+ this.onImagePaste = onImagePaste;
139
200
  this.onSelectionChanged = onSelectionChanged;
140
201
  this.onViewportChanged = onViewportChanged;
141
202
  this.editorView = null;
@@ -188,7 +249,7 @@ export class EditorViewAdapter {
188
249
  EditorState.allowMultipleSelections.of(true),
189
250
  indentOnInput(),
190
251
  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
191
- bracketMatching(),
252
+ bracketMatching({ renderMatch: renderBracketMatch }),
192
253
  closeBrackets(),
193
254
  autocompletion({
194
255
  override: [wikiLinkCompletions(this.getFileList)],
@@ -208,6 +269,9 @@ export class EditorViewAdapter {
208
269
  EditorView.contentAttributes.of({
209
270
  'aria-label': 'Markdown editor',
210
271
  }),
272
+ Prec.highest(EditorView.domEventHandlers({
273
+ paste: (event) => handleImagePasteEvent(event, this.onImagePaste),
274
+ })),
211
275
  createLanguageExtension(filePath),
212
276
  this.themeCompartment.of(createEditorTheme(this.initialTheme)),
213
277
  this.syntaxThemeCompartment.of(this.initialTheme === 'dark' ? oneDark : []),
@@ -561,6 +625,33 @@ export class EditorViewAdapter {
561
625
  return true;
562
626
  }
563
627
 
628
+ insertText(text) {
629
+ if (!this.editorView) {
630
+ return false;
631
+ }
632
+
633
+ const insertValue = String(text ?? '');
634
+ const { state } = this.editorView;
635
+ const range = state.selection.main;
636
+ const anchor = range.from + insertValue.length;
637
+
638
+ this.editorView.dispatch({
639
+ changes: {
640
+ from: range.from,
641
+ insert: insertValue,
642
+ to: range.to,
643
+ },
644
+ scrollIntoView: true,
645
+ selection: {
646
+ anchor,
647
+ head: anchor,
648
+ },
649
+ userEvent: 'input',
650
+ });
651
+ this.editorView.focus();
652
+ return true;
653
+ }
654
+
564
655
  updateCursorInfo(state) {
565
656
  if (!this.lineInfoElement) {
566
657
  return;
@@ -1,5 +1,9 @@
1
1
  import { resolveApiUrl } from '../domain/runtime-paths.js';
2
2
 
3
+ function encodeHeaderMetadata(value) {
4
+ return encodeURIComponent(String(value ?? ''));
5
+ }
6
+
3
7
  async function parseApiResponse(response, fallbackError) {
4
8
  const data = await response.json().catch(() => ({}));
5
9
  if (!response.ok || data.ok === false) {
@@ -56,6 +60,19 @@ export class VaultApiClient {
56
60
  });
57
61
  return parseApiResponse(response, 'Failed to create folder');
58
62
  }
63
+
64
+ async uploadImageAttachment({ file, fileName = '', sourcePath }) {
65
+ const response = await fetch(resolveApiUrl('/attachments'), {
66
+ body: file,
67
+ headers: {
68
+ 'Content-Type': file?.type || 'application/octet-stream',
69
+ 'X-CollabMD-File-Name': encodeHeaderMetadata(fileName),
70
+ 'X-CollabMD-Source-Path': encodeHeaderMetadata(sourcePath),
71
+ },
72
+ method: 'POST',
73
+ });
74
+ return parseApiResponse(response, 'Failed to upload image');
75
+ }
59
76
  }
60
77
 
61
78
  export const vaultApiClient = new VaultApiClient();
@@ -2,62 +2,95 @@ import { escapeHtml } from '../domain/vault-utils.js';
2
2
  import { resolveApiUrl } from '../domain/runtime-paths.js';
3
3
 
4
4
  /**
5
- * BacklinksPanel — a collapsible panel below the preview showing
6
- * which files link to the currently open file ("Linked Mentions").
5
+ * BacklinksPanel — renders "Linked Mentions" for the current file.
7
6
  *
8
- * Each mention shows the source file name and the context line
9
- * containing the [[wiki link]]. Clicking a mention navigates to that file.
7
+ * Desktop uses a docked bottom-left panel outside the preview scroll flow.
8
+ * Mobile keeps an inline collapsible panel at the end of the preview content.
10
9
  */
11
10
  export class BacklinksPanel {
12
- constructor({ panelElement, onFileSelect }) {
13
- this.panel = panelElement;
11
+ constructor({
12
+ documentRef = document,
13
+ inlinePanelElement = null,
14
+ onFileSelect,
15
+ panelElement,
16
+ }) {
17
+ this.documentRef = documentRef;
18
+ this.panelRoot = panelElement;
19
+ this.inlinePanel = inlinePanelElement;
14
20
  this.onFileSelect = onFileSelect;
15
21
 
16
- this.header = this.panel?.querySelector('.backlinks-header');
17
- this.toggle = this.panel?.querySelector('.backlinks-toggle');
18
- this.countBadge = this.panel?.querySelector('.backlinks-count');
19
- this.body = this.panel?.querySelector('.backlinks-body');
20
- this.list = this.panel?.querySelector('.backlinks-list');
21
-
22
22
  this._expanded = false;
23
23
  this._currentFile = null;
24
24
  this._fetchController = null;
25
25
  this._backlinks = [];
26
26
 
27
+ const desktopPanel = this.panelRoot?.querySelector('[data-backlinks-variant="dock"]') ?? this.panelRoot;
28
+ this.panels = [
29
+ this._createPanelRef(desktopPanel),
30
+ this._createPanelRef(this.inlinePanel),
31
+ ].filter(Boolean);
32
+
33
+ this.handleDocumentPointerDown = (event) => {
34
+ if (!this._expanded) {
35
+ return;
36
+ }
37
+
38
+ const target = event?.target;
39
+ if (target && this.panels.some(({ panel }) => panel?.contains?.(target))) {
40
+ return;
41
+ }
42
+
43
+ this.close();
44
+ };
45
+
46
+ this.handleDocumentKeyDown = (event) => {
47
+ if (!this._expanded || event?.key !== 'Escape') {
48
+ return;
49
+ }
50
+
51
+ event.preventDefault?.();
52
+ this.close();
53
+ };
54
+
27
55
  this.bindEvents();
28
56
  }
29
57
 
30
58
  bindEvents() {
31
- this.header?.addEventListener('click', () => {
32
- if (this._backlinks.length === 0) return;
33
- this._expanded = !this._expanded;
34
- this._applyExpandState();
35
- });
59
+ this.panels.forEach((refs) => {
60
+ refs.header?.addEventListener('click', () => {
61
+ if (this._backlinks.length === 0) return;
62
+ this._expanded = !this._expanded;
63
+ this._applyExpandState();
64
+ });
36
65
 
37
- this.header?.addEventListener('keydown', (event) => {
38
- if (event.key !== 'Enter' && event.key !== ' ') {
39
- return;
40
- }
66
+ refs.header?.addEventListener('keydown', (event) => {
67
+ if (event.key !== 'Enter' && event.key !== ' ') {
68
+ return;
69
+ }
41
70
 
42
- event.preventDefault();
43
- this.header.click();
71
+ event.preventDefault();
72
+ refs.header.click();
73
+ });
44
74
  });
75
+
76
+ this.documentRef?.addEventListener?.('pointerdown', this.handleDocumentPointerDown);
77
+ this.documentRef?.addEventListener?.('keydown', this.handleDocumentKeyDown);
45
78
  }
46
79
 
47
- /**
48
- * Load backlinks for a file. Call on every file open and after
49
- * content changes (debounced).
50
- */
51
80
  async load(filePath) {
81
+ const fileChanged = filePath !== this._currentFile;
52
82
  this._currentFile = filePath;
53
83
 
84
+ if (fileChanged) {
85
+ this._expanded = false;
86
+ }
87
+
54
88
  if (!filePath) {
55
89
  this._backlinks = [];
56
90
  this._render();
57
91
  return;
58
92
  }
59
93
 
60
- // Abort any in-flight fetch
61
94
  this._fetchController?.abort();
62
95
  this._fetchController = new AbortController();
63
96
 
@@ -68,7 +101,6 @@ export class BacklinksPanel {
68
101
  );
69
102
  const data = await response.json();
70
103
 
71
- // Guard against stale responses (user switched files)
72
104
  if (this._currentFile !== filePath) return;
73
105
 
74
106
  this._backlinks = data.backlinks ?? [];
@@ -81,7 +113,6 @@ export class BacklinksPanel {
81
113
  this._render();
82
114
  }
83
115
 
84
- /** Clear the panel (e.g. when navigating to empty state). */
85
116
  clear() {
86
117
  this._currentFile = null;
87
118
  this._backlinks = [];
@@ -89,100 +120,125 @@ export class BacklinksPanel {
89
120
  this._render();
90
121
  }
91
122
 
92
- // --- Private ---
93
-
94
- _render() {
95
- if (!this.panel) return;
123
+ close() {
124
+ if (!this._expanded) {
125
+ return;
126
+ }
96
127
 
97
- const count = this._backlinks.length;
98
- const label = count === 1 ? '1 Linked Mention' : `${count} Linked Mentions`;
128
+ this._expanded = false;
129
+ this._applyExpandState();
130
+ }
99
131
 
100
- if (this.countBadge) {
101
- this.countBadge.textContent = count > 0 ? String(count) : '';
132
+ _createPanelRef(panel) {
133
+ if (!panel) {
134
+ return null;
102
135
  }
103
136
 
104
- if (this.toggle) {
105
- this.toggle.textContent = label;
106
- }
137
+ return {
138
+ panel,
139
+ header: panel.querySelector('.backlinks-header'),
140
+ toggle: panel.querySelector('.backlinks-toggle'),
141
+ countBadge: panel.querySelector('.backlinks-count'),
142
+ body: panel.querySelector('.backlinks-body'),
143
+ list: panel.querySelector('.backlinks-list'),
144
+ };
145
+ }
107
146
 
108
- // Hide entire panel when no current file
109
- this.panel.classList.toggle('hidden', !this._currentFile);
147
+ _render() {
148
+ const count = this._backlinks.length;
149
+ const label = count === 1 ? 'Linked Mention' : 'Linked Mentions';
150
+ const shouldShow = Boolean(this._currentFile) && count > 0;
110
151
 
111
- // Disable header click when 0 backlinks
112
- this.header?.classList.toggle('backlinks-header-empty', count === 0);
152
+ this.panelRoot?.classList?.toggle('hidden', !shouldShow);
153
+ this.inlinePanel?.classList?.toggle('hidden', !shouldShow);
113
154
 
114
- if (count === 0) {
155
+ if (!shouldShow) {
115
156
  this._expanded = false;
116
- this._applyExpandState();
117
- if (this.list) this.list.innerHTML = '';
118
- return;
119
157
  }
120
158
 
121
- this._renderList();
159
+ this.panels.forEach((refs) => {
160
+ refs.countBadge.textContent = shouldShow ? String(count) : '';
161
+ refs.toggle.textContent = label;
162
+ refs.header?.classList.toggle('backlinks-header-empty', !shouldShow);
163
+
164
+ if (!shouldShow) {
165
+ refs.list.innerHTML = '';
166
+ } else {
167
+ this._renderList(refs.list);
168
+ }
169
+ });
170
+
122
171
  this._applyExpandState();
123
172
  }
124
173
 
125
- _renderList() {
126
- if (!this.list) return;
127
- this.list.innerHTML = '';
174
+ _renderList(listElement) {
175
+ if (!listElement) return;
176
+ listElement.innerHTML = '';
128
177
 
129
- const fragment = document.createDocumentFragment();
178
+ const fragment = this.documentRef?.createDocumentFragment?.();
179
+ const target = fragment ?? listElement;
130
180
 
131
- for (const backlink of this._backlinks) {
132
- const fileName = backlink.file.replace(/\.md$/i, '').split('/').pop();
133
- const dirPath = backlink.file.includes('/')
134
- ? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
135
- : '';
181
+ this._backlinks.forEach((backlink) => {
182
+ target.appendChild(this._createBacklinkItem(backlink));
183
+ });
136
184
 
137
- const item = document.createElement('button');
138
- item.type = 'button';
139
- item.className = 'backlink-item';
140
- item.addEventListener('click', () => {
141
- this.onFileSelect?.(backlink.file);
142
- });
185
+ if (fragment) {
186
+ listElement.appendChild(fragment);
187
+ }
188
+ }
143
189
 
144
- // File name row
145
- const nameRow = document.createElement('div');
146
- nameRow.className = 'backlink-file-name';
147
- nameRow.innerHTML = `
148
- <svg class="backlink-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
149
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
150
- <polyline points="14 2 14 8 20 8"/>
151
- </svg>
152
- <span>${escapeHtml(fileName)}</span>
153
- ${dirPath ? `<span class="backlink-dir">${escapeHtml(dirPath)}</span>` : ''}
154
- `;
155
- item.appendChild(nameRow);
156
-
157
- // Context snippets
158
- for (const ctx of backlink.contexts.slice(0, 3)) {
159
- const contextEl = document.createElement('div');
160
- contextEl.className = 'backlink-context';
161
- contextEl.textContent = ctx;
162
- item.appendChild(contextEl);
163
- }
190
+ _createBacklinkItem(backlink) {
191
+ const fileName = backlink.file.replace(/\.md$/i, '').split('/').pop();
192
+ const dirPath = backlink.file.includes('/')
193
+ ? backlink.file.substring(0, backlink.file.lastIndexOf('/'))
194
+ : '';
195
+
196
+ const item = this.documentRef.createElement('button');
197
+ item.type = 'button';
198
+ item.className = 'backlink-item';
199
+ item.addEventListener('click', () => {
200
+ this.close();
201
+ this.onFileSelect?.(backlink.file);
202
+ });
164
203
 
165
- if (backlink.contexts.length > 3) {
166
- const more = document.createElement('div');
167
- more.className = 'backlink-context backlink-more';
168
- more.textContent = `+${backlink.contexts.length - 3} more`;
169
- item.appendChild(more);
170
- }
204
+ const nameRow = this.documentRef.createElement('div');
205
+ nameRow.className = 'backlink-file-name';
206
+ nameRow.innerHTML = `
207
+ <svg class="backlink-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
208
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
209
+ <polyline points="14 2 14 8 20 8"/>
210
+ </svg>
211
+ <span>${escapeHtml(fileName)}</span>
212
+ ${dirPath ? `<span class="backlink-dir">${escapeHtml(dirPath)}</span>` : ''}
213
+ `;
214
+ item.appendChild(nameRow);
215
+
216
+ backlink.contexts.slice(0, 3).forEach((ctx) => {
217
+ const contextElement = this.documentRef.createElement('div');
218
+ contextElement.className = 'backlink-context';
219
+ contextElement.textContent = ctx;
220
+ item.appendChild(contextElement);
221
+ });
171
222
 
172
- fragment.appendChild(item);
223
+ if (backlink.contexts.length > 3) {
224
+ const moreElement = this.documentRef.createElement('div');
225
+ moreElement.className = 'backlink-context backlink-more';
226
+ moreElement.textContent = `+${backlink.contexts.length - 3} more`;
227
+ item.appendChild(moreElement);
173
228
  }
174
229
 
175
- this.list.appendChild(fragment);
230
+ return item;
176
231
  }
177
232
 
178
233
  _applyExpandState() {
179
- this.panel?.classList.toggle('expanded', this._expanded);
180
- this.header?.setAttribute('aria-expanded', String(this._expanded));
181
- this.header?.setAttribute('aria-disabled', String(this._backlinks.length === 0));
182
- this.body?.setAttribute('aria-hidden', String(!this._expanded));
183
-
184
- if (this.body) {
185
- this.body.toggleAttribute('inert', !this._expanded);
186
- }
234
+ const expanded = this._expanded && this._backlinks.length > 0;
235
+
236
+ this.panels.forEach((refs) => {
237
+ refs.panel.classList.toggle('expanded', expanded);
238
+ refs.header?.setAttribute('aria-expanded', String(expanded));
239
+ refs.header?.setAttribute('aria-disabled', String(this._backlinks.length === 0));
240
+ refs.body?.setAttribute('aria-hidden', String(!expanded));
241
+ refs.body?.toggleAttribute('inert', !expanded);
242
+ });
187
243
  }
188
244
  }
@@ -0,0 +1,68 @@
1
+ import markdownIt from 'markdown-it';
2
+ import hljs from 'highlight.js';
3
+
4
+ import { escapeHtml } from '../domain/vault-utils.js';
5
+
6
+ function renderToken(renderer, tokens, index, options, env, self) {
7
+ return renderer?.(tokens, index, options, env, self) ?? self.renderToken(tokens, index, options);
8
+ }
9
+
10
+ function renderPlainTextCommentHtml(text = '') {
11
+ const escaped = escapeHtml(String(text ?? ''));
12
+ return `<p>${escaped.replace(/\n/g, '<br>')}</p>`;
13
+ }
14
+
15
+ function createCommentMarkdownRenderer() {
16
+ const markdown = markdownIt({
17
+ breaks: true,
18
+ highlight(source, language) {
19
+ try {
20
+ if (language && hljs.getLanguage(language)) {
21
+ return hljs.highlight(source, {
22
+ ignoreIllegals: true,
23
+ language,
24
+ }).value;
25
+ }
26
+
27
+ return hljs.highlightAuto(source).value;
28
+ } catch {
29
+ return escapeHtml(source);
30
+ }
31
+ },
32
+ html: false,
33
+ linkify: true,
34
+ typographer: true,
35
+ });
36
+
37
+ const fallbackLinkOpen = markdown.renderer.rules.link_open;
38
+ const fallbackTableOpen = markdown.renderer.rules.table_open;
39
+ const fallbackTableClose = markdown.renderer.rules.table_close;
40
+
41
+ markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
42
+ tokens[index].attrSet('target', '_blank');
43
+ tokens[index].attrSet('rel', 'noopener noreferrer');
44
+ return renderToken(fallbackLinkOpen, tokens, index, options, env, self);
45
+ };
46
+
47
+ markdown.renderer.rules.table_open = (tokens, index, options, env, self) => (
48
+ `<div class="comment-markdown-table">${renderToken(fallbackTableOpen, tokens, index, options, env, self)}`
49
+ );
50
+
51
+ markdown.renderer.rules.table_close = (tokens, index, options, env, self) => (
52
+ `${renderToken(fallbackTableClose, tokens, index, options, env, self)}</div>`
53
+ );
54
+
55
+ return markdown;
56
+ }
57
+
58
+ const commentMarkdownRenderer = createCommentMarkdownRenderer();
59
+
60
+ export function renderCommentMarkdownToHtml(markdownText = '') {
61
+ const normalizedMarkdown = String(markdownText ?? '');
62
+
63
+ try {
64
+ return commentMarkdownRenderer.render(normalizedMarkdown);
65
+ } catch {
66
+ return renderPlainTextCommentHtml(normalizedMarkdown);
67
+ }
68
+ }