@nowline/preview-shell 0.4.2 → 0.5.1

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