@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/src/mount.ts CHANGED
@@ -13,8 +13,19 @@ import type { DiagnosticRow } from '@nowline/browser';
13
13
  import { buildViewport } from './markup.js';
14
14
  import { PREVIEW_SHELL_CSS } from './styles.js';
15
15
 
16
- export type ThemeOverride = 'auto' | 'light' | 'dark';
17
- export type NowOverride = 'today' | 'hide';
16
+ /**
17
+ * Diagram render theme. `'auto'` resolves to the current Mode's
18
+ * light/dark. `'grayscale'` is a fully available theme (`'greyscale'`
19
+ * is accepted as an alias).
20
+ */
21
+ export type ThemeOverride = 'auto' | 'light' | 'dark' | 'grayscale' | 'greyscale';
22
+
23
+ /**
24
+ * Now-line override. `'today'` and `'hide'` are the named sentinels;
25
+ * any `'YYYY-MM-DD'` date string is also valid.
26
+ */
27
+ export type NowOverride = 'today' | 'hide' | (string & {});
28
+
18
29
  export type InitialFit = 'fitPage' | 'fitWidth' | 'actual';
19
30
 
20
31
  /**
@@ -38,9 +49,9 @@ export interface ExportRequest {
38
49
  }
39
50
 
40
51
  /**
41
- * View-options overrides emitted when the user clicks the View ▾ menu.
42
- * Only fields the user explicitly chose are present; consumers merge
43
- * with their own option-resolution chain (settings, .nowlinerc, etc.).
52
+ * View-options overrides emitted when the user changes Theme, Now, or
53
+ * Show-links in the more-menu. Only explicitly chosen fields are
54
+ * present; consumers merge with their option-resolution chain.
44
55
  */
