collabmd 0.1.19 → 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.
package/public/index.html CHANGED
@@ -364,16 +364,26 @@
364
364
  <div class="preview-body">
365
365
  <div id="previewContainer" class="preview-container">
366
366
  <div id="previewContent" class="preview-content"></div>
367
-
368
- <!-- Backlinks (Linked Mentions) -->
369
- <div id="backlinksPanel" class="backlinks-panel hidden">
367
+ <div id="backlinksInlinePanel" class="backlinks-panel backlinks-panel-inline hidden">
368
+ <div class="backlinks-header" role="button" tabindex="0" aria-expanded="false">
369
+ <svg class="backlinks-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
370
+ <span class="backlinks-toggle">0 Linked Mentions</span>
371
+ <span class="backlinks-count"></span>
372
+ </div>
373
+ <div class="backlinks-body" aria-hidden="true" inert>
374
+ <div class="backlinks-list"></div>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ <div id="backlinksPanel" class="backlinks-panel-layer hidden">
379
+ <div class="backlinks-panel backlinks-panel-dock" data-backlinks-variant="dock">
370
380
  <div class="backlinks-header" role="button" tabindex="0" aria-expanded="false">
371
381
  <svg class="backlinks-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
372
382
  <span class="backlinks-toggle">0 Linked Mentions</span>
373
383
  <span class="backlinks-count"></span>
374
384
  </div>
375
385
  <div class="backlinks-body" aria-hidden="true" inert>
376
- <div class="backlinks-list" id="backlinksList"></div>
386
+ <div class="backlinks-list"></div>
377
387
  </div>
378
388
  </div>
379
389
  </div>
@@ -12,6 +12,17 @@ function normalizeWorkspaceChange(workspaceChange = {}) {
12
12
  }
13
13
 
14
14
  export const gitFeature = {
15
+ formatPullBackupToast(pullBackup = null) {
16
+ const fileCount = Number(pullBackup?.fileCount || 0);
17
+ if (fileCount === 1) {
18
+ return 'Pulled latest changes. 1 overlapping local file was backed up.';
19
+ }
20
+ if (fileCount > 1) {
21
+ return `Pulled latest changes. ${fileCount} overlapping local files were backed up.`;
22
+ }
23
+ return 'Pulled latest changes. Overlapping local changes were recorded in a pull backup.';
24
+ },
25
+
15
26
  setGitOperationStatus(message = '') {
16
27
  const badge = this.elements.gitOperationStatus;
17
28
  if (!badge) {
@@ -92,7 +103,11 @@ export const gitFeature = {
92
103
  });
93
104
  const data = await response.json();
94
105
  if (!response.ok) {
95
- throw new Error(data.error || 'Git action failed');
106
+ const error = new Error(data.error || 'Git action failed');
107
+ if (typeof data?.code === 'string') {
108
+ error.code = data.code;
109
+ }
110
+ throw error;
96
111
  }
97
112
  return data;
98
113
  },
@@ -242,7 +257,20 @@ export const gitFeature = {
242
257
  result,
243
258
  showLocalFileToast: true,
244
259
  });
260
+ if (result.pullBackup) {
261
+ this.toastController.show(this.formatPullBackupToast(result.pullBackup), 5000);
262
+ }
245
263
  } catch (error) {
264
+ if (error?.code === 'pull_diverged_ff_only') {
265
+ this.toastController.show('Cannot pull because local and remote commits have diverged. Fast-forward only pull is not possible.', 5000);
266
+ return;
267
+ }
268
+
269
+ if (error?.code === 'pull_conflicted_after_autostash') {
270
+ this.toastController.show('Pull updated the branch, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.', 6000);
271
+ return;
272
+ }
273
+
246
274
  this.toastController.show(error.message || 'Failed to pull branch');
247
275
  }
248
276
  });
