@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/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,20 @@ 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
169
  minimapDismissedThisSession: boolean;
117
170
  firstRender: boolean;
171
+ /** Resolved chrome color scheme — set by mode resolution, never by diagram theme. */
172
+ mode: 'light' | 'dark';
173
+ locale: string;
174
+ export: { format: 'svg' | 'png' };
118
175
  view: {
119
176
  theme: ThemeOverride;
120
177
  now: NowOverride;
@@ -126,13 +183,23 @@ interface InternalState {
126
183
  let stylesheetMounted = false;
127
184
 
128
185
  /**
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.
186
+ * Last chrome position saved after a drag, shared across all shells in
187
+ * this JS session. Persists for the lifetime of the page.
133
188
  */
189
+ let savedChromePosition: { left: number; top: number } | null = null;
190
+
134
191
  function ensureStylesheet(doc: Document): void {
135
192
  if (stylesheetMounted) return;
193
+ // A CSP-restricted consumer (e.g. the VS Code webview, whose
194
+ // `style-src` is nonce-only with no `'unsafe-inline'`) pre-injects
195
+ // this stylesheet into the host HTML with a nonce, marked by the
196
+ // `data-nl-preview-shell` attribute. A second `<style>` created here
197
+ // would carry no nonce and be refused by the CSP, leaving the
198
+ // toolbar unstyled — so defer to the host-provided copy when present.
199
+ if (doc.querySelector('style[data-nl-preview-shell]')) {
200
+ stylesheetMounted = true;
201
+ return;
202
+ }
136
203
  const style = doc.createElement('style');
137
204
  style.setAttribute('data-nl-preview-shell', '');
138
205
  style.textContent = PREVIEW_SHELL_CSS;
@@ -157,9 +224,15 @@ export function mountPreview(
157
224
  naturalHeight: 0,
158
225
  defaultFit: options.initialFit ?? 'fitPage',
159
226
  activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
227
+ isDirty: false,
228
+ lastViewportWidth: 0,
229
+ lastViewportHeight: 0,
160
230
  showMinimap: options.showMinimap !== false,
161
231
  minimapDismissedThisSession: false,
162
232
  firstRender: true,
233
+ mode: 'dark',
234
+ locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
235
+ export: { format: 'svg' },
163
236
  view: {
164
237
  theme: 'auto',
165
238
  now: 'today',
@@ -179,15 +252,185 @@ export function mountPreview(
179
252
  cleanups.push(() => target.removeEventListener(type, handler as EventListener, opts));
180
253
  }
181
254
 
182
- // ===== Toolbar fade =====
255
+ // ===== Mode resolution =====
256
+ function resolveSystemMode(): 'light' | 'dark' {
257
+ const body = doc.body;
258
+ if (body) {
259
+ if (
260
+ body.classList.contains('vscode-dark') ||
261
+ body.classList.contains('vscode-high-contrast-dark')
262
+ )
263
+ return 'dark';
264
+ if (
265
+ body.classList.contains('vscode-light') ||
266
+ body.classList.contains('vscode-high-contrast-light') ||
267
+ body.classList.contains('vscode-high-contrast')
268
+ )
269
+ return 'light';
270
+ }
271
+ if (typeof win.matchMedia === 'function') {
272
+ return win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
273
+ }
274
+ return 'dark';
275
+ }
276
+
277
+ function applyMode(resolved: 'light' | 'dark'): void {
278
+ state.mode = resolved;
279
+ rootEl.setAttribute('data-nl-mode', resolved);
280
+ }
281
+
282
+ const rawMode = options.mode ?? 'system';
283
+ applyMode(rawMode === 'system' ? resolveSystemMode() : rawMode);
284
+
285
+ if (rawMode === 'system') {
286
+ if (typeof MutationObserver !== 'undefined' && doc.body) {
287
+ const bodyObs = new MutationObserver(() => applyMode(resolveSystemMode()));
288
+ bodyObs.observe(doc.body, { attributes: true, attributeFilter: ['class'] });
289
+ cleanups.push(() => bodyObs.disconnect());
290
+ }
291
+ const mq =
292
+ typeof win.matchMedia === 'function'
293
+ ? win.matchMedia('(prefers-color-scheme: dark)')
294
+ : null;
295
+ if (mq) {
296
+ const onMqChange = (): void => {
297
+ const body = doc.body;
298
+ const hasVsCode =
299
+ body &&
300
+ (body.classList.contains('vscode-dark') ||
301
+ body.classList.contains('vscode-light') ||
302
+ body.classList.contains('vscode-high-contrast') ||
303
+ body.classList.contains('vscode-high-contrast-dark') ||
304
+ body.classList.contains('vscode-high-contrast-light'));
305
+ if (!hasVsCode) applyMode(mq.matches ? 'dark' : 'light');
306
+ };
307
+ mq.addEventListener('change', onMqChange);
308
+ cleanups.push(() => mq.removeEventListener('change', onMqChange));
309
+ }
310
+ }
311
+
312
+ // ===== Floating UI fade =====
183
313
  let fadeTimer: ReturnType<typeof setTimeout> | null = null;
184
- function showToolbar(): void {
314
+
315
+ function showFloatingUI(): void {
185
316
  els.chrome.classList.remove('faded');
317
+ els.minimap.classList.remove('faded');
186
318
  if (fadeTimer) clearTimeout(fadeTimer);
187
- fadeTimer = setTimeout(() => els.chrome.classList.add('faded'), 2000);
319
+ fadeTimer = setTimeout(() => {
320
+ els.chrome.classList.add('faded');
321
+ els.minimap.classList.add('faded');
322
+ }, 2000);
188
323
  }
189
- on(doc, 'mousemove', showToolbar);
190
- showToolbar();
324
+
325
+ on(doc, 'mousemove', showFloatingUI);
326
+ showFloatingUI();
327
+
328
+ // ===== Collapse / restore =====
329
+ // « collapses the toolbar to a translucent puck (just the drag grip
330
+ // + a » restore arrow); » expands it back to the full toolbar.
331
+ on(els.collapseBtn, 'click', (e: MouseEvent) => {
332
+ e.stopPropagation();
333
+ closeAllMenus();
334
+ els.chrome.classList.add('collapsed');
335
+ repositionChrome();
336
+ });
337
+ on(els.restoreBtn, 'click', (e: MouseEvent) => {
338
+ e.stopPropagation();
339
+ els.chrome.classList.remove('collapsed');
340
+ repositionChrome();
341
+ });
342
+
343
+ // ===== Toolbar positioning + drag =====
344
+ // The chrome defaults to the upper-right corner and tracks it on
345
+ // resize (`chromePinnedRight`). Once dragged it becomes free-floating
346
+ // and is merely clamped back into view. Either way the position is
347
+ // always expressed as explicit `left`/`top` so a narrowing viewport
348
+ // shifts the toolbar left instead of squishing the row.
349
+ let chromePinnedRight = savedChromePosition === null;
350
+
351
+ let chromeDragStart: {
352
+ pointerX: number;
353
+ pointerY: number;
354
+ chromeLeft: number;
355
+ chromeTop: number;
356
+ } | null = null;
357
+
358
+ function getGutter(): number {
359
+ const val = getComputedStyle(rootEl).getPropertyValue('--nl-preview-gutter').trim();
360
+ return parseInt(val, 10) || 8;
361
+ }
362
+
363
+ function repositionChrome(): void {
364
+ const g = getGutter();
365
+ const cw = els.chrome.offsetWidth;
366
+ const ch = els.chrome.offsetHeight;
367
+ const maxLeft = Math.max(g, rootEl.clientWidth - cw - g);
368
+ const maxTop = Math.max(g, rootEl.clientHeight - ch - g);
369
+ let left: number;
370
+ let top: number;
371
+ if (chromePinnedRight) {
372
+ left = rootEl.clientWidth - cw - g;
373
+ top = g;
374
+ } else {
375
+ left = parseInt(els.chrome.style.left, 10);
376
+ top = parseInt(els.chrome.style.top, 10);
377
+ if (Number.isNaN(left)) left = savedChromePosition?.left ?? g;
378
+ if (Number.isNaN(top)) top = savedChromePosition?.top ?? g;
379
+ }
380
+ els.chrome.style.right = 'auto';
381
+ els.chrome.style.left = `${Math.max(g, Math.min(left, maxLeft))}px`;
382
+ els.chrome.style.top = `${Math.max(g, Math.min(top, maxTop))}px`;
383
+ }
384
+
385
+ function ensureChromeLeftTopAnchoring(): void {
386
+ if (els.chrome.style.left !== '') return;
387
+ els.chrome.style.right = 'auto';
388
+ els.chrome.style.left = `${els.chrome.offsetLeft}px`;
389
+ els.chrome.style.top = `${els.chrome.offsetTop}px`;
390
+ }
391
+
392
+ on<PointerEvent>(els.toolbarHandle, 'pointerdown', (e) => {
393
+ e.preventDefault();
394
+ chromePinnedRight = false;
395
+ ensureChromeLeftTopAnchoring();
396
+ chromeDragStart = {
397
+ pointerX: e.clientX,
398
+ pointerY: e.clientY,
399
+ chromeLeft: parseInt(els.chrome.style.left, 10) || 0,
400
+ chromeTop: parseInt(els.chrome.style.top, 10) || 0,
401
+ };
402
+ els.chrome.classList.add('dragging');
403
+ els.toolbarHandle.setPointerCapture(e.pointerId);
404
+ });
405
+
406
+ on<PointerEvent>(els.toolbarHandle, 'pointermove', (e) => {
407
+ if (!chromeDragStart) return;
408
+ const dx = e.clientX - chromeDragStart.pointerX;
409
+ const dy = e.clientY - chromeDragStart.pointerY;
410
+ const rawLeft = chromeDragStart.chromeLeft + dx;
411
+ const rawTop = chromeDragStart.chromeTop + dy;
412
+ const g = getGutter();
413
+ const maxLeft = Math.max(g, rootEl.clientWidth - els.chrome.offsetWidth - g);
414
+ const maxTop = Math.max(g, rootEl.clientHeight - els.chrome.offsetHeight - g);
415
+ els.chrome.style.left = `${Math.max(g, Math.min(rawLeft, maxLeft))}px`;
416
+ els.chrome.style.top = `${Math.max(g, Math.min(rawTop, maxTop))}px`;
417
+ });
418
+
419
+ function endChromeDrag(): void {
420
+ if (!chromeDragStart) return;
421
+ chromeDragStart = null;
422
+ els.chrome.classList.remove('dragging');
423
+ savedChromePosition = {
424
+ left: parseInt(els.chrome.style.left, 10) || 0,
425
+ top: parseInt(els.chrome.style.top, 10) || 0,
426
+ };
427
+ }
428
+
429
+ on<PointerEvent>(els.toolbarHandle, 'pointerup', endChromeDrag);
430
+ on<PointerEvent>(els.toolbarHandle, 'pointercancel', () => {
431
+ chromeDragStart = null;
432
+ els.chrome.classList.remove('dragging');
433
+ });
191
434
 
192
435
  // ===== Transform =====
193
436
  function applyTransform(): void {
@@ -225,6 +468,7 @@ export function mountPreview(
225
468
  function fitPage(): void {
226
469
  if (!state.naturalWidth || !state.naturalHeight) return;
227
470
  state.activeFit = 'fitPage';
471
+ state.isDirty = false;
228
472
  const sx = els.viewport.clientWidth / state.naturalWidth;
229
473
  const sy = els.viewport.clientHeight / state.naturalHeight;
230
474
  setScale(Math.min(sx, sy));
@@ -235,6 +479,7 @@ export function mountPreview(
235
479
  function fitWidth(): void {
236
480
  if (!state.naturalWidth) return;
237
481
  state.activeFit = 'fitWidth';
482
+ state.isDirty = false;
238
483
  setScale(els.viewport.clientWidth / state.naturalWidth);
239
484
  els.viewport.scrollLeft = 0;
240
485
  els.viewport.scrollTop = 0;
@@ -242,6 +487,7 @@ export function mountPreview(
242
487
 
243
488
  function actualSize(): void {
244
489
  state.activeFit = 'manual';
490
+ state.isDirty = true;
245
491
  setScale(1);
246
492
  }
247
493
 
@@ -252,8 +498,26 @@ export function mountPreview(
252
498
  }
253
499
 
254
500
  function reapplyActiveFit(): void {
255
- if (state.activeFit === 'fitPage') fitPage();
256
- else if (state.activeFit === 'fitWidth') fitWidth();
501
+ const newW = els.viewport.clientWidth;
502
+ const newH = els.viewport.clientHeight;
503
+
504
+ if (state.isDirty) {
505
+ const oldW = state.lastViewportWidth || newW;
506
+ const oldH = state.lastViewportHeight || newH;
507
+ if (state.scale > 0 && (oldW !== newW || oldH !== newH)) {
508
+ const cx = (els.viewport.scrollLeft + oldW / 2) / state.scale;
509
+ const cy = (els.viewport.scrollTop + oldH / 2) / state.scale;
510
+ els.viewport.scrollLeft = Math.max(0, cx * state.scale - newW / 2);
511
+ els.viewport.scrollTop = Math.max(0, cy * state.scale - newH / 2);
512
+ }
513
+ } else if (state.activeFit === 'fitPage') {
514
+ fitPage();
515
+ } else if (state.activeFit === 'fitWidth') {
516
+ fitWidth();
517
+ }
518
+
519
+ state.lastViewportWidth = newW;
520
+ state.lastViewportHeight = newH;
257
521
  }
258
522
 
259
523
  // ===== Render handling =====
@@ -342,6 +606,9 @@ export function mountPreview(
342
606
  if (!errors && warnings) els.diagSummary.classList.add('warn-only');
343
607
  if (!errors && !warnings) els.diagSummary.classList.add('clean');
344
608
 
609
+ // Hide Open Problems link when there are no issues
610
+ els.openProblems.hidden = errors + warnings === 0;
611
+
345
612
  const tbody = els.diagTbody;
346
613
  while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
347
614
 
@@ -412,8 +679,8 @@ export function mountPreview(
412
679
  }
413
680
  svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
414
681
  }
415
- const maxW = 160;
416
- const maxH = 120;
682
+ const maxW = 120;
683
+ const maxH = 90;
417
684
  if (state.naturalWidth && state.naturalHeight) {
418
685
  const ratio = state.naturalWidth / state.naturalHeight;
419
686
  let w = maxW;
@@ -490,11 +757,26 @@ export function mountPreview(
490
757
  });
491
758
 
492
759
  on(els.viewport, 'scroll', updateMinimapRect);
493
- on(win, 'resize', () => {
494
- reapplyActiveFit();
495
- updateMinimapRect();
496
- updateMinimapVisibility();
497
- });
760
+
761
+ let resizeTimer: ReturnType<typeof setTimeout> | null = null;
762
+ function handleResize(): void {
763
+ if (resizeTimer) clearTimeout(resizeTimer);
764
+ resizeTimer = setTimeout(() => {
765
+ resizeTimer = null;
766
+ reapplyActiveFit();
767
+ updateMinimapRect();
768
+ updateMinimapVisibility();
769
+ repositionChrome();
770
+ }, 50);
771
+ }
772
+
773
+ on(win, 'resize', handleResize);
774
+
775
+ if (typeof ResizeObserver !== 'undefined') {
776
+ const ro = new ResizeObserver(handleResize);
777
+ ro.observe(els.viewport);
778
+ cleanups.push(() => ro.disconnect());
779
+ }
498
780
 
499
781
  // ===== Wheel zoom =====
500
782
  on(
@@ -504,6 +786,7 @@ export function mountPreview(
504
786
  if (!(e.ctrlKey || e.metaKey)) return;
505
787
  e.preventDefault();
506
788
  state.activeFit = 'manual';
789
+ state.isDirty = true;
507
790
  const rect = els.viewport.getBoundingClientRect();
508
791
  const anchorX = e.clientX - rect.left;
509
792
  const anchorY = e.clientY - rect.top;
@@ -547,6 +830,7 @@ export function mountPreview(
547
830
  });
548
831
  on(els.viewport, 'mousedown', (e: MouseEvent) => {
549
832
  if (!spaceDown) return;
833
+ state.isDirty = true;
550
834
  dragOrigin = {
551
835
  x: e.clientX,
552
836
  y: e.clientY,
@@ -571,92 +855,140 @@ export function mountPreview(
571
855
  // ===== Toolbar buttons =====
572
856
  on(els.zoomOut, 'click', () => {
573
857
  state.activeFit = 'manual';
858
+ state.isDirty = true;
574
859
  setScale(state.scale / 1.1);
575
860
  });
576
861
  on(els.zoomIn, 'click', () => {
577
862
  state.activeFit = 'manual';
863
+ state.isDirty = true;
578
864
  setScale(state.scale * 1.1);
579
865
  });
580
866
  on(els.zoomReset, 'click', actualSize);
581
867
  on(els.fitWidth, 'click', fitWidth);
582
868
  on(els.fitPage, 'click', fitPage);
583
869
 
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
- });
870
+ // ===== Menu state helpers =====
871
+ function closeSubMenus(): void {
872
+ els.formatMenu.hidden = true;
873
+ els.themeMenu.hidden = true;
874
+ els.nowPicker.hidden = true;
875
+ els.linksMenu.hidden = true;
593
876
  }
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());
877
+
878
+ function closeAllMenus(): void {
879
+ els.moreMenu.hidden = true;
880
+ closeSubMenus();
604
881
  }
605
882
 
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');
883
+ /**
884
+ * Keep a just-opened flyout (more-menu, a sub-dropdown, or the
885
+ * calendar) inside the preview root + gutters. `defaultSide` is the
886
+ * edge the panel is anchored to in CSS; if opening that way would
887
+ * spill past the opposite gutter the panel is flipped, then any
888
+ * residual horizontal overflow is nudged back with a transform and
889
+ * excess height is capped with an internal scroll.
890
+ *
891
+ * `clampVertical` is off for the more-menu so capping its height
892
+ * never clips the calendar that pops out of it.
893
+ */
894
+ function placeFlyout(
895
+ panel: HTMLElement,
896
+ defaultSide: 'left' | 'right',
897
+ clampVertical = true,
898
+ ): void {
899
+ panel.classList.remove('flip');
900
+ panel.style.maxHeight = '';
901
+ panel.style.overflowY = '';
902
+ panel.style.transform = '';
903
+
904
+ const root = rootEl.getBoundingClientRect();
905
+ // Skip when the host hasn't laid out yet (e.g. happy-dom tests).
906
+ if (root.width === 0 && root.height === 0) return;
907
+ const g = getGutter();
908
+
909
+ let box = panel.getBoundingClientRect();
910
+ const fitsWidth = box.width <= root.width - 2 * g;
911
+ if (fitsWidth && defaultSide === 'left' && box.right > root.right - g) {
912
+ panel.classList.add('flip');
913
+ } else if (fitsWidth && defaultSide === 'right' && box.left < root.left + g) {
914
+ panel.classList.add('flip');
617
915
  }
618
- }
619
916
 
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);
917
+ box = panel.getBoundingClientRect();
918
+ let dx = 0;
919
+ if (box.right > root.right - g) dx = root.right - g - box.right;
920
+ if (box.left + dx < root.left + g) dx = root.left + g - box.left;
921
+ if (dx !== 0) panel.style.transform = `translateX(${Math.round(dx)}px)`;
922
+
923
+ if (clampVertical) {
924
+ box = panel.getBoundingClientRect();
925
+ const avail = root.bottom - g - box.top;
926
+ if (box.height > avail && avail > 80) {
927
+ panel.style.maxHeight = `${Math.round(avail)}px`;
928
+ panel.style.overflowY = 'auto';
929
+ }
930
+ }
626
931
  }
627
932
 
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;
933
+ on(doc, 'click', closeAllMenus);
934
+ on(els.moreMenu, 'click', (e: Event) => e.stopPropagation());
935
+
936
+ // ===== More-menu toggle =====
937
+ on(els.moreToggle, 'click', (e: MouseEvent) => {
938
+ e.stopPropagation();
939
+ const opening = els.moreMenu.hidden;
940
+ closeAllMenus();
941
+ if (opening) {
942
+ els.moreMenu.hidden = false;
943
+ placeFlyout(els.moreMenu, 'right', /* clampVertical */ false);
944
+ }
945
+ });
946
+
947
+ // ===== Format dropdown =====
948
+ on(els.formatToggle, 'click', (e: MouseEvent) => {
949
+ e.stopPropagation();
950
+ const opening = els.formatMenu.hidden;
951
+ closeSubMenus();
952
+ if (opening) {
953
+ els.formatMenu.hidden = false;
954
+ placeFlyout(els.formatMenu, 'left');
955
+ }
956
+ });
957
+
958
+ on(els.formatMenu, 'click', (e: Event) => {
959
+ e.stopPropagation();
960
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.format-opt');
631
961
  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();
962
+ const value = btn.getAttribute('data-value') as 'svg' | 'png' | null;
963
+ if (!value) return;
964
+ state.export.format = value;
965
+ refreshFormatToggle();
966
+ els.formatMenu.hidden = true;
647
967
  });
648
968
 
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
- });
969
+ function refreshFormatToggle(): void {
970
+ els.formatToggle.textContent = `${state.export.format.toUpperCase()} \u25be`;
971
+ for (const btn of Array.from(
972
+ els.formatMenu.querySelectorAll<HTMLButtonElement>('.format-opt'),
973
+ )) {
974
+ btn.setAttribute(
975
+ 'data-active',
976
+ (btn.getAttribute('data-value') === state.export.format).toString(),
977
+ );
978
+ }
658
979
  }
659
980
 
981
+ // ===== Copy / export actions =====
982
+ on(els.copyAction, 'click', () => {
983
+ closeAllMenus();
984
+ void handleExportAction(`copy-${state.export.format}`);
985
+ });
986
+
987
+ on(els.exportAction, 'click', () => {
988
+ closeAllMenus();
989
+ void handleExportAction(`save-${state.export.format}`);
990
+ });
991
+
660
992
  async function handleExportAction(action: string): Promise<void> {
661
993
  if (!state.svgString) return;
662
994
  try {
@@ -735,14 +1067,299 @@ export function mountPreview(
735
1067
  });
736
1068
  }
737
1069
 
738
- // ===== Apply initial baselines =====
1070
+ // ===== Theme dropdown =====
1071
+ function buildThemeMenu(themes: string[]): void {
1072
+ els.themeMenu.innerHTML = '';
1073
+
1074
+ const autoLi = doc.createElement('li');
1075
+ const autoBtn = doc.createElement('button');
1076
+ autoBtn.className = 'btn theme-opt';
1077
+ autoBtn.setAttribute('data-value', 'auto');
1078
+ autoBtn.textContent = 'Auto';
1079
+ autoLi.appendChild(autoBtn);
1080
+ els.themeMenu.appendChild(autoLi);
1081
+
1082
+ for (const theme of themes) {
1083
+ const li = doc.createElement('li');
1084
+ const btn = doc.createElement('button');
1085
+ btn.className = 'btn theme-opt';
1086
+ btn.setAttribute('data-value', theme);
1087
+ const chip = doc.createElement('code');
1088
+ chip.className = 'code-chip';
1089
+ chip.textContent = theme;
1090
+ btn.appendChild(chip);
1091
+ li.appendChild(btn);
1092
+ els.themeMenu.appendChild(li);
1093
+ }
1094
+
1095
+ refreshThemeToggle();
1096
+ }
1097
+
1098
+ on(els.themeToggle, 'click', (e: MouseEvent) => {
1099
+ e.stopPropagation();
1100
+ const opening = els.themeMenu.hidden;
1101
+ closeSubMenus();
1102
+ if (opening) {
1103
+ els.themeMenu.hidden = false;
1104
+ placeFlyout(els.themeMenu, 'left');
1105
+ }
1106
+ });
1107
+
1108
+ on(els.themeMenu, 'click', (e: Event) => {
1109
+ e.stopPropagation();
1110
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.theme-opt');
1111
+ if (!btn) return;
1112
+ const value = btn.getAttribute('data-value');
1113
+ if (!value) return;
1114
+ state.view.theme = value as ThemeOverride;
1115
+ state.view.overridden.theme = true;
1116
+ refreshThemeToggle();
1117
+ els.themeMenu.hidden = true;
1118
+ postViewOverrides();
1119
+ });
1120
+
1121
+ function refreshThemeToggle(): void {
1122
+ if (state.view.theme === 'auto') {
1123
+ els.themeToggle.textContent = `Auto \u25be`;
1124
+ } else {
1125
+ els.themeToggle.innerHTML = '';
1126
+ const chip = doc.createElement('code');
1127
+ chip.className = 'code-chip';
1128
+ chip.textContent = state.view.theme;
1129
+ els.themeToggle.appendChild(chip);
1130
+ els.themeToggle.appendChild(doc.createTextNode(` \u25be`));
1131
+ }
1132
+ for (const btn of Array.from(
1133
+ els.themeMenu.querySelectorAll<HTMLButtonElement>('.theme-opt'),
1134
+ )) {
1135
+ btn.setAttribute(
1136
+ 'data-active',
1137
+ (btn.getAttribute('data-value') === state.view.theme).toString(),
1138
+ );
1139
+ }
1140
+ }
1141
+
1142
+ // ===== Now picker (calendar) =====
1143
+ let calendarYear = new Date().getFullYear();
1144
+ let calendarMonth = new Date().getMonth();
1145
+
1146
+ function buildCalendar(): void {
1147
+ const picker = els.nowPicker;
1148
+ picker.innerHTML = '';
1149
+ const year = calendarYear;
1150
+ const month = calendarMonth;
1151
+
1152
+ const nav = doc.createElement('div');
1153
+ nav.className = 'cal-nav';
1154
+
1155
+ const prevBtn = doc.createElement('button');
1156
+ prevBtn.className = 'btn cal-nav-btn';
1157
+ prevBtn.textContent = '\u2039';
1158
+ prevBtn.addEventListener('click', (e) => {
1159
+ e.stopPropagation();
1160
+ calendarMonth--;
1161
+ if (calendarMonth < 0) {
1162
+ calendarMonth = 11;
1163
+ calendarYear--;
1164
+ }
1165
+ buildCalendar();
1166
+ });
1167
+
1168
+ const heading = doc.createElement('span');
1169
+ heading.className = 'cal-heading';
1170
+ heading.textContent = new Intl.DateTimeFormat(state.locale, {
1171
+ month: 'long',
1172
+ year: 'numeric',
1173
+ }).format(new Date(year, month, 1));
1174
+
1175
+ const nextBtn = doc.createElement('button');
1176
+ nextBtn.className = 'btn cal-nav-btn';
1177
+ nextBtn.textContent = '\u203a';
1178
+ nextBtn.addEventListener('click', (e) => {
1179
+ e.stopPropagation();
1180
+ calendarMonth++;
1181
+ if (calendarMonth > 11) {
1182
+ calendarMonth = 0;
1183
+ calendarYear++;
1184
+ }
1185
+ buildCalendar();
1186
+ });
1187
+
1188
+ nav.appendChild(prevBtn);
1189
+ nav.appendChild(heading);
1190
+ nav.appendChild(nextBtn);
1191
+ picker.appendChild(nav);
1192
+
1193
+ const grid = doc.createElement('div');
1194
+ grid.className = 'cal-grid';
1195
+
1196
+ for (const d of ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']) {
1197
+ const cell = doc.createElement('span');
1198
+ cell.className = 'cal-dow';
1199
+ cell.textContent = d;
1200
+ grid.appendChild(cell);
1201
+ }
1202
+
1203
+ const firstDow = new Date(year, month, 1).getDay();
1204
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
1205
+
1206
+ for (let i = 0; i < firstDow; i++) {
1207
+ const empty = doc.createElement('span');
1208
+ empty.className = 'cal-empty';
1209
+ grid.appendChild(empty);
1210
+ }
1211
+
1212
+ const now = new Date();
1213
+ const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
1214
+
1215
+ for (let d = 1; d <= daysInMonth; d++) {
1216
+ const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
1217
+ const btn = doc.createElement('button');
1218
+ btn.className = 'btn cal-day';
1219
+ btn.textContent = String(d);
1220
+ if (dateStr === todayStr) btn.classList.add('is-today');
1221
+ if (state.view.now === dateStr) btn.classList.add('is-selected');
1222
+ btn.addEventListener('click', (e) => {
1223
+ e.stopPropagation();
1224
+ applyNow(dateStr);
1225
+ });
1226
+ grid.appendChild(btn);
1227
+ }
1228
+
1229
+ picker.appendChild(grid);
1230
+
1231
+ const footer = doc.createElement('div');
1232
+ footer.className = 'cal-footer';
1233
+
1234
+ const todayBtnEl = doc.createElement('button');
1235
+ todayBtnEl.className = 'btn cal-footer-btn';
1236
+ todayBtnEl.textContent = 'Today';
1237
+ todayBtnEl.addEventListener('click', (e) => {
1238
+ e.stopPropagation();
1239
+ applyNow('today');
1240
+ });
1241
+
1242
+ const noneBtnEl = doc.createElement('button');
1243
+ noneBtnEl.className = 'btn cal-footer-btn';
1244
+ noneBtnEl.textContent = 'None';
1245
+ noneBtnEl.addEventListener('click', (e) => {
1246
+ e.stopPropagation();
1247
+ applyNow('hide');
1248
+ });
1249
+
1250
+ footer.appendChild(todayBtnEl);
1251
+ footer.appendChild(noneBtnEl);
1252
+ picker.appendChild(footer);
1253
+ }
1254
+
1255
+ function applyNow(value: NowOverride): void {
1256
+ state.view.now = value;
1257
+ state.view.overridden.now = true;
1258
+ refreshNowToggle();
1259
+ els.nowPicker.hidden = true;
1260
+ postViewOverrides();
1261
+ }
1262
+
1263
+ on(els.nowToggle, 'click', (e: MouseEvent) => {
1264
+ e.stopPropagation();
1265
+ const opening = els.nowPicker.hidden;
1266
+ closeSubMenus();
1267
+ if (opening) {
1268
+ buildCalendar();
1269
+ els.nowPicker.hidden = false;
1270
+ placeFlyout(els.nowPicker, 'left');
1271
+ }
1272
+ });
1273
+
1274
+ function refreshNowToggle(): void {
1275
+ let label: string;
1276
+ if (state.view.now === 'today') {
1277
+ label = 'Today';
1278
+ } else if (state.view.now === 'hide') {
1279
+ label = 'None';
1280
+ } else {
1281
+ try {
1282
+ label = new Intl.DateTimeFormat(state.locale, { dateStyle: 'medium' }).format(
1283
+ new Date(`${state.view.now}T00:00:00`),
1284
+ );
1285
+ } catch {
1286
+ label = state.view.now;
1287
+ }
1288
+ }
1289
+ els.nowLabel.textContent = label;
1290
+ }
1291
+
1292
+ // ===== Show-links dropdown =====
1293
+ on(els.linksToggle, 'click', (e: MouseEvent) => {
1294
+ e.stopPropagation();
1295
+ const opening = els.linksMenu.hidden;
1296
+ closeSubMenus();
1297
+ if (opening) {
1298
+ els.linksMenu.hidden = false;
1299
+ placeFlyout(els.linksMenu, 'left');
1300
+ }
1301
+ });
1302
+
1303
+ on(els.linksMenu, 'click', (e: Event) => {
1304
+ e.stopPropagation();
1305
+ const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.links-opt');
1306
+ if (!btn) return;
1307
+ const value = btn.getAttribute('data-value');
1308
+ if (value === null) return;
1309
+ state.view.showLinks = value === 'true';
1310
+ state.view.overridden.showLinks = true;
1311
+ refreshLinksToggle();
1312
+ els.linksMenu.hidden = true;
1313
+ postViewOverrides();
1314
+ });
1315
+
1316
+ function refreshLinksToggle(): void {
1317
+ els.linksToggle.textContent = `${state.view.showLinks ? 'Yes' : 'No'} \u25be`;
1318
+ for (const btn of Array.from(
1319
+ els.linksMenu.querySelectorAll<HTMLButtonElement>('.links-opt'),
1320
+ )) {
1321
+ btn.setAttribute(
1322
+ 'data-active',
1323
+ (
1324
+ btn.getAttribute('data-value') === (state.view.showLinks ? 'true' : 'false')
1325
+ ).toString(),
1326
+ );
1327
+ }
1328
+ }
1329
+
1330
+ // ===== View-options helpers =====
1331
+ function postViewOverrides(): void {
1332
+ const overrides: ViewOptionsOverrides = {};
1333
+ if (state.view.overridden.theme) overrides.theme = state.view.theme;
1334
+ if (state.view.overridden.now) overrides.now = state.view.now;
1335
+ if (state.view.overridden.showLinks) overrides.showLinks = state.view.showLinks;
1336
+ options.onViewOptions?.(overrides);
1337
+ }
1338
+
1339
+ function refreshAll(): void {
1340
+ refreshThemeToggle();
1341
+ refreshNowToggle();
1342
+ refreshLinksToggle();
1343
+ refreshFormatToggle();
1344
+ }
1345
+
1346
+ // ===== Theme control visibility =====
1347
+ if (options.themeControl === 'hide') {
1348
+ const themeRow = els.themeMenu.closest<HTMLElement>('.theme-control-row');
1349
+ if (themeRow) themeRow.style.display = 'none';
1350
+ }
1351
+
1352
+ // ===== Apply initial theme menu + baselines =====
1353
+ buildThemeMenu(options.availableThemes ?? ['light', 'dark', 'grayscale']);
1354
+
739
1355
  if (options.viewBaseline) {
740
1356
  applyBaseline(state, options.viewBaseline, true);
741
- refreshViewMenu();
742
- } else {
743
- refreshViewMenu();
744
1357
  }
1358
+ refreshAll();
745
1359
  updateMinimapVisibility();
1360
+ // Anchor the toolbar explicitly (default: upper-right) so resize
1361
+ // shifts it left rather than the CSS `right` anchor squishing it.
1362
+ repositionChrome();
746
1363
 
747
1364
  // ===== Imperative API =====
748
1365
  return {
@@ -751,7 +1368,7 @@ export function mountPreview(
751
1368
  setFatal: showFatal,
752
1369
  setViewBaseline(baseline, resetOverrides = false) {
753
1370
  applyBaseline(state, baseline, resetOverrides);
754
- refreshViewMenu();
1371
+ refreshAll();
755
1372
  },
756
1373
  setDefaultFit(fit) {
757
1374
  state.defaultFit = fit;
@@ -761,6 +1378,17 @@ export function mountPreview(
761
1378
  state.minimapDismissedThisSession = false;
762
1379
  updateMinimapVisibility();
763
1380
  },
1381
+ setMode(mode) {
1382
+ const resolved = mode === 'system' ? resolveSystemMode() : mode;
1383
+ applyMode(resolved);
1384
+ },
1385
+ setAvailableThemes(themes) {
1386
+ buildThemeMenu(themes);
1387
+ },
1388
+ setLocale(locale) {
1389
+ state.locale = locale;
1390
+ refreshNowToggle();
1391
+ },
764
1392
  fitPage,
765
1393
  fitWidth,
766
1394
  actualSize,
@@ -769,13 +1397,16 @@ export function mountPreview(
769
1397
  },
770
1398
  setZoom(scale) {
771
1399
  state.activeFit = 'manual';
1400
+ state.isDirty = true;
772
1401
  setScale(scale);
773
1402
  },
774
1403
  dispose() {
775
1404
  if (fadeTimer) clearTimeout(fadeTimer);
1405
+ if (resizeTimer) clearTimeout(resizeTimer);
776
1406
  for (const c of cleanups) c();
777
1407
  cleanups.length = 0;
778
1408
  rootEl.classList.remove('nl-preview-root');
1409
+ rootEl.removeAttribute('data-nl-mode');
779
1410
  rootEl.innerHTML = '';
780
1411
  },
781
1412
  };
@@ -790,10 +1421,13 @@ function applyBaseline(
790
1421
  state.view.theme = baseline.theme;
791
1422
  }
792
1423
  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';
1424
+ if (baseline.now === 'none' || baseline.now === 'hide') {
1425
+ state.view.now = 'hide';
1426
+ } else if (typeof baseline.now === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(baseline.now)) {
1427
+ state.view.now = baseline.now;
1428
+ } else {
1429
+ state.view.now = 'today';
1430
+ }
797
1431
  }
798
1432
  if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
799
1433
  state.view.showLinks = baseline.showLinks !== false;