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
@@ -87,6 +87,7 @@ export class GitPanelController {
87
87
  constructor({
88
88
  enabled = true,
89
89
  onCommitStaged = () => {},
90
+ onOpenPullBackup = () => {},
90
91
  onPullBranch = () => {},
91
92
  onPushBranch = () => {},
92
93
  onRepoChange = () => {},
@@ -100,6 +101,7 @@ export class GitPanelController {
100
101
  } = {}) {
101
102
  this.enabled = enabled;
102
103
  this.onCommitStaged = onCommitStaged;
104
+ this.onOpenPullBackup = onOpenPullBackup;
103
105
  this.onPullBranch = onPullBranch;
104
106
  this.onPushBranch = onPushBranch;
105
107
  this.onRepoChange = onRepoChange;
@@ -111,6 +113,7 @@ export class GitPanelController {
111
113
  this.searchInput = searchInput;
112
114
  this.toastController = toastController;
113
115
  this.panel = document.getElementById('gitPanel');
116
+ this.pullBackups = [];
114
117
  this.status = null;
115
118
  this.active = false;
116
119
  this.refreshTimer = null;
@@ -147,6 +150,18 @@ export class GitPanelController {
147
150
  return;
148
151
  }
149
152
 
153
+ const pullBackupButton = event.target instanceof Element
154
+ ? event.target.closest('[data-git-pull-backup-path]')
155
+ : null;
156
+ if (pullBackupButton) {
157
+ const summaryPath = pullBackupButton.getAttribute('data-git-pull-backup-path');
158
+ if (!summaryPath) {
159
+ return;
160
+ }
161
+ this.onOpenPullBackup(summaryPath);
162
+ return;
163
+ }
164
+
150
165
  const actionButton = event.target instanceof Element
151
166
  ? event.target.closest('[data-git-file-action]')
152
167
  : null;
@@ -243,6 +258,7 @@ export class GitPanelController {
243
258
  sections: [],
244
259
  summary: { changedFiles: 0 },
245
260
  };
261
+ this.pullBackups = [];
246
262
  this.onRepoChange(false, this.status);
247
263
  this.render();
248
264
  return this.status;
@@ -256,6 +272,19 @@ export class GitPanelController {
256
272
  }
257
273
 
258
274
  this.status = data;
275
+ this.pullBackups = [];
276
+ if (data.isGitRepo) {
277
+ try {
278
+ const backupResponse = await fetch(resolveApiUrl('/git/pull-backups'));
279
+ const backupData = await backupResponse.json();
280
+ if (!backupResponse.ok) {
281
+ throw new Error(backupData.error || 'Failed to load pull backups');
282
+ }
283
+ this.pullBackups = Array.isArray(backupData.backups) ? backupData.backups : [];
284
+ } catch (backupError) {
285
+ console.error('[git-panel] Failed to load pull backups:', backupError);
286
+ }
287
+ }
259
288
  this.onRepoChange(Boolean(data.isGitRepo), data);
260
289
  this.render();
261
290
  return data;
@@ -267,6 +296,7 @@ export class GitPanelController {
267
296
  sections: [],
268
297
  summary: { changedFiles: 0 },
269
298
  };
299
+ this.pullBackups = [];
270
300
  this.onRepoChange(false, this.status);
271
301
  this.render();
272
302
  return this.status;
@@ -367,6 +397,47 @@ export class GitPanelController {
367
397
  `;
368
398
  }
369
399
 
400
+ renderPullBackupsSection() {
401
+ if (!Array.isArray(this.pullBackups) || this.pullBackups.length === 0) {
402
+ return '';
403
+ }
404
+
405
+ return `
406
+ <section class="git-section">
407
+ <button class="git-section-header" type="button" data-git-section-toggle="pull-backups">
408
+ ${chevronSvg(this.collapsedSections.has('pull-backups'))}
409
+ Pull Backups
410
+ <span class="git-section-count">${this.pullBackups.length}</span>
411
+ </button>
412
+ <div class="git-file-list${this.collapsedSections.has('pull-backups') ? ' hidden' : ''}">
413
+ ${this.pullBackups.map((backup) => this.renderPullBackup(backup)).join('')}
414
+ </div>
415
+ </section>
416
+ `;
417
+ }
418
+
419
+ renderPullBackup(backup) {
420
+ const createdAt = String(backup?.createdAt || '').replace('T', ' ').replace(/\.\d+Z?$/u, 'Z');
421
+ const fileCount = Number(backup?.fileCount || 0);
422
+
423
+ return `
424
+ <div class="git-file-row">
425
+ <button
426
+ class="git-file-item"
427
+ type="button"
428
+ data-git-pull-backup-path="${escapeHtml(backup.summaryPath || '')}"
429
+ >
430
+ ${fileIconSvg()}
431
+ <span class="git-file-copy">
432
+ <span class="git-file-name">Pull backup ${escapeHtml(backup.id || '')}</span>
433
+ <span class="git-file-path">${escapeHtml(`${createdAt} · ${backup.branch || 'HEAD'} · ${fileCount} file${fileCount === 1 ? '' : 's'}`)}</span>
434
+ </span>
435
+ <span class="git-status-badge modified">BK</span>
436
+ </button>
437
+ </div>
438
+ `;
439
+ }
440
+
370
441
  renderFile(file) {
371
442
  const isActive = this.selection.path === file.path && this.selection.scope === file.scope;
372
443
  const dirPath = getPathDir(file.path);
@@ -449,6 +520,7 @@ export class GitPanelController {
449
520
  }
450
521
 
451
522
  const branch = this.status.branch ?? {};
523
+ const pullBackupsMarkup = this.renderPullBackupsSection();
452
524
  const sectionMarkup = (this.status.sections ?? [])
453
525
  .map((section) => this.renderSection(section))
454
526
  .filter(Boolean)
@@ -492,6 +564,7 @@ export class GitPanelController {
492
564
  </button>
493
565
  </div>
494
566
  </div>
567
+ ${pullBackupsMarkup}
495
568
  ${sectionMarkup || '<div class="git-panel-empty">No local changes</div>'}
496
569
  ${hasChanges ? `
497
570
  <div class="git-panel-footer">
@@ -0,0 +1,394 @@
1
+ const MIN_SCALE = 1;
2
+ const MAX_SCALE = 6;
3
+ const ZOOM_STEP = 0.25;
4
+ const CLICK_ZOOM_SCALE = 2;
5
+ const DRAG_THRESHOLD_PX = 6;
6
+
7
+ export function clampImageLightboxScale(value) {
8
+ const numericValue = Number(value);
9
+ if (!Number.isFinite(numericValue)) {
10
+ return MIN_SCALE;
11
+ }
12
+
13
+ return Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.round(numericValue * 100) / 100));
14
+ }
15
+
16
+ export function clampImageLightboxOffset(offset, {
17
+ contentSize = 0,
18
+ scale = 1,
19
+ viewportSize = 0,
20
+ } = {}) {
21
+ const numericOffset = Number(offset);
22
+ const numericContentSize = Number(contentSize);
23
+ const numericScale = Number(scale);
24
+ const numericViewportSize = Number(viewportSize);
25
+
26
+ if (
27
+ !Number.isFinite(numericOffset)
28
+ || !Number.isFinite(numericContentSize)
29
+ || !Number.isFinite(numericScale)
30
+ || !Number.isFinite(numericViewportSize)
31
+ ) {
32
+ return 0;
33
+ }
34
+
35
+ const overflow = ((numericContentSize * numericScale) - numericViewportSize) / 2;
36
+ if (overflow <= 0) {
37
+ return 0;
38
+ }
39
+
40
+ return Math.min(Math.max(numericOffset, -overflow), overflow);
41
+ }
42
+
43
+ export class ImageLightboxController {
44
+ constructor({
45
+ previewElement,
46
+ documentRef = document,
47
+ windowRef = window,
48
+ }) {
49
+ this.previewElement = previewElement;
50
+ this.document = documentRef;
51
+ this.window = windowRef;
52
+ this.overlayRoot = null;
53
+ this.viewport = null;
54
+ this.imageElement = null;
55
+ this.zoomLabel = null;
56
+ this.titleElement = null;
57
+ this.isOpen = false;
58
+ this.scale = MIN_SCALE;
59
+ this.offsetX = 0;
60
+ this.offsetY = 0;
61
+ this.activePointerId = null;
62
+ this.dragStartX = 0;
63
+ this.dragStartY = 0;
64
+ this.dragOriginX = 0;
65
+ this.dragOriginY = 0;
66
+ this.didDrag = false;
67
+ this.resetButton = null;
68
+
69
+ this.handlePreviewClick = (event) => {
70
+ const target = event.target;
71
+ if (!(target instanceof this.window.Element)) {
72
+ return;
73
+ }
74
+
75
+ const image = target.closest('img');
76
+ if (!image || !this.previewElement?.contains(image)) {
77
+ return;
78
+ }
79
+
80
+ if (image.closest('[data-video-overlay-root="true"], [data-excalidraw-overlay-root="true"]')) {
81
+ return;
82
+ }
83
+
84
+ event.preventDefault();
85
+ event.stopImmediatePropagation?.();
86
+ this.openFromImage(image);
87
+ };
88
+
89
+ this.handleKeyDown = (event) => {
90
+ if (!this.isOpen) {
91
+ return;
92
+ }
93
+
94
+ if (event.key === 'Escape') {
95
+ event.preventDefault();
96
+ this.close();
97
+ return;
98
+ }
99
+
100
+ if (event.key === '+' || event.key === '=') {
101
+ event.preventDefault();
102
+ this.zoomBy(ZOOM_STEP);
103
+ return;
104
+ }
105
+
106
+ if (event.key === '-') {
107
+ event.preventDefault();
108
+ this.zoomBy(-ZOOM_STEP);
109
+ return;
110
+ }
111
+
112
+ if (event.key === '0') {
113
+ event.preventDefault();
114
+ this.resetView();
115
+ }
116
+ };
117
+
118
+ this.handleWindowResize = () => {
119
+ if (!this.isOpen) {
120
+ return;
121
+ }
122
+
123
+ this.clampOffsets();
124
+ this.syncTransform();
125
+ };
126
+
127
+ this.previewElement?.addEventListener('click', this.handlePreviewClick);
128
+ this.document.addEventListener('keydown', this.handleKeyDown);
129
+ this.window.addEventListener('resize', this.handleWindowResize);
130
+ }
131
+
132
+ destroy() {
133
+ this.previewElement?.removeEventListener('click', this.handlePreviewClick);
134
+ this.document.removeEventListener('keydown', this.handleKeyDown);
135
+ this.window.removeEventListener('resize', this.handleWindowResize);
136
+ this.close();
137
+ this.overlayRoot?.remove();
138
+ this.overlayRoot = null;
139
+ this.viewport = null;
140
+ this.imageElement = null;
141
+ this.zoomLabel = null;
142
+ this.titleElement = null;
143
+ this.resetButton = null;
144
+ }
145
+
146
+ openFromImage(image) {
147
+ const src = image?.currentSrc || image?.getAttribute?.('src') || '';
148
+ if (!src) {
149
+ return false;
150
+ }
151
+
152
+ this.ensureOverlayRoot();
153
+ this.resetView();
154
+ this.titleElement.textContent = image.getAttribute('alt') || 'Image preview';
155
+ this.imageElement.src = src;
156
+ this.imageElement.alt = image.getAttribute('alt') || '';
157
+ this.overlayRoot.hidden = false;
158
+ this.isOpen = true;
159
+ this.document.body.classList.add('image-lightbox-open');
160
+ this.syncTransform();
161
+ this.overlayRoot.focus?.();
162
+ return true;
163
+ }
164
+
165
+ close() {
166
+ if (!this.isOpen && !this.overlayRoot) {
167
+ return;
168
+ }
169
+
170
+ this.activePointerId = null;
171
+ this.didDrag = false;
172
+ this.viewport?.classList?.remove('is-dragging');
173
+ if (this.overlayRoot) {
174
+ this.overlayRoot.hidden = true;
175
+ }
176
+ this.isOpen = false;
177
+ this.document.body.classList.remove('image-lightbox-open');
178
+ }
179
+
180
+ resetView() {
181
+ this.scale = MIN_SCALE;
182
+ this.offsetX = 0;
183
+ this.offsetY = 0;
184
+ this.syncTransform();
185
+ }
186
+
187
+ zoomBy(delta) {
188
+ this.setScale(this.scale + delta);
189
+ }
190
+
191
+ setScale(nextScale) {
192
+ this.scale = clampImageLightboxScale(nextScale);
193
+ this.clampOffsets();
194
+ this.syncTransform();
195
+ }
196
+
197
+ clampOffsets() {
198
+ if (!this.viewport || !this.imageElement) {
199
+ this.offsetX = 0;
200
+ this.offsetY = 0;
201
+ return;
202
+ }
203
+
204
+ this.offsetX = clampImageLightboxOffset(this.offsetX, {
205
+ contentSize: this.imageElement.offsetWidth || 0,
206
+ scale: this.scale,
207
+ viewportSize: this.viewport.clientWidth || 0,
208
+ });
209
+ this.offsetY = clampImageLightboxOffset(this.offsetY, {
210
+ contentSize: this.imageElement.offsetHeight || 0,
211
+ scale: this.scale,
212
+ viewportSize: this.viewport.clientHeight || 0,
213
+ });
214
+ }
215
+
216
+ syncTransform() {
217
+ if (!this.imageElement) {
218
+ return;
219
+ }
220
+
221
+ this.imageElement.style.transform = `translate3d(${this.offsetX}px, ${this.offsetY}px, 0) scale(${this.scale})`;
222
+ this.imageElement.style.cursor = this.scale > MIN_SCALE ? 'grab' : 'zoom-in';
223
+ if (this.zoomLabel) {
224
+ this.zoomLabel.textContent = `${Math.round(this.scale * 100)}%`;
225
+ }
226
+ if (this.resetButton) {
227
+ this.resetButton.disabled = this.scale <= MIN_SCALE && this.offsetX === 0 && this.offsetY === 0;
228
+ }
229
+ }
230
+
231
+ ensureOverlayRoot() {
232
+ if (this.overlayRoot?.isConnected && this.overlayRoot.parentElement === this.document.body) {
233
+ return this.overlayRoot;
234
+ }
235
+
236
+ const overlayRoot = this.document.createElement('div');
237
+ overlayRoot.className = 'image-lightbox-root';
238
+ overlayRoot.dataset.imageLightboxRoot = 'true';
239
+ overlayRoot.hidden = true;
240
+ overlayRoot.tabIndex = -1;
241
+
242
+ const backdrop = this.document.createElement('button');
243
+ backdrop.className = 'image-lightbox-backdrop';
244
+ backdrop.type = 'button';
245
+ backdrop.setAttribute('aria-label', 'Close image preview');
246
+ backdrop.addEventListener('click', (event) => {
247
+ event.preventDefault();
248
+ event.stopPropagation();
249
+ this.close();
250
+ });
251
+
252
+ const shell = this.document.createElement('div');
253
+ shell.className = 'image-lightbox-shell';
254
+ shell.setAttribute('role', 'dialog');
255
+ shell.setAttribute('aria-modal', 'true');
256
+ shell.setAttribute('aria-label', 'Image preview');
257
+ shell.addEventListener('click', (event) => event.stopPropagation());
258
+ shell.addEventListener('pointerdown', (event) => event.stopPropagation());
259
+
260
+ const toolbar = this.document.createElement('div');
261
+ toolbar.className = 'image-lightbox-toolbar';
262
+
263
+ const title = this.document.createElement('div');
264
+ title.className = 'image-lightbox-title';
265
+ title.textContent = 'Image preview';
266
+
267
+ const controls = this.document.createElement('div');
268
+ controls.className = 'image-lightbox-controls';
269
+
270
+ const zoomLabel = this.document.createElement('span');
271
+ zoomLabel.className = 'image-lightbox-zoom-label';
272
+ zoomLabel.textContent = '100%';
273
+ const resetButton = this.createControlButton('Reset', 'Reset zoom and position', () => this.resetView());
274
+ const closeButton = this.createControlButton('Close', 'Close image preview', () => this.close());
275
+
276
+ controls.append(zoomLabel, resetButton, closeButton);
277
+ toolbar.append(title, controls);
278
+
279
+ const viewport = this.document.createElement('div');
280
+ viewport.className = 'image-lightbox-viewport';
281
+
282
+ const image = this.document.createElement('img');
283
+ image.className = 'image-lightbox-image';
284
+ image.alt = '';
285
+ image.addEventListener('load', () => {
286
+ this.clampOffsets();
287
+ this.syncTransform();
288
+ });
289
+
290
+ viewport.addEventListener('wheel', (event) => {
291
+ if (!this.isOpen) {
292
+ return;
293
+ }
294
+
295
+ event.preventDefault();
296
+ this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
297
+ }, { passive: false });
298
+
299
+ viewport.addEventListener('pointerdown', (event) => {
300
+ if (!this.isOpen || this.scale <= MIN_SCALE || event.button !== 0) {
301
+ return;
302
+ }
303
+
304
+ this.activePointerId = event.pointerId;
305
+ this.dragStartX = event.clientX;
306
+ this.dragStartY = event.clientY;
307
+ this.dragOriginX = this.offsetX;
308
+ this.dragOriginY = this.offsetY;
309
+ this.didDrag = false;
310
+ viewport.classList.add('is-dragging');
311
+ viewport.setPointerCapture?.(event.pointerId);
312
+ event.preventDefault();
313
+ });
314
+
315
+ viewport.addEventListener('pointermove', (event) => {
316
+ if (!this.isOpen || this.activePointerId !== event.pointerId) {
317
+ return;
318
+ }
319
+
320
+ if (
321
+ !this.didDrag
322
+ && (Math.abs(event.clientX - this.dragStartX) >= DRAG_THRESHOLD_PX
323
+ || Math.abs(event.clientY - this.dragStartY) >= DRAG_THRESHOLD_PX)
324
+ ) {
325
+ this.didDrag = true;
326
+ }
327
+
328
+ this.offsetX = this.dragOriginX + (event.clientX - this.dragStartX);
329
+ this.offsetY = this.dragOriginY + (event.clientY - this.dragStartY);
330
+ this.clampOffsets();
331
+ this.syncTransform();
332
+ });
333
+
334
+ const finishDrag = (event) => {
335
+ if (this.activePointerId !== event.pointerId) {
336
+ return;
337
+ }
338
+
339
+ this.activePointerId = null;
340
+ viewport.classList.remove('is-dragging');
341
+ viewport.releasePointerCapture?.(event.pointerId);
342
+ this.imageElement.style.cursor = this.scale > MIN_SCALE ? 'grab' : 'zoom-in';
343
+ };
344
+
345
+ viewport.addEventListener('pointerup', finishDrag);
346
+ viewport.addEventListener('pointercancel', finishDrag);
347
+ image.addEventListener('click', (event) => {
348
+ if (!this.isOpen) {
349
+ return;
350
+ }
351
+
352
+ event.preventDefault();
353
+ event.stopPropagation();
354
+ if (this.didDrag) {
355
+ this.didDrag = false;
356
+ return;
357
+ }
358
+
359
+ if (this.scale <= MIN_SCALE) {
360
+ this.setScale(CLICK_ZOOM_SCALE);
361
+ }
362
+ });
363
+
364
+ viewport.appendChild(image);
365
+ shell.append(toolbar, viewport);
366
+ overlayRoot.append(backdrop, shell);
367
+ this.document.body.appendChild(overlayRoot);
368
+
369
+ this.overlayRoot = overlayRoot;
370
+ this.viewport = viewport;
371
+ this.imageElement = image;
372
+ this.zoomLabel = zoomLabel;
373
+ this.titleElement = title;
374
+ this.resetButton = resetButton;
375
+ this.syncTransform();
376
+ return overlayRoot;
377
+ }
378
+
379
+ createControlButton(label, ariaLabel, onClick, {
380
+ className = '',
381
+ } = {}) {
382
+ const button = this.document.createElement('button');
383
+ button.className = `image-lightbox-btn ${className}`.trim();
384
+ button.type = 'button';
385
+ button.setAttribute('aria-label', ariaLabel);
386
+ button.textContent = label;
387
+ button.addEventListener('click', (event) => {
388
+ event.preventDefault();
389
+ event.stopPropagation();
390
+ onClick(event);
391
+ });
392
+ return button;
393
+ }
394
+ }
@@ -2,6 +2,7 @@ export class OutlineController {
2
2
  constructor({
3
3
  mobileBreakpointQuery = window.matchMedia('(max-width: 768px)'),
4
4
  onNavigateToHeading,
5
+ onWillOpen,
5
6
  } = {}) {
6
7
  this.outlineOpen = false;
7
8
  this.activeHeadingFrame = null;
@@ -9,16 +10,36 @@ export class OutlineController {
9
10
  this.headings = [];
10
11
  this.pinnedHeadingId = null;
11
12
  this.onNavigateToHeading = onNavigateToHeading;
13
+ this.onWillOpen = onWillOpen;
12
14
  this.panel = document.getElementById('outlinePanel');
13
15
  this.navigation = document.getElementById('outlineNav');
14
16
  this.previewContainer = document.getElementById('previewContainer');
15
17
  this.toggleButton = document.getElementById('outlineToggle');
16
18
  this.mobileBreakpointQuery = mobileBreakpointQuery;
17
19
  this.handlePreviewScroll = () => this.scheduleActiveHeadingUpdate();
20
+ this.handleDocumentPointerDown = (event) => {
21
+ if (!this.outlineOpen) {
22
+ return;
23
+ }
24
+
25
+ const target = event.target;
26
+ if (
27
+ target instanceof Node
28
+ && (
29
+ this.panel?.contains(target)
30
+ || this.toggleButton?.contains(target)
31
+ )
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ this.close();
37
+ };
18
38
  }
19
39
 
20
40
  initialize() {
21
41
  this.toggleButton?.addEventListener('click', () => this.toggle());
42
+ document.addEventListener('pointerdown', this.handleDocumentPointerDown);
22
43
  }
23
44
 
24
45
  toggle() {
@@ -30,6 +51,10 @@ export class OutlineController {
30
51
  }
31
52
 
32
53
  setOpenState(nextState) {
54
+ if (nextState && !this.outlineOpen) {
55
+ this.onWillOpen?.();
56
+ }
57
+
33
58
  this.outlineOpen = nextState;
34
59
  this.panel?.classList.toggle('hidden', !this.outlineOpen);
35
60
  this.toggleButton?.classList.toggle('active', this.outlineOpen);