@@ -61,6 +61,7 @@ export function bindAppShellElements(doc = document) {
61
61
  emptyStateNewFileBtn: doc.getElementById('emptyStateNewFileBtn'),
62
62
  emptyStateSearchBtn: doc.getElementById('emptyStateSearchBtn'),
63
63
  backlinksPanel: doc.getElementById('backlinksPanel'),
64
+ backlinksInlinePanel: doc.getElementById('backlinksInlinePanel'),
64
65
  commentSelectionButton: doc.getElementById('commentSelectionBtn'),
65
66
  commentsDrawer: doc.getElementById('commentsDrawer'),
66
67
  commentsDrawerEmpty: doc.getElementById('commentsDrawerEmpty'),
@@ -131,6 +131,7 @@ export class CollabMdAppShell {
131
131
  this.gitPanel = new GitPanelController({
132
132
  enabled: this.runtimeConfig.gitEnabled !== false,
133
133
  onCommitStaged: () => this.openGitCommitDialog(),
134
+ onOpenPullBackup: (filePath) => filePath && this.navigation.navigateToFile(filePath),
134
135
  onPullBranch: () => this.pullGitBranch(),
135
136
  onPushBranch: () => this.pushGitBranch(),
136
137
  onRepoChange: (isGitRepo, status) => this.handleGitRepoChange(isGitRepo, status),
@@ -149,6 +150,7 @@ export class CollabMdAppShell {
149
150
  this.scrollSyncController.suspendSync(250);
150
151
  this.session?.scrollToLine(sourceLine, 0);
151
152
  },
153
+ onWillOpen: () => this.commentUi?.closeDrawer(),
152
154
  });
153
155
  this.videoEmbed = new VideoEmbedController({
154
156
  previewElement: this.elements.previewContent,
@@ -196,6 +198,7 @@ export class CollabMdAppShell {
196
198
  scrollEditorToLine: (lineNumber, viewportRatio) => this.session?.scrollToLine(lineNumber, viewportRatio),
197
199
  });
198
200
  this.backlinksPanel = new BacklinksPanel({
201
+ inlinePanelElement: this.elements.backlinksInlinePanel,
199
202
  onFileSelect: (filePath) => this.handleFileSelection(filePath, { closeSidebarOnMobile: true }),
200
203
  panelElement: this.elements.backlinksPanel,
201
204
  });
@@ -214,6 +217,7 @@ export class CollabMdAppShell {
214
217
  commentsDrawerList: this.elements.commentsDrawerList,
215
218
  commentsToggleButton: this.elements.commentsToggleButton,
216
219
  editorContainer: this.elements.editorContainer,
220
+ onWillOpenDrawer: () => this.outlineController.close(),
217
221
  previewContainer: this.elements.previewContainer,
218
222
  previewElement: this.elements.previewContent,
219
223
  onCreateThread: ({ anchor, body }) => this.session?.createCommentThread({ anchor, body }),
@@ -14,6 +14,7 @@ import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
14
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,
@@ -33,6 +34,38 @@ import { plantUmlLanguage, plantUmlLanguageDescription } from '../domain/plantum
33
34
  import { handleImagePasteEvent } from './editor-paste-utils.js';
34
35
 
35
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
+ }
36
69
 
37
70
  function createEditorTheme(theme) {
38
71
  const activeLineBackground = theme === 'dark'
@@ -47,6 +80,21 @@ function createEditorTheme(theme) {
47
80
  const selectionBorder = theme === 'dark'
48
81
  ? 'oklch(from var(--color-primary) l c h / 0.65)'
49
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)';
50
98
 
51
99
  return EditorView.theme({
52
100
  '&': {
@@ -54,13 +102,14 @@ function createEditorTheme(theme) {
54
102
  color: 'var(--color-text)',
55
103
  },
56
104
  '.cm-content': {
57
- caretColor: 'var(--color-primary)',
105
+ caretColor,
58
106
  fontFamily: 'var(--font-mono)',
59
107
  padding: '16px 0',
60
108
  },
61
109
  '.cm-cursor, .cm-dropCursor': {
62
- borderLeftColor: 'var(--color-primary)',
63
- borderLeftWidth: '2px',
110
+ borderLeftColor: caretColor,
111
+ borderLeftWidth: '3px',
112
+ marginLeft: '-1px',
64
113
  },
65
114
  '.cm-foldPlaceholder': {
66
115
  backgroundColor: 'var(--color-surface-dynamic)',
@@ -86,8 +135,17 @@ function createEditorTheme(theme) {
86
135
  color: 'var(--color-text-muted)',
87
136
  },
88
137
  '.cm-matchingBracket': {
89
- backgroundColor: 'var(--color-primary-highlight)',
90
- 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',
91
149
  },
92
150
  '.cm-selectionMatch': {
93
151
  backgroundColor: 'var(--color-primary-highlight)',
@@ -191,7 +249,7 @@ export class EditorViewAdapter {
191
249
  EditorState.allowMultipleSelections.of(true),
192
250
  indentOnInput(),
193
251
  syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
194
- bracketMatching(),
252
+ bracketMatching({ renderMatch: renderBracketMatch }),
195
253
  closeBrackets(),
196
254
  autocompletion({
197
255
  override: [wikiLinkCompletions(this.getFileList)],
@@ -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
  }
@@ -279,6 +279,7 @@ export class CommentUiController {
279
279
  commentsDrawerList,
280
280
  commentsToggleButton,
281
281
  editorContainer,
282
+ onWillOpenDrawer,
282
283
  onCreateThread,
283
284
  onNavigateToLine,
284
285
  onReplyToThread,
@@ -293,6 +294,7 @@ export class CommentUiController {
293
294
  this.commentsDrawerList = commentsDrawerList;
294
295
  this.commentsToggleButton = commentsToggleButton;
295
296
  this.editorContainer = editorContainer;
297
+ this.onWillOpenDrawer = onWillOpenDrawer;
296
298
  this.onCreateThread = onCreateThread;
297
299
  this.onNavigateToLine = onNavigateToLine;
298
300
  this.onReplyToThread = onReplyToThread;
@@ -384,13 +386,30 @@ export class CommentUiController {
384
386
  this.scheduleLayoutRefresh();
385
387
  };
386
388
  this.handleDocumentPointerDown = (event) => {
387
- if (!this.activeCard || !this.cardRoot) {
389
+ const target = event.target;
390
+
391
+ if (this.activeCard && this.cardRoot) {
392
+ if (this.cardRoot.contains(target)) {
393
+ return;
394
+ }
395
+ this.closeCard();
396
+ }
397
+
398
+ if (!this.drawerOpen) {
388
399
  return;
389
400
  }
390
- if (this.cardRoot.contains(event.target)) {
401
+
402
+ if (
403
+ target instanceof Node
404
+ && (
405
+ this.commentsDrawer?.contains(target)
406
+ || this.commentsToggleButton?.contains(target)
407
+ )
408
+ ) {
391
409
  return;
392
410
  }
393
- this.closeCard();
411
+
412
+ this.closeDrawer();
394
413
  };
395
414
  this.handleDocumentKeyDown = (event) => {
396
415
  if (event.key === 'Escape' && this.reactionPicker) {
@@ -414,8 +433,7 @@ export class CommentUiController {
414
433
  this.openComposerForSelection('toolbar');
415
434
  });
416
435
  this.commentsToggleButton?.addEventListener('click', () => {
417
- this.drawerOpen = !this.drawerOpen;
418
- this.render();
436
+ this.setDrawerOpen(!this.drawerOpen);
419
437
  });
420
438
  this.previewContainer?.addEventListener('scroll', this.handlePreviewScroll, { passive: true });
421
439
  this.editorContainer?.addEventListener('pointerdown', this.handleEditorPointerDown);
@@ -569,6 +587,24 @@ export class CommentUiController {
569
587
  this.render();
570
588
  }
571
589
 
590
+ setDrawerOpen(nextState) {
591
+ const normalizedState = Boolean(nextState);
592
+ if (normalizedState === this.drawerOpen) {
593
+ return;
594
+ }
595
+
596
+ if (normalizedState) {
597
+ this.onWillOpenDrawer?.();
598
+ }
599
+
600
+ this.drawerOpen = normalizedState;
601
+ this.render();
602
+ }
603
+
604
+ closeDrawer() {
605
+ this.setDrawerOpen(false);
606
+ }
607
+
572
608
  refreshLayout() {
573
609
  this.renderEditorLayer();
574
610
  this.renderPreviewLayer();