45
56
  export interface ViewOptionsOverrides {
46
57
  theme?: ThemeOverride;
@@ -50,12 +61,12 @@ export interface ViewOptionsOverrides {
50
61
 
51
62
  /**
52
63
  * Baseline view state pushed by the consumer (e.g. when settings
53
- * change). The shell uses these to pre-fill the View menu's
54
- * checkmarks without claiming the option as user-overridden.
64
+ * change). The shell uses these to pre-fill menu checkmarks without
65
+ * claiming the option as user-overridden.
55
66
  */
56
67
  export interface ViewBaseline {
57
68
  theme?: ThemeOverride;
58
- /** Maps to one of `today` | `hide` for the checkmark — host values like 'auto' / 'YYYY-MM-DD' collapse to 'today'. */
69
+ /** `'today'` | `'hide'` | `'YYYY-MM-DD'` | legacy `'auto'` / `'none'` values are accepted. */
59
70
  now?: NowOverride | 'auto' | 'none' | string;
60
71
  showLinks?: boolean;
61
72
  }
@@ -68,15 +79,36 @@ export interface MountPreviewOptions {
68
79
  * with `handle.setViewBaseline(...)`.
69
80
  */
70
81
  viewBaseline?: ViewBaseline;
82
+ /**
83
+ * Color scheme for the chrome (toolbar, menus, minimap).
84
+ * `'system'` (default) auto-detects: VS Code webview via `<body>`
85
+ * class; browser via `prefers-color-scheme`. Never affects the
86
+ * diagram theme.
87
+ */
88
+ mode?: 'light' | 'dark' | 'system';
89
+ /**
90
+ * Whether to show the Theme row in the more-menu.
91
+ * Defaults to `'show'`.
92
+ */
93
+ themeControl?: 'show' | 'hide';
94
+ /**
95
+ * Diagram themes available in the Theme dropdown. **Auto** is
96
+ * always prepended. Defaults to `['light', 'dark', 'grayscale']`.
97
+ */
98
+ availableThemes?: string[];
99
+ /**
100
+ * Locale used for date formatting in the Now picker.
101
+ * Defaults to `navigator.language`.
102
+ */
103
+ locale?: string;
71
104
  onGoto?: (loc: DiagnosticGoto) => void;
72
105
  onOpenProblems?: () => void;
73
106
  onSave?: (req: ExportRequest) => void;
74
107
  onCopy?: (req: ExportRequest) => void;
75
108
  /**
76
- * Fired when the user toggles save-png / copy-png and the browser's
77
- * `navigator.clipboard.write` is unavailable. Consumers usually
78
- * pass the bytes off to a host-side fallback (write to a temp file
79
- * + notify) and pop a message describing what happened.
109
+ * Fired when the browser's `navigator.clipboard.write` is
110
+ * unavailable during a copy-PNG action. Consumers pass the bytes
111
+ * to a host-side fallback.
80
112
  */
81
113
  onCopyPngFallback?: (body: Uint8Array) => void;
82
114
  onViewOptions?: (overrides: ViewOptionsOverrides) => void;
@@ -90,12 +122,26 @@ export interface PreviewHandle {
90
122
  setDiagnostics(rows: DiagnosticRow[]): void;
91
123
  /** Show a one-row diagnostics overlay with the supplied message. */
92
124
  setFatal(message: string): void;
93
- /** Re-skin the view-menu baselines without claiming user overrides. */
125
+ /** Re-skin view-menu baselines without claiming user overrides. */
94
126
  setViewBaseline(baseline: ViewBaseline, resetOverrides?: boolean): void;
95
- /** Update the "default fit" used by the next first-render. */
127
+ /** Update the default fit used by the next first-render. */
96
128
  setDefaultFit(fit: InitialFit): void;
97
- /** Toggle the minimap behaviour from the host. */
129
+ /** Toggle minimap behaviour from the host. */
98
130
  setShowMinimap(show: boolean): void;
131
+ /**
132
+ * Switch the chrome color scheme. `'system'` re-detects from the
133
+ * host environment; `'light'`/`'dark'` pin explicitly.
134
+ */
135
+ setMode(mode: 'light' | 'dark' | 'system'): void;
136
+ /**
137
+ * Replace the list of selectable diagram themes. **Auto** is
138
+ * always prepended. Rebuilds the Theme dropdown immediately.
139
+ */
140
+ setAvailableThemes(themes: string[]): void;
141
+ /**
142
+ * Update the locale used for date formatting in the Now picker.
143
+ */
144
+ setLocale(locale: string): void;
99
145
  fitPage(): void;
100
146
  fitWidth(): void;
101
147
  actualSize(): void;
@@ -112,9 +158,19 @@ interface InternalState {
112
158
  naturalHeight: number;
113
159
  defaultFit: InitialFit;
114
160
  activeFit: 'fitPage' | 'fitWidth' | 'manual';
161
+ /**
162
+ * True once the user has manually zoomed, panned, or used zoom buttons.
163
+ * Drives the resize focal-point strategy.
164
+ */
165
+ isDirty: boolean;
166
+ lastViewportWidth: number;
167
+ lastViewportHeight: number;
115
168
  showMinimap: boolean;
116
- minimapDismissedThisSession: boolean;
117
169
  firstRender: boolean;
170
+ /** Resolved chrome color scheme — set by mode resolution, never by diagram theme. */
171
+ mode: 'light' | 'dark';
172
+ locale: string;
173
+ export: { format: 'svg' | 'png' };
118
174
  view: {
119
175
  theme: ThemeOverride;
120
176
  now: NowOverride;
@@ -126,13 +182,23 @@ interface InternalState {
126
182
  let stylesheetMounted = false;
127
183
 
128
184
  /**
129
- * Ensure the shared `<style>` block is in the document `<head>`. Safe
130
- * to call repeatedly — only the first call writes. The stylesheet
131
- * scopes every selector under `.nl-preview-root` so multiple shells
132
- * can coexist with workbench / app styles.
185
+ * Last chrome position saved after a drag, shared across all shells in
186
+ * this JS session. Persists for the lifetime of the page.
133
187
  */
188
+ let savedChromePosition: { left: number; top: number } | null = null;
189
+
134
190
  function ensureStylesheet(doc: Document): void {
135
191
  if (stylesheetMounted) return;
192
+ // A CSP-restricted consumer (e.g. the VS Code webview, whose
193
+ // `style-src` is nonce-only with no `'unsafe-inline'`) pre-injects
194
+ // this stylesheet into the host HTML with a nonce, marked by the
195
+ // `data-nl-preview-shell` attribute. A second `<style>` created here
196
+ // would carry no nonce and be refused by the CSP, leaving the
197
+ // toolbar unstyled — so defer to the host-provided copy when present.
198
+ if (doc.querySelector('style[data-nl-preview-shell]')) {
199
+ stylesheetMounted = true;
200
+ return;
201
+ }
136
202
  const style = doc.createElement('style');
137
203
  style.setAttribute('data-nl-preview-shell', '');
138
204
  style.textContent = PREVIEW_SHELL_CSS;
@@ -157,9 +223,14 @@ export function mountPreview(
157
223
  naturalHeight: 0,
158
224
  defaultFit: options.initialFit ?? 'fitPage',
159
225
  activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
226
+ isDirty: false,
227
+ lastViewportWidth: 0,
228
+ lastViewportHeight: 0,
160
229
  showMinimap: options.showMinimap !== false,
161
- minimapDismissedThisSession: false,
162
230
  firstRender: true,
231
+ mode: 'dark',
232
+ locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
233
+ export: { format: 'svg' },
163
234
  view: {
164
235
  theme: 'auto',
165
236
  now: 'today',
@@ -179,15 +250,185 @@ export function mountPreview(
179
250
  cleanups.push(() => target.removeEventListener(type, handler as EventListener, opts));
180
251
  }
181
252
 
182
- // ===== Toolbar fade =====
253
+ // ===== Mode resolution =====
254
+ function resolveSystemMode(): 'light' | 'dark' {
255
+ const body = doc.body;
256
+ if (body) {
257
+ if (
258
+ body.classList.contains('vscode-dark') ||
259
+ body.classList.contains('vscode-high-contrast-dark')
260
+ )
261
+ return 'dark';
262
+ if (
263
+ body.classList.contains('vscode-light') ||
264
+ body.classList.contains('vscode-high-contrast-light') ||
265
+ body.classList.contains('vscode-high-contrast')
266
+ )
267
+ return 'light';
268
+ }
269
+ if (typeof win.matchMedia === 'function') {
270
+ return win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
271
+ }
272
+ return 'dark';
273
+ }
274
+
275
+ function applyMode(resolved: 'light' | 'dark'): void {
276
+ state.mode = resolved;
277
+ rootEl.setAttribute('data-nl-mode', resolved);
278
+ }
279
+
280
+ const rawMode = options.mode ?? 'system';
281
+ applyMode(rawMode === 'system' ? resolveSystemMode() : rawMode);
282
+
283
+ if (rawMode === 'system') {
284
+ if (typeof MutationObserver !== 'undefined' && doc.body) {
285
+ const bodyObs = new MutationObserver(() => applyMode(resolveSystemMode()));
286
+ bodyObs.observe(doc.body, { attributes: true, attributeFilter: ['class'] });
287
+ cleanups.push(() => bodyObs.disconnect());
288
+ }
289
+ const mq =
290
+ typeof win.matchMedia === 'function'
291
+ ? win.matchMedia('(prefers-color-scheme: dark)')
292
+ : null;
293
+ if (mq) {
294
+ const onMqChange = (): void => {
295
+ const body = doc.body;
296
+ const hasVsCode =
297
+ body &&
298
+ (body.classList.contains('vscode-dark') ||
299
+ body.classList.contains('vscode-light') ||
300
+ body.classList.contains('vscode-high-contrast') ||
301
+ body.classList.contains('vscode-high-contrast-dark') ||
302
+ body.classList.contains('vscode-high-contrast-light'));
303
+ if (!hasVsCode) applyMode(mq.matches ? 'dark' : 'light');
304
+ };
305
+ mq.addEventListener('change', onMqChange);
306
+ cleanups.push(() => mq.removeEventListener('change', onMqChange));
307
+ }
308
+ }
309
+
310
+ // ===== Floating UI fade =====
183
311
  let fadeTimer: ReturnType<typeof setTimeout> | null = null;
184
- function showToolbar(): void {
312
+
313
+ function showFloatingUI(): void {
185
314
  els.chrome.classList.remove('faded');
315
+ els.minimap.classList.remove('faded');
186
316
  if (fadeTimer) clearTimeout(fadeTimer);
187
- fadeTimer = setTimeout(() => els.chrome.classList.add('faded'), 2000);
317
+ fadeTimer = setTimeout(() => {
318
+ els.chrome.classList.add('faded');
319
+ els.minimap.classList.add('faded');
320
+ }, 2000);
188
321
  }
189
- on(doc, 'mousemove', showToolbar);
190
- showToolbar();
322
+
323
+ on(doc, 'mousemove', showFloatingUI);
324
+ showFloatingUI();
325
+
326
+ // ===== Collapse / restore =====
327
+ // « collapses the toolbar to a translucent puck (just the drag grip
328
+ // + a » restore arrow); » expands it back to the full toolbar.
329
+ on(els.collapseBtn, 'click', (e: MouseEvent) => {
330
+ e.stopPropagation();
331
+ closeAllMenus();
332
+ els.chrome.classList.add('collapsed');
333
+ repositionChrome();
334
+ });
335
+ on(els.restoreBtn, 'click', (e: MouseEvent) => {
336
+ e.stopPropagation();
337
+ els.chrome.classList.remove('collapsed');
338
+ repositionChrome();
339
+ });
340
+
341
+ // ===== Toolbar positioning + drag =====
342
+ // The chrome defaults to the upper-right corner and tracks it on
343
+ // resize (`chromePinnedRight`). Once dragged it becomes free-floating
344
+ // and is merely clamped back into view. Either way the position is
345
+ // always expressed as explicit `left`/`top` so a narrowing viewport
346
+ // shifts the toolbar left instead of squishing the row.
347
+ let chromePinnedRight = savedChromePosition === null;
348
+
349
+ let chromeDragStart: {
350
+ pointerX: number;
351
+ pointerY: number;
352
+ chromeLeft: number;
353
+ chromeTop: number;
354
+ } | null = null;
355
+
356
+ function getGutter(): number {
357
+ const val = getComputedStyle(rootEl).getPropertyValue('--nl-preview-gutter').trim();
358
+ return parseInt(val, 10) || 8;
359
+ }
360
+
361
+ function repositionChrome(): void {
362
+ const g = getGutter();
363
+ const cw = els.chrome.offsetWidth;
364
+ const ch = els.chrome.offsetHeight;
365
+ const maxLeft = Math.max(g, rootEl.clientWidth - cw - g);
366
+ const maxTop = Math.max(g, rootEl.clientHeight - ch - g);
367
+ let left: number;
368
+ let top: number;
369
+ if (chromePinnedRight) {
370
+ left = rootEl.clientWidth - cw - g;
371
+ top = g;
372
+ } else {
373
+ left = parseInt(els.chrome.style.left, 10);
374
+ top = parseInt(els.chrome.style.top, 10);
375
+ if (Number.isNaN(left)) left = savedChromePosition?.left ?? g;
376
+ if (Number.isNaN(top)) top = savedChromePosition?.top ?? g;
377
+ }
378
+ els.chrome.style.right = 'auto';
379
+ els.chrome.style.left = `${Math.max(g, Math.min(left, maxLeft))}px`;
380
+ els.chrome.style.top = `${Math.max(g, Math.min(top, maxTop))}px`;
381
+ }
382
+
383
+ function ensureChromeLeftTopAnchoring(): void {
384
+ if (els.chrome.style.left !== '') return;
385
+ els.chrome.style.right = 'auto';
386
+ els.chrome.style.left = `${els.chrome.offsetLeft}px`;
387
+ els.chrome.style.top = `${els.chrome.offsetTop}px`;
388
+ }
389
+
390
+ on<PointerEvent>(els.toolbarHandle, 'pointerdown', (e) => {
391
+ e.preventDefault();
392
+ chromePinnedRight = false;
393
+ ensureChromeLeftTopAnchoring();
394
+ chromeDragStart = {
395
+ pointerX: e.clientX,
396
+ pointerY: e.clientY,
397
+ chromeLeft: parseInt(els.chrome.style.left, 10) || 0,
398
+ chromeTop: parseInt(els.chrome.style.top, 10) || 0,
399
+ };
400
+ els.chrome.classList.add('dragging');
401
+ els.toolbarHandle.setPointerCapture(e.pointerId);
402
+ });
403
+
404
+ on<PointerEvent>(els.toolbarHandle, 'pointermove', (e) => {
405
+ if (!chromeDragStart) return;
406
+ const dx = e.clientX - chromeDragStart.pointerX;
407
+ const dy = e.clientY - chromeDragStart.pointerY;
408
+ const rawLeft = chromeDragStart.chromeLeft + dx;
409
+ const rawTop = chromeDragStart.chromeTop + dy;
410
+ const g = getGutter();
411
+ const maxLeft = Math.max(g, rootEl.clientWidth - els.chrome.offsetWidth - g);
412
+ const maxTop = Math.max(g, rootEl.clientHeight - els.chrome.offsetHeight - g);
413
+ els.chrome.style.left = `${Math.max(g, Math.min(rawLeft, maxLeft))}px`;
414
+ els.chrome.style.top = `${Math.max(g, Math.min(rawTop, maxTop))}px`;
415
+ });
416
+
417
+ function endChromeDrag(): void {
418
+ if (!chromeDragStart) return;
419
+ chromeDragStart = null;
420
+ els.chrome.classList.remove('dragging');
421
+ savedChromePosition = {
422
+ left: parseInt(els.chrome.style.left, 10) || 0,
423
+ top: parseInt(els.chrome.style.top, 10) || 0,
424
+ };
425
+ }
426
+
427
+ on<PointerEvent>(els.toolbarHandle, 'pointerup', endChromeDrag);
428
+ on<PointerEvent>(els.toolbarHandle, 'pointercancel', () => {
429
+ chromeDragStart = null;
430
+ els.chrome.classList.remove('dragging');
431
+ });
191
432
 
192
433
  // ===== Transform =====
193
434
  function applyTransform(): void {
@@ -225,6 +466,7 @@ export function mountPreview(
225
466
  function fitPage(): void {
226
467
  if (!state.naturalWidth || !state.naturalHeight) return;
227
468
  state.activeFit = 'fitPage';
469
+ state.isDirty = false;
228
470
  const sx = els.viewport.clientWidth / state.naturalWidth;
229
471
  const sy = els.viewport.clientHeight / state.naturalHeight;
230
472
  setScale(Math.min(sx, sy));
@@ -235,6 +477,7 @@ export function mountPreview(
235
477
  function fitWidth(): void {
236
478
  if (!state.naturalWidth) return;
237
479
  state.activeFit = 'fitWidth';
480
+ state.isDirty = false;
238
481
  setScale(els.viewport.clientWidth / state.naturalWidth);
239
482
  els.viewport.scrollLeft = 0;
240
483
  els.viewport.scrollTop = 0;
@@ -242,6 +485,7 @@ export function mountPreview(
242
485
 
243
486
  function actualSize(): void {
244
487
  state.activeFit = 'manual';
488
+ state.isDirty = true;
245
489
  setScale(1);
246
490
  }
247
491
 
@@ -252,8 +496,26 @@ export function mountPreview(
252
496
  }
253
497
 
254
498
  function reapplyActiveFit(): void {
255
- if (state.activeFit === 'fitPage') fitPage();
256
- else if (state.activeFit === 'fitWidth') fitWidth();
499
+ const newW = els.viewport.clientWidth;
500
+ const newH = els.viewport.clientHeight;
501
+
502
+ if (state.isDirty) {
503
+ const oldW = state.lastViewportWidth || newW;
504
+ const oldH = state.lastViewportHeight || newH;
505
+ if (state.scale > 0 && (oldW !== newW || oldH !== newH)) {
506
+ const cx = (els.viewport.scrollLeft + oldW / 2) / state.scale;
507
+ const cy = (els.viewport.scrollTop + oldH / 2) / state.scale;
508
+ els.viewport.scrollLeft = Math.max(0, cx * state.scale - newW / 2);
509
+ els.viewport.scrollTop = Math.max(0, cy * state.scale - newH / 2);
510
+ }
511
+ } else if (state.activeFit === 'fitPage') {
512
+ fitPage();
513
+ } else if (state.activeFit === 'fitWidth') {
514
+ fitWidth();
515
+ }
516
+
517
+ state.lastViewportWidth = newW;
518
+ state.lastViewportHeight = newH;
257
519
  }
258
520
 
259
521
  // ===== Render handling =====
@@ -342,6 +604,9 @@ export function mountPreview(
342
604
  if (!errors && warnings) els.diagSummary.classList.add('warn-only');
343
605
  if (!errors && !warnings) els.diagSummary.classList.add('clean');
344
606
 
607
+ // Hide Open Problems link when there are no issues
608
+ els.openProblems.hidden = errors + warnings === 0;
609
+
345
610
  const tbody = els.diagTbody;
346
611
  while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
347
612
 
@@ -412,8 +677,8 @@ export function mountPreview(
412
677
  }
413
678
  svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
414
679
  }
415
- const maxW = 160;
416
- const maxH = 120;
680
+ const maxW = 120;
681
+ const maxH = 90;
417
682
  if (state.naturalWidth && state.naturalHeight) {
418
683
  const ratio = state.naturalWidth / state.naturalHeight;
419
684
  let w = maxW;
@@ -445,7 +710,7 @@ export function mountPreview(
445
710
  }
446
711
 
447
712
  function updateMinimapVisibility(): void {
448
- if (!state.showMinimap || state.minimapDismissedThisSession) {
713
+ if (!state.showMinimap) {
449
714
  els.minimap.classList.add('hidden');
450
715
  return;
451
716
  }
@@ -460,12 +725,6 @@ export function mountPreview(
460
725
  }
461
726
  }
462
727
 
463
- on(els.minimapClose, 'click', (e: Event) => {
464
- e.stopPropagation();
465
- state.minimapDismissedThisSession = true;
466
- els.minimap.classList.add('hidden');
467
- });
468
-
469
728
  function panToMinimapPoint(clientX: number, clientY: number): void {
470
729
  const rect = els.minimapCanvas.getBoundingClientRect();
471
730
  const x = (clientX - rect.left) / rect.width;
@@ -490,11 +749,26 @@ export function mountPreview(
490
749
  });
491
750
 
492
751
  on(els.viewport, 'scroll', updateMinimapRect);
493
- on(win, 'resize', () => {
494
- reapplyActiveFit();
495
- updateMinimapRect();
496
- updateMinimapVisibility();
497
- });
752
+
753
+ let resizeTimer: ReturnType<typeof setTimeout> | null = null;
754
+ function handleResize(): void {
755
+ if (resizeTimer) clearTimeout(resizeTimer);
756
+ resizeTimer = setTimeout(() => {
757
+ resizeTimer = null;
758
+ reapplyActiveFit();
759
+ updateMinimapRect();
760
+ updateMinimapVisibility();
761
+ repositionChrome();
762
+ }, 50);
763
+ }
764
+
765
+ on(win, 'resize', handleResize);
766
+
767
+ if (typeof ResizeObserver !== 'undefined') {
768
+ const ro = new ResizeObserver(handleResize);
769
+ ro.observe(els.viewport);
770
+ cleanups.push(() => ro.disconnect());
771
+ }
498
772
 
499
773
  // ===== Wheel zoom =====
500
774
  on(
@@ -504,6 +778,7 @@ export function mountPreview(
504
778
  if (!(e.ctrlKey || e.metaKey)) return;
505
779
  e.preventDefault();
506
780
  state.activeFit = 'manual';
781
+ state.isDirty = true;
507
782
  const rect = els.viewport.getBoundingClientRect();
508
783
  const anchorX = e.clientX - rect.left;
509
784
  const anchorY = e.clientY - rect.top;
@@ -547,6 +822,7 @@ export function mountPreview(
547
822
  });
548
823
  on(els.viewport, 'mousedown', (e: MouseEvent) => {
549
824
  if (!spaceDown) return;
825
+ state.isDirty = true;
550
826
  dragOrigin = {
551
827
  x: e.clientX,
552
828
  y: e.clientY,
@@ -571,92 +847,140 @@ export function mountPreview(
571
847
  // ===== Toolbar buttons =====
572
848
  on(els.zoomOut, 'click', () => {
573
849
  state.activeFit = 'manual';
850
+ state.isDirty = true;
574
851
  setScale(state.scale / 1.1);
575
852
  });
576
853
  on(els.zoomIn, 'click', () => {
577
854
  state.activeFit = 'manual';
855
+ state.isDirty = true;
578
856
  setScale(state.scale * 1.1);
579
857
  });
580
858
  on(els.zoomReset, 'click', actualSize);
581
859
  on(els.fitWidth, 'click', fitWidth);
582
860
  on(els.fitPage, 'click', fitPage);
583
861
 
584
- // ===== Dropdowns =====
585
- function setupDropdown(toggle: HTMLButtonElement, menu: HTMLUListElement): void {
586
- on(toggle, 'click', (e: MouseEvent) => {
587
- e.stopPropagation();
588
- els.saveMenu.hidden = true;
589
- els.copyMenu.hidden = true;
590
- els.viewMenu.hidden = true;
591
- menu.hidden = !menu.hidden;
592
- });
862
+ // ===== Menu state helpers =====
863
+ function closeSubMenus(): void {
864
+ els.formatMenu.hidden = true;
865
+ els.themeMenu.hidden = true;
866
+ els.nowPicker.hidden = true;
867
+ els.linksMenu.hidden = true;
593
868
  }
594
- setupDropdown(els.viewToggle, els.viewMenu);
595
- setupDropdown(els.saveToggle, els.saveMenu);
596
- setupDropdown(els.copyToggle, els.copyMenu);
597
- on(doc, 'click', () => {
598
- els.saveMenu.hidden = true;
599
- els.copyMenu.hidden = true;
600
- els.viewMenu.hidden = true;
601
- });
602
- for (const m of [els.saveMenu, els.copyMenu, els.viewMenu]) {
603
- on(m, 'click', (e: Event) => e.stopPropagation());
869
+
870
+ function closeAllMenus(): void {
871
+ els.moreMenu.hidden = true;
872
+ closeSubMenus();
604
873
  }
605
874
 
606
- // ===== View options =====
607
- function refreshViewMenu(): void {
608
- const items = els.viewMenu.querySelectorAll<HTMLButtonElement>('.view-opt');
609
- for (const item of Array.from(items)) {
610
- const opt = item.getAttribute('data-opt');
611
- const value = item.getAttribute('data-value');
612
- let active = false;
613
- if (opt === 'theme') active = state.view.theme === value;
614
- else if (opt === 'now') active = state.view.now === value;
615
- else if (opt === 'showLinks') active = state.view.showLinks;
616
- item.setAttribute('data-active', active ? 'true' : 'false');
875
+ /**
876
+ * Keep a just-opened flyout (more-menu, a sub-dropdown, or the
877
+ * calendar) inside the preview root + gutters. `defaultSide` is the
878
+ * edge the panel is anchored to in CSS; if opening that way would
879
+ * spill past the opposite gutter the panel is flipped, then any
880
+ * residual horizontal overflow is nudged back with a transform and
881
+ * excess height is capped with an internal scroll.
882
+ *
883
+ * `clampVertical` is off for the more-menu so capping its height
884
+ * never clips the calendar that pops out of it.
885
+ */
886
+ function placeFlyout(
887
+ panel: HTMLElement,
888
+ defaultSide: 'left' | 'right',
889
+ clampVertical = true,
890
+ ): void {
891
+ panel.classList.remove('flip');
892
+ panel.style.maxHeight = '';
893
+ panel.style.overflowY = '';
894
+ panel.style.transform = '';
895
+
896
+ const root = rootEl.getBoundingClientRect();
897
+ // Skip when the host hasn't laid out yet (e.g. happy-dom tests).
898
+ if (root.width === 0 && root.height === 0) return;
899
+ const g = getGutter();
900
+
901
+ let box = panel.getBoundingClientRect();
902
+ const fitsWidth = box.width <= root.width - 2 * g;
903
+ if (fitsWidth && defaultSide === 'left' && box.right > root.right - g) {
904
+ panel.classList.add('flip');
905
+ } else if (fitsWidth && defaultSide === 'right' && box.left < root.left + g) {
906
+ panel.classList.add('flip');
617
907
  }
618
- }
619
908
 
620
- function postViewOverrides(): void {
621
- const overrides: ViewOptionsOverrides = {};
622
- if (state.view.overridden.theme) overrides.theme = state.view.theme;
623
- if (state.view.overridden.now) overrides.now = state.view.now;
624
- if (state.view.overridden.showLinks) overrides.showLinks = state.view.showLinks;
625
- options.onViewOptions?.(overrides);
909
+ box = panel.getBoundingClientRect();
910
+ let dx = 0;
911
+ if (box.right > root.right - g) dx = root.right - g - box.right;
912
+ if (box.left + dx < root.left + g) dx = root.left + g - box.left;
913
+ if (dx !== 0) panel.style.transform = `translateX(${Math.round(dx)}px)`;
914
+
915
+ if (clampVertical) {
916
+ box = panel.getBoundingClientRect();
917
+ const avail = root.bottom - g - box.top;
918
+ if (box.height > avail && avail > 80) {
919
+ panel.style.maxHeight = `${Math.round(avail)}px`;
920
+ panel.style.overflowY = 'auto';
921
+ }
922
+ }
626
923
  }
627
924
 
628
- on(els.viewMenu, 'click', (e: Event) => {
629
- const target = e.target as HTMLElement | null;
630
- const btn = target?.closest('.view-opt') as HTMLButtonElement | null;
925
+ on(doc, 'click', closeAllMenus);
926
+ on(els.moreMenu, 'click', (e: Event) => e.stopPropagation());
927
+
928
+ // ===== More-menu toggle =====
929
+ on(els.moreToggle, 'click', (e: MouseEvent) => {
930
+ e.stopPropagation();
931
+ const opening = els.moreMenu.hidden;
932
+ closeAllMenus();
933
+ if (opening) {
934
+ els.moreMenu.hidden = false;
935
+ placeFlyout(els.moreMenu, 'right', /* clampVertical */ false);
936
+ }
937
+ });
938
+
939
+ // ===== Format dropdown =====
940
+ on(els.formatToggle, 'click', (e: MouseEvent) => {
941
+ e.stopPropagation();
942
+ const opening = els.formatMenu.hidden;
943
+ closeSubMenus();
944
+ if (opening) {
945
+ els.formatMenu.hidden = false;
946
+ placeFlyout(els.formatMenu, 'left');
947
+ }
948
+ });
949
+
950
+ on(els.formatMenu, 'click', (e: Event) => {
951
+ e.stopPropagation();
952
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.format-opt');
631
953
  if (!btn) return;
632
- const opt = btn.getAttribute('data-opt');
633
- const value = btn.getAttribute('data-value');
634
- if (opt === 'theme') {
635
- state.view.theme = value as ThemeOverride;
636
- state.view.overridden.theme = true;
637
- } else if (opt === 'now') {
638
- state.view.now = value as NowOverride;
639
- state.view.overridden.now = true;
640
- } else if (opt === 'showLinks') {
641
- state.view.showLinks = !state.view.showLinks;
642
- state.view.overridden.showLinks = true;
643
- }
644
- refreshViewMenu();
645
- els.viewMenu.hidden = true;
646
- postViewOverrides();
954
+ const value = btn.getAttribute('data-value') as 'svg' | 'png' | null;
955
+ if (!value) return;
956
+ state.export.format = value;
957
+ refreshFormatToggle();
958
+ els.formatMenu.hidden = true;
647
959
  });
648
960
 
649
- // ===== Save / copy actions =====
650
- const actionButtons = els.root.querySelectorAll<HTMLButtonElement>('[data-action]');
651
- for (const btn of Array.from(actionButtons)) {
652
- on(btn, 'click', () => {
653
- const action = btn.getAttribute('data-action');
654
- els.saveMenu.hidden = true;
655
- els.copyMenu.hidden = true;
656
- if (action) void handleExportAction(action);
657
- });
961
+ function refreshFormatToggle(): void {
962
+ els.formatToggle.textContent = `${state.export.format.toUpperCase()} \u25be`;
963
+ for (const btn of Array.from(
964
+ els.formatMenu.querySelectorAll<HTMLButtonElement>('.format-opt'),
965
+ )) {
966
+ btn.setAttribute(
967
+ 'data-active',
968
+ (btn.getAttribute('data-value') === state.export.format).toString(),
969
+ );
970
+ }
658
971
  }
659
972
 
973
+ // ===== Copy / export actions =====
974
+ on(els.copyAction, 'click', () => {
975
+ closeAllMenus();
976
+ void handleExportAction(`copy-${state.export.format}`);
977
+ });
978
+
979
+ on(els.exportAction, 'click', () => {
980
+ closeAllMenus();
981
+ void handleExportAction(`save-${state.export.format}`);
982
+ });
983
+
660
984
  async function handleExportAction(action: string): Promise<void> {
661
985
  if (!state.svgString) return;
662
986
  try {
@@ -735,14 +1059,299 @@ export function mountPreview(
735
1059
  });
736
1060
  }
737
1061
 
738
- // ===== Apply initial baselines =====
1062
+ // ===== Theme dropdown =====
1063
+ function buildThemeMenu(themes: string[]): void {
1064
+ els.themeMenu.innerHTML = '';
1065
+
1066
+ const autoLi = doc.createElement('li');
1067
+ const autoBtn = doc.createElement('button');
1068
+ autoBtn.className = 'btn theme-opt';
1069
+ autoBtn.setAttribute('data-value', 'auto');
1070
+ autoBtn.textContent = 'Auto';
1071
+ autoLi.appendChild(autoBtn);
1072
+ els.themeMenu.appendChild(autoLi);
1073
+
1074
+ for (const theme of themes) {
1075
+ const li = doc.createElement('li');
1076
+ const btn = doc.createElement('button');
1077
+ btn.className = 'btn theme-opt';
1078
+ btn.setAttribute('data-value', theme);
1079
+ const chip = doc.createElement('code');
1080
+ chip.className = 'code-chip';
1081
+ chip.textContent = theme;
1082
+ btn.appendChild(chip);
1083
+ li.appendChild(btn);
1084
+ els.themeMenu.appendChild(li);
1085
+ }
1086
+
1087
+ refreshThemeToggle();
1088
+ }
1089
+
1090
+ on(els.themeToggle, 'click', (e: MouseEvent) => {
1091
+ e.stopPropagation();
1092
+ const opening = els.themeMenu.hidden;
1093
+ closeSubMenus();
1094
+ if (opening) {
1095
+ els.themeMenu.hidden = false;
1096
+ placeFlyout(els.themeMenu, 'left');
1097
+ }
1098
+ });
1099
+
1100
+ on(els.themeMenu, 'click', (e: Event) => {
1101
+ e.stopPropagation();
1102
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.theme-opt');
1103
+ if (!btn) return;
1104
+ const value = btn.getAttribute('data-value');
1105
+ if (!value) return;
1106
+ state.view.theme = value as ThemeOverride;
1107
+ state.view.overridden.theme = true;
1108
+ refreshThemeToggle();
1109
+ els.themeMenu.hidden = true;
1110
+ postViewOverrides();
1111
+ });
1112
+
1113
+ function refreshThemeToggle(): void {
1114
+ if (state.view.theme === 'auto') {
1115
+ els.themeToggle.textContent = `Auto \u25be`;
1116
+ } else {
1117
+ els.themeToggle.innerHTML = '';
1118
+ const chip = doc.createElement('code');
1119
+ chip.className = 'code-chip';
1120
+ chip.textContent = state.view.theme;
1121
+ els.themeToggle.appendChild(chip);
1122
+ els.themeToggle.appendChild(doc.createTextNode(` \u25be`));
1123
+ }
1124
+ for (const btn of Array.from(
1125
+ els.themeMenu.querySelectorAll<HTMLButtonElement>('.theme-opt'),
1126
+ )) {
1127
+ btn.setAttribute(
1128
+ 'data-active',
1129
+ (btn.getAttribute('data-value') === state.view.theme).toString(),
1130
+ );
1131
+ }
1132
+ }
1133
+
1134
+ // ===== Now picker (calendar) =====
1135
+ let calendarYear = new Date().getFullYear();
1136
+ let calendarMonth = new Date().getMonth();
1137
+
1138
+ function buildCalendar(): void {
1139
+ const picker = els.nowPicker;
1140
+ picker.innerHTML = '';
1141
+ const year = calendarYear;
1142
+ const month = calendarMonth;
1143
+
1144
+ const nav = doc.createElement('div');
1145
+ nav.className = 'cal-nav';
1146
+
1147
+ const prevBtn = doc.createElement('button');
1148
+ prevBtn.className = 'btn cal-nav-btn';
1149
+ prevBtn.textContent = '\u2039';
1150
+ prevBtn.addEventListener('click', (e) => {
1151
+ e.stopPropagation();
1152
+ calendarMonth--;
1153
+ if (calendarMonth < 0) {
1154
+ calendarMonth = 11;
1155
+ calendarYear--;
1156
+ }
1157
+ buildCalendar();
1158
+ });
1159
+
1160
+ const heading = doc.createElement('span');
1161
+ heading.className = 'cal-heading';
1162
+ heading.textContent = new Intl.DateTimeFormat(state.locale, {
1163
+ month: 'long',
1164
+ year: 'numeric',
1165
+ }).format(new Date(year, month, 1));
1166
+
1167
+ const nextBtn = doc.createElement('button');
1168
+ nextBtn.className = 'btn cal-nav-btn';
1169
+ nextBtn.textContent = '\u203a';
1170
+ nextBtn.addEventListener('click', (e) => {
1171
+ e.stopPropagation();
1172
+ calendarMonth++;
1173
+ if (calendarMonth > 11) {
1174
+ calendarMonth = 0;
1175
+ calendarYear++;
1176
+ }
1177
+ buildCalendar();
1178
+ });
1179
+
1180
+ nav.appendChild(prevBtn);
1181
+ nav.appendChild(heading);
1182
+ nav.appendChild(nextBtn);
1183
+ picker.appendChild(nav);
1184
+
1185
+ const grid = doc.createElement('div');
1186
+ grid.className = 'cal-grid';
1187
+
1188
+ for (const d of ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']) {
1189
+ const cell = doc.createElement('span');
1190
+ cell.className = 'cal-dow';
1191
+ cell.textContent = d;
1192
+ grid.appendChild(cell);
1193
+ }
1194
+
1195
+ const firstDow = new Date(year, month, 1).getDay();
1196
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
1197
+
1198
+ for (let i = 0; i < firstDow; i++) {
1199
+ const empty = doc.createElement('span');
1200
+ empty.className = 'cal-empty';
1201
+ grid.appendChild(empty);
1202
+ }
1203
+
1204
+ const now = new Date();
1205
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
1206
+
1207
+ for (let d = 1; d <= daysInMonth; d++) {
1208
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
1209
+ const btn = doc.createElement('button');
1210
+ btn.className = 'btn cal-day';
1211
+ btn.textContent = String(d);
1212
+ if (dateStr === todayStr) btn.classList.add('is-today');
1213
+ if (state.view.now === dateStr) btn.classList.add('is-selected');
1214
+ btn.addEventListener('click', (e) => {
1215
+ e.stopPropagation();
1216
+ applyNow(dateStr);
1217
+ });
1218
+ grid.appendChild(btn);
1219
+ }
1220
+
1221
+ picker.appendChild(grid);
1222
+
1223
+ const footer = doc.createElement('div');
1224
+ footer.className = 'cal-footer';
1225
+
1226
+ const todayBtnEl = doc.createElement('button');
1227
+ todayBtnEl.className = 'btn cal-footer-btn';
1228
+ todayBtnEl.textContent = 'Today';
1229
+ todayBtnEl.addEventListener('click', (e) => {
1230
+ e.stopPropagation();
1231
+ applyNow('today');
1232
+ });
1233
+
1234
+ const noneBtnEl = doc.createElement('button');
1235
+ noneBtnEl.className = 'btn cal-footer-btn';
1236
+ noneBtnEl.textContent = 'None';
1237
+ noneBtnEl.addEventListener('click', (e) => {
1238
+ e.stopPropagation();
1239
+ applyNow('hide');
1240
+ });
1241
+
1242
+ footer.appendChild(todayBtnEl);
1243
+ footer.appendChild(noneBtnEl);
1244
+ picker.appendChild(footer);
1245
+ }
1246
+
1247
+ function applyNow(value: NowOverride): void {
1248
+ state.view.now = value;
1249
+ state.view.overridden.now = true;
1250
+ refreshNowToggle();
1251
+ els.nowPicker.hidden = true;
1252
+ postViewOverrides();
1253
+ }
1254
+
1255
+ on(els.nowToggle, 'click', (e: MouseEvent) => {
1256
+ e.stopPropagation();
1257
+ const opening = els.nowPicker.hidden;
1258
+ closeSubMenus();
1259
+ if (opening) {
1260
+ buildCalendar();
1261
+ els.nowPicker.hidden = false;
1262
+ placeFlyout(els.nowPicker, 'left');
1263
+ }
1264
+ });
1265
+
1266
+ function refreshNowToggle(): void {
1267
+ let label: string;
1268
+ if (state.view.now === 'today') {
1269
+ label = 'Today';
1270
+ } else if (state.view.now === 'hide') {
1271
+ label = 'None';
1272
+ } else {
1273
+ try {
1274
+ label = new Intl.DateTimeFormat(state.locale, { dateStyle: 'medium' }).format(
1275
+ new Date(`${state.view.now}T00:00:00`),
1276
+ );
1277
+ } catch {
1278
+ label = state.view.now;
1279
+ }
1280
+ }
1281
+ els.nowLabel.textContent = label;
1282
+ }
1283
+
1284
+ // ===== Show-links dropdown =====
1285
+ on(els.linksToggle, 'click', (e: MouseEvent) => {
1286
+ e.stopPropagation();
1287
+ const opening = els.linksMenu.hidden;
1288
+ closeSubMenus();
1289
+ if (opening) {
1290
+ els.linksMenu.hidden = false;
1291
+ placeFlyout(els.linksMenu, 'left');
1292
+ }
1293
+ });
1294
+
1295
+ on(els.linksMenu, 'click', (e: Event) => {
1296
+ e.stopPropagation();
1297
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.links-opt');
1298
+ if (!btn) return;
1299
+ const value = btn.getAttribute('data-value');
1300
+ if (value === null) return;
1301
+ state.view.showLinks = value === 'true';
1302
+ state.view.overridden.showLinks = true;
1303
+ refreshLinksToggle();
1304
+ els.linksMenu.hidden = true;
1305
+ postViewOverrides();
1306
+ });
1307
+
1308
+ function refreshLinksToggle(): void {
1309
+ els.linksToggle.textContent = `${state.view.showLinks ? 'Yes' : 'No'} \u25be`;
1310
+ for (const btn of Array.from(
1311
+ els.linksMenu.querySelectorAll<HTMLButtonElement>('.links-opt'),
1312
+ )) {
1313
+ btn.setAttribute(
1314
+ 'data-active',
1315
+ (
1316
+ btn.getAttribute('data-value') === (state.view.showLinks ? 'true' : 'false')
1317
+ ).toString(),
1318
+ );
1319
+ }
1320
+ }
1321
+
1322
+ // ===== View-options helpers =====
1323
+ function postViewOverrides(): void {
1324
+ const overrides: ViewOptionsOverrides = {};
1325
+ if (state.view.overridden.theme) overrides.theme = state.view.theme;
1326
+ if (state.view.overridden.now) overrides.now = state.view.now;
1327
+ if (state.view.overridden.showLinks) overrides.showLinks = state.view.showLinks;
1328
+ options.onViewOptions?.(overrides);
1329
+ }
1330
+
1331
+ function refreshAll(): void {
1332
+ refreshThemeToggle();
1333
+ refreshNowToggle();
1334
+ refreshLinksToggle();
1335
+ refreshFormatToggle();
1336
+ }
1337
+
1338
+ // ===== Theme control visibility =====
1339
+ if (options.themeControl === 'hide') {
1340
+ const themeRow = els.themeMenu.closest<HTMLElement>('.theme-control-row');
1341
+ if (themeRow) themeRow.style.display = 'none';
1342
+ }
1343
+
1344
+ // ===== Apply initial theme menu + baselines =====
1345
+ buildThemeMenu(options.availableThemes ?? ['light', 'dark', 'grayscale']);
1346
+
739
1347
  if (options.viewBaseline) {
740
1348
  applyBaseline(state, options.viewBaseline, true);
741
- refreshViewMenu();
742
- } else {
743
- refreshViewMenu();
744
1349
  }
1350
+ refreshAll();
745
1351
  updateMinimapVisibility();
1352
+ // Anchor the toolbar explicitly (default: upper-right) so resize
1353
+ // shifts it left rather than the CSS `right` anchor squishing it.
1354
+ repositionChrome();
746
1355
 
747
1356
  // ===== Imperative API =====
748
1357
  return {
@@ -751,16 +1360,26 @@ export function mountPreview(
751
1360
  setFatal: showFatal,
752
1361
  setViewBaseline(baseline, resetOverrides = false) {
753
1362
  applyBaseline(state, baseline, resetOverrides);
754
- refreshViewMenu();
1363
+ refreshAll();
755
1364
  },
756
1365
  setDefaultFit(fit) {
757
1366
  state.defaultFit = fit;
758
1367
  },
759
1368
  setShowMinimap(show) {
760
1369
  state.showMinimap = show;
761
- state.minimapDismissedThisSession = false;
762
1370
  updateMinimapVisibility();
763
1371
  },
1372
+ setMode(mode) {
1373
+ const resolved = mode === 'system' ? resolveSystemMode() : mode;
1374
+ applyMode(resolved);
1375
+ },
1376
+ setAvailableThemes(themes) {
1377
+ buildThemeMenu(themes);
1378
+ },
1379
+ setLocale(locale) {
1380
+ state.locale = locale;
1381
+ refreshNowToggle();
1382
+ },
764
1383
  fitPage,
765
1384
  fitWidth,
766
1385
  actualSize,
@@ -769,13 +1388,16 @@ export function mountPreview(
769
1388
  },
770
1389
  setZoom(scale) {
771
1390
  state.activeFit = 'manual';
1391
+ state.isDirty = true;
772
1392
  setScale(scale);
773
1393
  },
774
1394
  dispose() {
775
1395
  if (fadeTimer) clearTimeout(fadeTimer);
1396
+ if (resizeTimer) clearTimeout(resizeTimer);
776
1397
  for (const c of cleanups) c();
777
1398
  cleanups.length = 0;
778
1399
  rootEl.classList.remove('nl-preview-root');
1400
+ rootEl.removeAttribute('data-nl-mode');
779
1401
  rootEl.innerHTML = '';
780
1402
  },
781
1403
  };
@@ -790,10 +1412,13 @@ function applyBaseline(
790
1412
  state.view.theme = baseline.theme;
791
1413
  }
792
1414
  if (baseline.now !== undefined && !state.view.overridden.now) {
793
- // Setting is 'auto'/'none'/YYYY-MM-DD; the toolbar only exposes
794
- // today/hide, so collapse anything that isn't 'none' to 'today'
795
- // for the displayed checkmark.
796
- state.view.now = baseline.now === 'none' ? 'hide' : 'today';
1415
+ if (baseline.now === 'none' || baseline.now === 'hide') {
1416
+ state.view.now = 'hide';
1417
+ } else if (typeof baseline.now === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(baseline.now)) {
1418
+ state.view.now = baseline.now;
1419
+ } else {
1420
+ state.view.now = 'today';
1421
+ }
797
1422
  }
798
1423
  if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
799
1424
  state.view.showLinks = baseline.showLinks !== false;