@nowline/preview-shell 0.4.2 → 0.5.0

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/dist/mount.js CHANGED
@@ -12,14 +12,23 @@ import { buildViewport } from './markup.js';
12
12
  import { PREVIEW_SHELL_CSS } from './styles.js';
13
13
  let stylesheetMounted = false;
14
14
  /**
15
- * Ensure the shared `<style>` block is in the document `<head>`. Safe
16
- * to call repeatedly — only the first call writes. The stylesheet
17
- * scopes every selector under `.nl-preview-root` so multiple shells
18
- * can coexist with workbench / app styles.
15
+ * Last chrome position saved after a drag, shared across all shells in
16
+ * this JS session. Persists for the lifetime of the page.
19
17
  */
18
+ let savedChromePosition = null;
20
19
  function ensureStylesheet(doc) {
21
20
  if (stylesheetMounted)
22
21
  return;
22
+ // A CSP-restricted consumer (e.g. the VS Code webview, whose
23
+ // `style-src` is nonce-only with no `'unsafe-inline'`) pre-injects
24
+ // this stylesheet into the host HTML with a nonce, marked by the
25
+ // `data-nl-preview-shell` attribute. A second `<style>` created here
26
+ // would carry no nonce and be refused by the CSP, leaving the
27
+ // toolbar unstyled — so defer to the host-provided copy when present.
28
+ if (doc.querySelector('style[data-nl-preview-shell]')) {
29
+ stylesheetMounted = true;
30
+ return;
31
+ }
23
32
  const style = doc.createElement('style');
24
33
  style.setAttribute('data-nl-preview-shell', '');
25
34
  style.textContent = PREVIEW_SHELL_CSS;
@@ -38,9 +47,15 @@ export function mountPreview(rootEl, options = {}) {
38
47
  naturalHeight: 0,
39
48
  defaultFit: options.initialFit ?? 'fitPage',
40
49
  activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
50
+ isDirty: false,
51
+ lastViewportWidth: 0,
52
+ lastViewportHeight: 0,
41
53
  showMinimap: options.showMinimap !== false,
42
54
  minimapDismissedThisSession: false,
43
55
  firstRender: true,
56
+ mode: 'dark',
57
+ locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
58
+ export: { format: 'svg' },
44
59
  view: {
45
60
  theme: 'auto',
46
61
  now: 'today',
@@ -53,16 +68,166 @@ export function mountPreview(rootEl, options = {}) {
53
68
  target.addEventListener(type, handler, opts);
54
69
  cleanups.push(() => target.removeEventListener(type, handler, opts));
55
70
  }
56
- // ===== Toolbar fade =====
71
+ // ===== Mode resolution =====
72
+ function resolveSystemMode() {
73
+ const body = doc.body;
74
+ if (body) {
75
+ if (body.classList.contains('vscode-dark') ||
76
+ body.classList.contains('vscode-high-contrast-dark'))
77
+ return 'dark';
78
+ if (body.classList.contains('vscode-light') ||
79
+ body.classList.contains('vscode-high-contrast-light') ||
80
+ body.classList.contains('vscode-high-contrast'))
81
+ return 'light';
82
+ }
83
+ if (typeof win.matchMedia === 'function') {
84
+ return win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
85
+ }
86
+ return 'dark';
87
+ }
88
+ function applyMode(resolved) {
89
+ state.mode = resolved;
90
+ rootEl.setAttribute('data-nl-mode', resolved);
91
+ }
92
+ const rawMode = options.mode ?? 'system';
93
+ applyMode(rawMode === 'system' ? resolveSystemMode() : rawMode);
94
+ if (rawMode === 'system') {
95
+ if (typeof MutationObserver !== 'undefined' && doc.body) {
96
+ const bodyObs = new MutationObserver(() => applyMode(resolveSystemMode()));
97
+ bodyObs.observe(doc.body, { attributes: true, attributeFilter: ['class'] });
98
+ cleanups.push(() => bodyObs.disconnect());
99
+ }
100
+ const mq = typeof win.matchMedia === 'function'
101
+ ? win.matchMedia('(prefers-color-scheme: dark)')
102
+ : null;
103
+ if (mq) {
104
+ const onMqChange = () => {
105
+ const body = doc.body;
106
+ const hasVsCode = body &&
107
+ (body.classList.contains('vscode-dark') ||
108
+ body.classList.contains('vscode-light') ||
109
+ body.classList.contains('vscode-high-contrast') ||
110
+ body.classList.contains('vscode-high-contrast-dark') ||
111
+ body.classList.contains('vscode-high-contrast-light'));
112
+ if (!hasVsCode)
113
+ applyMode(mq.matches ? 'dark' : 'light');
114
+ };
115
+ mq.addEventListener('change', onMqChange);
116
+ cleanups.push(() => mq.removeEventListener('change', onMqChange));
117
+ }
118
+ }
119
+ // ===== Floating UI fade =====
57
120
  let fadeTimer = null;
58
- function showToolbar() {
121
+ function showFloatingUI() {
59
122
  els.chrome.classList.remove('faded');
123
+ els.minimap.classList.remove('faded');
60
124
  if (fadeTimer)
61
125
  clearTimeout(fadeTimer);
62
- fadeTimer = setTimeout(() => els.chrome.classList.add('faded'), 2000);
126
+ fadeTimer = setTimeout(() => {
127
+ els.chrome.classList.add('faded');
128
+ els.minimap.classList.add('faded');
129
+ }, 2000);
130
+ }
131
+ on(doc, 'mousemove', showFloatingUI);
132
+ showFloatingUI();
133
+ // ===== Collapse / restore =====
134
+ // « collapses the toolbar to a translucent puck (just the drag grip
135
+ // + a » restore arrow); » expands it back to the full toolbar.
136
+ on(els.collapseBtn, 'click', (e) => {
137
+ e.stopPropagation();
138
+ closeAllMenus();
139
+ els.chrome.classList.add('collapsed');
140
+ repositionChrome();
141
+ });
142
+ on(els.restoreBtn, 'click', (e) => {
143
+ e.stopPropagation();
144
+ els.chrome.classList.remove('collapsed');
145
+ repositionChrome();
146
+ });
147
+ // ===== Toolbar positioning + drag =====
148
+ // The chrome defaults to the upper-right corner and tracks it on
149
+ // resize (`chromePinnedRight`). Once dragged it becomes free-floating
150
+ // and is merely clamped back into view. Either way the position is
151
+ // always expressed as explicit `left`/`top` so a narrowing viewport
152
+ // shifts the toolbar left instead of squishing the row.
153
+ let chromePinnedRight = savedChromePosition === null;
154
+ let chromeDragStart = null;
155
+ function getGutter() {
156
+ const val = getComputedStyle(rootEl).getPropertyValue('--nl-preview-gutter').trim();
157
+ return parseInt(val, 10) || 8;
158
+ }
159
+ function repositionChrome() {
160
+ const g = getGutter();
161
+ const cw = els.chrome.offsetWidth;
162
+ const ch = els.chrome.offsetHeight;
163
+ const maxLeft = Math.max(g, rootEl.clientWidth - cw - g);
164
+ const maxTop = Math.max(g, rootEl.clientHeight - ch - g);
165
+ let left;
166
+ let top;
167
+ if (chromePinnedRight) {
168
+ left = rootEl.clientWidth - cw - g;
169
+ top = g;
170
+ }
171
+ else {
172
+ left = parseInt(els.chrome.style.left, 10);
173
+ top = parseInt(els.chrome.style.top, 10);
174
+ if (Number.isNaN(left))
175
+ left = savedChromePosition?.left ?? g;
176
+ if (Number.isNaN(top))
177
+ top = savedChromePosition?.top ?? g;
178
+ }
179
+ els.chrome.style.right = 'auto';
180
+ els.chrome.style.left = `${Math.max(g, Math.min(left, maxLeft))}px`;
181
+ els.chrome.style.top = `${Math.max(g, Math.min(top, maxTop))}px`;
63
182
  }
64
- on(doc, 'mousemove', showToolbar);
65
- showToolbar();
183
+ function ensureChromeLeftTopAnchoring() {
184
+ if (els.chrome.style.left !== '')
185
+ return;
186
+ els.chrome.style.right = 'auto';
187
+ els.chrome.style.left = `${els.chrome.offsetLeft}px`;
188
+ els.chrome.style.top = `${els.chrome.offsetTop}px`;
189
+ }
190
+ on(els.toolbarHandle, 'pointerdown', (e) => {
191
+ e.preventDefault();
192
+ chromePinnedRight = false;
193
+ ensureChromeLeftTopAnchoring();
194
+ chromeDragStart = {
195
+ pointerX: e.clientX,
196
+ pointerY: e.clientY,
197
+ chromeLeft: parseInt(els.chrome.style.left, 10) || 0,
198
+ chromeTop: parseInt(els.chrome.style.top, 10) || 0,
199
+ };
200
+ els.chrome.classList.add('dragging');
201
+ els.toolbarHandle.setPointerCapture(e.pointerId);
202
+ });
203
+ on(els.toolbarHandle, 'pointermove', (e) => {
204
+ if (!chromeDragStart)
205
+ return;
206
+ const dx = e.clientX - chromeDragStart.pointerX;
207
+ const dy = e.clientY - chromeDragStart.pointerY;
208
+ const rawLeft = chromeDragStart.chromeLeft + dx;
209
+ const rawTop = chromeDragStart.chromeTop + dy;
210
+ const g = getGutter();
211
+ const maxLeft = Math.max(g, rootEl.clientWidth - els.chrome.offsetWidth - g);
212
+ const maxTop = Math.max(g, rootEl.clientHeight - els.chrome.offsetHeight - g);
213
+ els.chrome.style.left = `${Math.max(g, Math.min(rawLeft, maxLeft))}px`;
214
+ els.chrome.style.top = `${Math.max(g, Math.min(rawTop, maxTop))}px`;
215
+ });
216
+ function endChromeDrag() {
217
+ if (!chromeDragStart)
218
+ return;
219
+ chromeDragStart = null;
220
+ els.chrome.classList.remove('dragging');
221
+ savedChromePosition = {
222
+ left: parseInt(els.chrome.style.left, 10) || 0,
223
+ top: parseInt(els.chrome.style.top, 10) || 0,
224
+ };
225
+ }
226
+ on(els.toolbarHandle, 'pointerup', endChromeDrag);
227
+ on(els.toolbarHandle, 'pointercancel', () => {
228
+ chromeDragStart = null;
229
+ els.chrome.classList.remove('dragging');
230
+ });
66
231
  // ===== Transform =====
67
232
  function applyTransform() {
68
233
  const w = Math.max(1, Math.round(state.naturalWidth * state.scale));
@@ -99,6 +264,7 @@ export function mountPreview(rootEl, options = {}) {
99
264
  if (!state.naturalWidth || !state.naturalHeight)
100
265
  return;
101
266
  state.activeFit = 'fitPage';
267
+ state.isDirty = false;
102
268
  const sx = els.viewport.clientWidth / state.naturalWidth;
103
269
  const sy = els.viewport.clientHeight / state.naturalHeight;
104
270
  setScale(Math.min(sx, sy));
@@ -109,12 +275,14 @@ export function mountPreview(rootEl, options = {}) {
109
275
  if (!state.naturalWidth)
110
276
  return;
111
277
  state.activeFit = 'fitWidth';
278
+ state.isDirty = false;
112
279
  setScale(els.viewport.clientWidth / state.naturalWidth);
113
280
  els.viewport.scrollLeft = 0;
114
281
  els.viewport.scrollTop = 0;
115
282
  }
116
283
  function actualSize() {
117
284
  state.activeFit = 'manual';
285
+ state.isDirty = true;
118
286
  setScale(1);
119
287
  }
120
288
  function applyDefaultFit() {
@@ -126,10 +294,26 @@ export function mountPreview(rootEl, options = {}) {
126
294
  fitPage();
127
295
  }
128
296
  function reapplyActiveFit() {
129
- if (state.activeFit === 'fitPage')
297
+ const newW = els.viewport.clientWidth;
298
+ const newH = els.viewport.clientHeight;
299
+ if (state.isDirty) {
300
+ const oldW = state.lastViewportWidth || newW;
301
+ const oldH = state.lastViewportHeight || newH;
302
+ if (state.scale > 0 && (oldW !== newW || oldH !== newH)) {
303
+ const cx = (els.viewport.scrollLeft + oldW / 2) / state.scale;
304
+ const cy = (els.viewport.scrollTop + oldH / 2) / state.scale;
305
+ els.viewport.scrollLeft = Math.max(0, cx * state.scale - newW / 2);
306
+ els.viewport.scrollTop = Math.max(0, cy * state.scale - newH / 2);
307
+ }
308
+ }
309
+ else if (state.activeFit === 'fitPage') {
130
310
  fitPage();
131
- else if (state.activeFit === 'fitWidth')
311
+ }
312
+ else if (state.activeFit === 'fitWidth') {
132
313
  fitWidth();
314
+ }
315
+ state.lastViewportWidth = newW;
316
+ state.lastViewportHeight = newH;
133
317
  }
134
318
  // ===== Render handling =====
135
319
  function extractNaturalSize(svgEl) {
@@ -221,6 +405,8 @@ export function mountPreview(rootEl, options = {}) {
221
405
  els.diagSummary.classList.add('warn-only');
222
406
  if (!errors && !warnings)
223
407
  els.diagSummary.classList.add('clean');
408
+ // Hide Open Problems link when there are no issues
409
+ els.openProblems.hidden = errors + warnings === 0;
224
410
  const tbody = els.diagTbody;
225
411
  while (tbody.firstChild)
226
412
  tbody.removeChild(tbody.firstChild);
@@ -286,8 +472,8 @@ export function mountPreview(rootEl, options = {}) {
286
472
  }
287
473
  svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
288
474
  }
289
- const maxW = 160;
290
- const maxH = 120;
475
+ const maxW = 120;
476
+ const maxH = 90;
291
477
  if (state.naturalWidth && state.naturalHeight) {
292
478
  const ratio = state.naturalWidth / state.naturalHeight;
293
479
  let w = maxW;
@@ -361,17 +547,31 @@ export function mountPreview(rootEl, options = {}) {
361
547
  miniDragging = false;
362
548
  });
363
549
  on(els.viewport, 'scroll', updateMinimapRect);
364
- on(win, 'resize', () => {
365
- reapplyActiveFit();
366
- updateMinimapRect();
367
- updateMinimapVisibility();
368
- });
550
+ let resizeTimer = null;
551
+ function handleResize() {
552
+ if (resizeTimer)
553
+ clearTimeout(resizeTimer);
554
+ resizeTimer = setTimeout(() => {
555
+ resizeTimer = null;
556
+ reapplyActiveFit();
557
+ updateMinimapRect();
558
+ updateMinimapVisibility();
559
+ repositionChrome();
560
+ }, 50);
561
+ }
562
+ on(win, 'resize', handleResize);
563
+ if (typeof ResizeObserver !== 'undefined') {
564
+ const ro = new ResizeObserver(handleResize);
565
+ ro.observe(els.viewport);
566
+ cleanups.push(() => ro.disconnect());
567
+ }
369
568
  // ===== Wheel zoom =====
370
569
  on(els.viewport, 'wheel', (e) => {
371
570
  if (!(e.ctrlKey || e.metaKey))
372
571
  return;
373
572
  e.preventDefault();
374
573
  state.activeFit = 'manual';
574
+ state.isDirty = true;
375
575
  const rect = els.viewport.getBoundingClientRect();
376
576
  const anchorX = e.clientX - rect.left;
377
577
  const anchorY = e.clientY - rect.top;
@@ -420,6 +620,7 @@ export function mountPreview(rootEl, options = {}) {
420
620
  on(els.viewport, 'mousedown', (e) => {
421
621
  if (!spaceDown)
422
622
  return;
623
+ state.isDirty = true;
423
624
  dragOrigin = {
424
625
  x: e.clientX,
425
626
  y: e.clientY,
@@ -444,96 +645,123 @@ export function mountPreview(rootEl, options = {}) {
444
645
  // ===== Toolbar buttons =====
445
646
  on(els.zoomOut, 'click', () => {
446
647
  state.activeFit = 'manual';
648
+ state.isDirty = true;
447
649
  setScale(state.scale / 1.1);
448
650
  });
449
651
  on(els.zoomIn, 'click', () => {
450
652
  state.activeFit = 'manual';
653
+ state.isDirty = true;
451
654
  setScale(state.scale * 1.1);
452
655
  });
453
656
  on(els.zoomReset, 'click', actualSize);
454
657
  on(els.fitWidth, 'click', fitWidth);
455
658
  on(els.fitPage, 'click', fitPage);
456
- // ===== Dropdowns =====
457
- function setupDropdown(toggle, menu) {
458
- on(toggle, 'click', (e) => {
459
- e.stopPropagation();
460
- els.saveMenu.hidden = true;
461
- els.copyMenu.hidden = true;
462
- els.viewMenu.hidden = true;
463
- menu.hidden = !menu.hidden;
464
- });
659
+ // ===== Menu state helpers =====
660
+ function closeSubMenus() {
661
+ els.formatMenu.hidden = true;
662
+ els.themeMenu.hidden = true;
663
+ els.nowPicker.hidden = true;
664
+ els.linksMenu.hidden = true;
465
665
  }
466
- setupDropdown(els.viewToggle, els.viewMenu);
467
- setupDropdown(els.saveToggle, els.saveMenu);
468
- setupDropdown(els.copyToggle, els.copyMenu);
469
- on(doc, 'click', () => {
470
- els.saveMenu.hidden = true;
471
- els.copyMenu.hidden = true;
472
- els.viewMenu.hidden = true;
473
- });
474
- for (const m of [els.saveMenu, els.copyMenu, els.viewMenu]) {
475
- on(m, 'click', (e) => e.stopPropagation());
476
- }
477
- // ===== View options =====
478
- function refreshViewMenu() {
479
- const items = els.viewMenu.querySelectorAll('.view-opt');
480
- for (const item of Array.from(items)) {
481
- const opt = item.getAttribute('data-opt');
482
- const value = item.getAttribute('data-value');
483
- let active = false;
484
- if (opt === 'theme')
485
- active = state.view.theme === value;
486
- else if (opt === 'now')
487
- active = state.view.now === value;
488
- else if (opt === 'showLinks')
489
- active = state.view.showLinks;
490
- item.setAttribute('data-active', active ? 'true' : 'false');
491
- }
666
+ function closeAllMenus() {
667
+ els.moreMenu.hidden = true;
668
+ closeSubMenus();
492
669
  }
493
- function postViewOverrides() {
494
- const overrides = {};
495
- if (state.view.overridden.theme)
496
- overrides.theme = state.view.theme;
497
- if (state.view.overridden.now)
498
- overrides.now = state.view.now;
499
- if (state.view.overridden.showLinks)
500
- overrides.showLinks = state.view.showLinks;
501
- options.onViewOptions?.(overrides);
502
- }
503
- on(els.viewMenu, 'click', (e) => {
504
- const target = e.target;
505
- const btn = target?.closest('.view-opt');
506
- if (!btn)
670
+ /**
671
+ * Keep a just-opened flyout (more-menu, a sub-dropdown, or the
672
+ * calendar) inside the preview root + gutters. `defaultSide` is the
673
+ * edge the panel is anchored to in CSS; if opening that way would
674
+ * spill past the opposite gutter the panel is flipped, then any
675
+ * residual horizontal overflow is nudged back with a transform and
676
+ * excess height is capped with an internal scroll.
677
+ *
678
+ * `clampVertical` is off for the more-menu so capping its height
679
+ * never clips the calendar that pops out of it.
680
+ */
681
+ function placeFlyout(panel, defaultSide, clampVertical = true) {
682
+ panel.classList.remove('flip');
683
+ panel.style.maxHeight = '';
684
+ panel.style.overflowY = '';
685
+ panel.style.transform = '';
686
+ const root = rootEl.getBoundingClientRect();
687
+ // Skip when the host hasn't laid out yet (e.g. happy-dom tests).
688
+ if (root.width === 0 && root.height === 0)
507
689
  return;
508
- const opt = btn.getAttribute('data-opt');
509
- const value = btn.getAttribute('data-value');
510
- if (opt === 'theme') {
511
- state.view.theme = value;
512
- state.view.overridden.theme = true;
690
+ const g = getGutter();
691
+ let box = panel.getBoundingClientRect();
692
+ const fitsWidth = box.width <= root.width - 2 * g;
693
+ if (fitsWidth && defaultSide === 'left' && box.right > root.right - g) {
694
+ panel.classList.add('flip');
513
695
  }
514
- else if (opt === 'now') {
515
- state.view.now = value;
516
- state.view.overridden.now = true;
696
+ else if (fitsWidth && defaultSide === 'right' && box.left < root.left + g) {
697
+ panel.classList.add('flip');
517
698
  }
518
- else if (opt === 'showLinks') {
519
- state.view.showLinks = !state.view.showLinks;
520
- state.view.overridden.showLinks = true;
699
+ box = panel.getBoundingClientRect();
700
+ let dx = 0;
701
+ if (box.right > root.right - g)
702
+ dx = root.right - g - box.right;
703
+ if (box.left + dx < root.left + g)
704
+ dx = root.left + g - box.left;
705
+ if (dx !== 0)
706
+ panel.style.transform = `translateX(${Math.round(dx)}px)`;
707
+ if (clampVertical) {
708
+ box = panel.getBoundingClientRect();
709
+ const avail = root.bottom - g - box.top;
710
+ if (box.height > avail && avail > 80) {
711
+ panel.style.maxHeight = `${Math.round(avail)}px`;
712
+ panel.style.overflowY = 'auto';
713
+ }
714
+ }
715
+ }
716
+ on(doc, 'click', closeAllMenus);
717
+ on(els.moreMenu, 'click', (e) => e.stopPropagation());
718
+ // ===== More-menu toggle =====
719
+ on(els.moreToggle, 'click', (e) => {
720
+ e.stopPropagation();
721
+ const opening = els.moreMenu.hidden;
722
+ closeAllMenus();
723
+ if (opening) {
724
+ els.moreMenu.hidden = false;
725
+ placeFlyout(els.moreMenu, 'right', /* clampVertical */ false);
521
726
  }
522
- refreshViewMenu();
523
- els.viewMenu.hidden = true;
524
- postViewOverrides();
525
727
  });
526
- // ===== Save / copy actions =====
527
- const actionButtons = els.root.querySelectorAll('[data-action]');
528
- for (const btn of Array.from(actionButtons)) {
529
- on(btn, 'click', () => {
530
- const action = btn.getAttribute('data-action');
531
- els.saveMenu.hidden = true;
532
- els.copyMenu.hidden = true;
533
- if (action)
534
- void handleExportAction(action);
535
- });
728
+ // ===== Format dropdown =====
729
+ on(els.formatToggle, 'click', (e) => {
730
+ e.stopPropagation();
731
+ const opening = els.formatMenu.hidden;
732
+ closeSubMenus();
733
+ if (opening) {
734
+ els.formatMenu.hidden = false;
735
+ placeFlyout(els.formatMenu, 'left');
736
+ }
737
+ });
738
+ on(els.formatMenu, 'click', (e) => {
739
+ e.stopPropagation();
740
+ const btn = e.target.closest('.format-opt');
741
+ if (!btn)
742
+ return;
743
+ const value = btn.getAttribute('data-value');
744
+ if (!value)
745
+ return;
746
+ state.export.format = value;
747
+ refreshFormatToggle();
748
+ els.formatMenu.hidden = true;
749
+ });
750
+ function refreshFormatToggle() {
751
+ els.formatToggle.textContent = `${state.export.format.toUpperCase()} \u25be`;
752
+ for (const btn of Array.from(els.formatMenu.querySelectorAll('.format-opt'))) {
753
+ btn.setAttribute('data-active', (btn.getAttribute('data-value') === state.export.format).toString());
754
+ }
536
755
  }
756
+ // ===== Copy / export actions =====
757
+ on(els.copyAction, 'click', () => {
758
+ closeAllMenus();
759
+ void handleExportAction(`copy-${state.export.format}`);
760
+ });
761
+ on(els.exportAction, 'click', () => {
762
+ closeAllMenus();
763
+ void handleExportAction(`save-${state.export.format}`);
764
+ });
537
765
  async function handleExportAction(action) {
538
766
  if (!state.svgString)
539
767
  return;
@@ -619,15 +847,264 @@ export function mountPreview(rootEl, options = {}) {
619
847
  img.src = url;
620
848
  });
621
849
  }
622
- // ===== Apply initial baselines =====
850
+ // ===== Theme dropdown =====
851
+ function buildThemeMenu(themes) {
852
+ els.themeMenu.innerHTML = '';
853
+ const autoLi = doc.createElement('li');
854
+ const autoBtn = doc.createElement('button');
855
+ autoBtn.className = 'btn theme-opt';
856
+ autoBtn.setAttribute('data-value', 'auto');
857
+ autoBtn.textContent = 'Auto';
858
+ autoLi.appendChild(autoBtn);
859
+ els.themeMenu.appendChild(autoLi);
860
+ for (const theme of themes) {
861
+ const li = doc.createElement('li');
862
+ const btn = doc.createElement('button');
863
+ btn.className = 'btn theme-opt';
864
+ btn.setAttribute('data-value', theme);
865
+ const chip = doc.createElement('code');
866
+ chip.className = 'code-chip';
867
+ chip.textContent = theme;
868
+ btn.appendChild(chip);
869
+ li.appendChild(btn);
870
+ els.themeMenu.appendChild(li);
871
+ }
872
+ refreshThemeToggle();
873
+ }
874
+ on(els.themeToggle, 'click', (e) => {
875
+ e.stopPropagation();
876
+ const opening = els.themeMenu.hidden;
877
+ closeSubMenus();
878
+ if (opening) {
879
+ els.themeMenu.hidden = false;
880
+ placeFlyout(els.themeMenu, 'left');
881
+ }
882
+ });
883
+ on(els.themeMenu, 'click', (e) => {
884
+ e.stopPropagation();
885
+ const btn = e.target.closest('.theme-opt');
886
+ if (!btn)
887
+ return;
888
+ const value = btn.getAttribute('data-value');
889
+ if (!value)
890
+ return;
891
+ state.view.theme = value;
892
+ state.view.overridden.theme = true;
893
+ refreshThemeToggle();
894
+ els.themeMenu.hidden = true;
895
+ postViewOverrides();
896
+ });
897
+ function refreshThemeToggle() {
898
+ if (state.view.theme === 'auto') {
899
+ els.themeToggle.textContent = `Auto \u25be`;
900
+ }
901
+ else {
902
+ els.themeToggle.innerHTML = '';
903
+ const chip = doc.createElement('code');
904
+ chip.className = 'code-chip';
905
+ chip.textContent = state.view.theme;
906
+ els.themeToggle.appendChild(chip);
907
+ els.themeToggle.appendChild(doc.createTextNode(` \u25be`));
908
+ }
909
+ for (const btn of Array.from(els.themeMenu.querySelectorAll('.theme-opt'))) {
910
+ btn.setAttribute('data-active', (btn.getAttribute('data-value') === state.view.theme).toString());
911
+ }
912
+ }
913
+ // ===== Now picker (calendar) =====
914
+ let calendarYear = new Date().getFullYear();
915
+ let calendarMonth = new Date().getMonth();
916
+ function buildCalendar() {
917
+ const picker = els.nowPicker;
918
+ picker.innerHTML = '';
919
+ const year = calendarYear;
920
+ const month = calendarMonth;
921
+ const nav = doc.createElement('div');
922
+ nav.className = 'cal-nav';
923
+ const prevBtn = doc.createElement('button');
924
+ prevBtn.className = 'btn cal-nav-btn';
925
+ prevBtn.textContent = '\u2039';
926
+ prevBtn.addEventListener('click', (e) => {
927
+ e.stopPropagation();
928
+ calendarMonth--;
929
+ if (calendarMonth < 0) {
930
+ calendarMonth = 11;
931
+ calendarYear--;
932
+ }
933
+ buildCalendar();
934
+ });
935
+ const heading = doc.createElement('span');
936
+ heading.className = 'cal-heading';
937
+ heading.textContent = new Intl.DateTimeFormat(state.locale, {
938
+ month: 'long',
939
+ year: 'numeric',
940
+ }).format(new Date(year, month, 1));
941
+ const nextBtn = doc.createElement('button');
942
+ nextBtn.className = 'btn cal-nav-btn';
943
+ nextBtn.textContent = '\u203a';
944
+ nextBtn.addEventListener('click', (e) => {
945
+ e.stopPropagation();
946
+ calendarMonth++;
947
+ if (calendarMonth > 11) {
948
+ calendarMonth = 0;
949
+ calendarYear++;
950
+ }
951
+ buildCalendar();
952
+ });
953
+ nav.appendChild(prevBtn);
954
+ nav.appendChild(heading);
955
+ nav.appendChild(nextBtn);
956
+ picker.appendChild(nav);
957
+ const grid = doc.createElement('div');
958
+ grid.className = 'cal-grid';
959
+ for (const d of ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']) {
960
+ const cell = doc.createElement('span');
961
+ cell.className = 'cal-dow';
962
+ cell.textContent = d;
963
+ grid.appendChild(cell);
964
+ }
965
+ const firstDow = new Date(year, month, 1).getDay();
966
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
967
+ for (let i = 0; i < firstDow; i++) {
968
+ const empty = doc.createElement('span');
969
+ empty.className = 'cal-empty';
970
+ grid.appendChild(empty);
971
+ }
972
+ const now = new Date();
973
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
974
+ for (let d = 1; d <= daysInMonth; d++) {
975
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
976
+ const btn = doc.createElement('button');
977
+ btn.className = 'btn cal-day';
978
+ btn.textContent = String(d);
979
+ if (dateStr === todayStr)
980
+ btn.classList.add('is-today');
981
+ if (state.view.now === dateStr)
982
+ btn.classList.add('is-selected');
983
+ btn.addEventListener('click', (e) => {
984
+ e.stopPropagation();
985
+ applyNow(dateStr);
986
+ });
987
+ grid.appendChild(btn);
988
+ }
989
+ picker.appendChild(grid);
990
+ const footer = doc.createElement('div');
991
+ footer.className = 'cal-footer';
992
+ const todayBtnEl = doc.createElement('button');
993
+ todayBtnEl.className = 'btn cal-footer-btn';
994
+ todayBtnEl.textContent = 'Today';
995
+ todayBtnEl.addEventListener('click', (e) => {
996
+ e.stopPropagation();
997
+ applyNow('today');
998
+ });
999
+ const noneBtnEl = doc.createElement('button');
1000
+ noneBtnEl.className = 'btn cal-footer-btn';
1001
+ noneBtnEl.textContent = 'None';
1002
+ noneBtnEl.addEventListener('click', (e) => {
1003
+ e.stopPropagation();
1004
+ applyNow('hide');
1005
+ });
1006
+ footer.appendChild(todayBtnEl);
1007
+ footer.appendChild(noneBtnEl);
1008
+ picker.appendChild(footer);
1009
+ }
1010
+ function applyNow(value) {
1011
+ state.view.now = value;
1012
+ state.view.overridden.now = true;
1013
+ refreshNowToggle();
1014
+ els.nowPicker.hidden = true;
1015
+ postViewOverrides();
1016
+ }
1017
+ on(els.nowToggle, 'click', (e) => {
1018
+ e.stopPropagation();
1019
+ const opening = els.nowPicker.hidden;
1020
+ closeSubMenus();
1021
+ if (opening) {
1022
+ buildCalendar();
1023
+ els.nowPicker.hidden = false;
1024
+ placeFlyout(els.nowPicker, 'left');
1025
+ }
1026
+ });
1027
+ function refreshNowToggle() {
1028
+ let label;
1029
+ if (state.view.now === 'today') {
1030
+ label = 'Today';
1031
+ }
1032
+ else if (state.view.now === 'hide') {
1033
+ label = 'None';
1034
+ }
1035
+ else {
1036
+ try {
1037
+ label = new Intl.DateTimeFormat(state.locale, { dateStyle: 'medium' }).format(new Date(`${state.view.now}T00:00:00`));
1038
+ }
1039
+ catch {
1040
+ label = state.view.now;
1041
+ }
1042
+ }
1043
+ els.nowLabel.textContent = label;
1044
+ }
1045
+ // ===== Show-links dropdown =====
1046
+ on(els.linksToggle, 'click', (e) => {
1047
+ e.stopPropagation();
1048
+ const opening = els.linksMenu.hidden;
1049
+ closeSubMenus();
1050
+ if (opening) {
1051
+ els.linksMenu.hidden = false;
1052
+ placeFlyout(els.linksMenu, 'left');
1053
+ }
1054
+ });
1055
+ on(els.linksMenu, 'click', (e) => {
1056
+ e.stopPropagation();
1057
+ const btn = e.target.closest('.links-opt');
1058
+ if (!btn)
1059
+ return;
1060
+ const value = btn.getAttribute('data-value');
1061
+ if (value === null)
1062
+ return;
1063
+ state.view.showLinks = value === 'true';
1064
+ state.view.overridden.showLinks = true;
1065
+ refreshLinksToggle();
1066
+ els.linksMenu.hidden = true;
1067
+ postViewOverrides();
1068
+ });
1069
+ function refreshLinksToggle() {
1070
+ els.linksToggle.textContent = `${state.view.showLinks ? 'Yes' : 'No'} \u25be`;
1071
+ for (const btn of Array.from(els.linksMenu.querySelectorAll('.links-opt'))) {
1072
+ btn.setAttribute('data-active', (btn.getAttribute('data-value') === (state.view.showLinks ? 'true' : 'false')).toString());
1073
+ }
1074
+ }
1075
+ // ===== View-options helpers =====
1076
+ function postViewOverrides() {
1077
+ const overrides = {};
1078
+ if (state.view.overridden.theme)
1079
+ overrides.theme = state.view.theme;
1080
+ if (state.view.overridden.now)
1081
+ overrides.now = state.view.now;
1082
+ if (state.view.overridden.showLinks)
1083
+ overrides.showLinks = state.view.showLinks;
1084
+ options.onViewOptions?.(overrides);
1085
+ }
1086
+ function refreshAll() {
1087
+ refreshThemeToggle();
1088
+ refreshNowToggle();
1089
+ refreshLinksToggle();
1090
+ refreshFormatToggle();
1091
+ }
1092
+ // ===== Theme control visibility =====
1093
+ if (options.themeControl === 'hide') {
1094
+ const themeRow = els.themeMenu.closest('.theme-control-row');
1095
+ if (themeRow)
1096
+ themeRow.style.display = 'none';
1097
+ }
1098
+ // ===== Apply initial theme menu + baselines =====
1099
+ buildThemeMenu(options.availableThemes ?? ['light', 'dark', 'grayscale']);
623
1100
  if (options.viewBaseline) {
624
1101
  applyBaseline(state, options.viewBaseline, true);
625
- refreshViewMenu();
626
- }
627
- else {
628
- refreshViewMenu();
629
1102
  }
1103
+ refreshAll();
630
1104
  updateMinimapVisibility();
1105
+ // Anchor the toolbar explicitly (default: upper-right) so resize
1106
+ // shifts it left rather than the CSS `right` anchor squishing it.
1107
+ repositionChrome();
631
1108
  // ===== Imperative API =====
632
1109
  return {
633
1110
  setSvg,
@@ -635,7 +1112,7 @@ export function mountPreview(rootEl, options = {}) {
635
1112
  setFatal: showFatal,
636
1113
  setViewBaseline(baseline, resetOverrides = false) {
637
1114
  applyBaseline(state, baseline, resetOverrides);
638
- refreshViewMenu();
1115
+ refreshAll();
639
1116
  },
640
1117
  setDefaultFit(fit) {
641
1118
  state.defaultFit = fit;
@@ -645,6 +1122,17 @@ export function mountPreview(rootEl, options = {}) {
645
1122
  state.minimapDismissedThisSession = false;
646
1123
  updateMinimapVisibility();
647
1124
  },
1125
+ setMode(mode) {
1126
+ const resolved = mode === 'system' ? resolveSystemMode() : mode;
1127
+ applyMode(resolved);
1128
+ },
1129
+ setAvailableThemes(themes) {
1130
+ buildThemeMenu(themes);
1131
+ },
1132
+ setLocale(locale) {
1133
+ state.locale = locale;
1134
+ refreshNowToggle();
1135
+ },
648
1136
  fitPage,
649
1137
  fitWidth,
650
1138
  actualSize,
@@ -653,15 +1141,19 @@ export function mountPreview(rootEl, options = {}) {
653
1141
  },
654
1142
  setZoom(scale) {
655
1143
  state.activeFit = 'manual';
1144
+ state.isDirty = true;
656
1145
  setScale(scale);
657
1146
  },
658
1147
  dispose() {
659
1148
  if (fadeTimer)
660
1149
  clearTimeout(fadeTimer);
1150
+ if (resizeTimer)
1151
+ clearTimeout(resizeTimer);
661
1152
  for (const c of cleanups)
662
1153
  c();
663
1154
  cleanups.length = 0;
664
1155
  rootEl.classList.remove('nl-preview-root');
1156
+ rootEl.removeAttribute('data-nl-mode');
665
1157
  rootEl.innerHTML = '';
666
1158
  },
667
1159
  };
@@ -671,10 +1163,15 @@ function applyBaseline(state, baseline, resetOverrides) {
671
1163
  state.view.theme = baseline.theme;
672
1164
  }
673
1165
  if (baseline.now !== undefined && !state.view.overridden.now) {
674
- // Setting is 'auto'/'none'/YYYY-MM-DD; the toolbar only exposes
675
- // today/hide, so collapse anything that isn't 'none' to 'today'
676
- // for the displayed checkmark.
677
- state.view.now = baseline.now === 'none' ? 'hide' : 'today';
1166
+ if (baseline.now === 'none' || baseline.now === 'hide') {
1167
+ state.view.now = 'hide';
1168
+ }
1169
+ else if (typeof baseline.now === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(baseline.now)) {
1170
+ state.view.now = baseline.now;
1171
+ }
1172
+ else {
1173
+ state.view.now = 'today';
1174
+ }
678
1175
  }
679
1176
  if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
680
1177
  state.view.showLinks = baseline.showLinks !== false;