@nowline/preview-shell 0.0.0-dev.20260601071750.g04bdff9

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 ADDED
@@ -0,0 +1,1448 @@
1
+ // Framework-agnostic preview viewport mounted into a host element.
2
+ // Lifted from `packages/vscode-extension/src/preview/shell-html.ts`'s
3
+ // inline SCRIPT body and translated from a VS Code-coupled IIFE into
4
+ // a TypeScript module with an imperative API.
5
+ //
6
+ // The mount supplies its own DOM, CSS class scoping, zoom/pan logic,
7
+ // minimap, and diagnostic table. Consumers feed SVG strings + diagnostic
8
+ // rows in via the returned handle and receive user interactions (jump
9
+ // to source, save, copy, view-options changes) via callback options —
10
+ // no `acquireVsCodeApi()`, no `postMessage`, no host coupling.
11
+
12
+ import type { DiagnosticRow } from '@nowline/browser';
13
+ import { buildViewport } from './markup.js';
14
+ import { PREVIEW_SHELL_CSS } from './styles.js';
15
+
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
+
29
+ export type InitialFit = 'fitPage' | 'fitWidth' | 'actual';
30
+
31
+ /**
32
+ * Locator info posted when the user clicks a diagnostic row. `file`
33
+ * carries the absolute fs path from the originating DiagnosticRow;
34
+ * `line` and `column` are 1-based to match VS Code's Problems panel.
35
+ */
36
+ export interface DiagnosticGoto {
37
+ file: string;
38
+ line: number;
39
+ column: number;
40
+ }
41
+
42
+ /**
43
+ * Save / copy request payload. The shell rasterizes PNG via
44
+ * `<canvas>`; consumers receive the bytes and decide where they go.
45
+ */
46
+ export interface ExportRequest {
47
+ format: 'svg' | 'png';
48
+ body: string | Uint8Array;
49
+ }
50
+
51
+ /**
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.
55
+ */
56
+ export interface ViewOptionsOverrides {
57
+ theme?: ThemeOverride;
58
+ now?: NowOverride;
59
+ showLinks?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Baseline view state pushed by the consumer (e.g. when settings
64
+ * change). The shell uses these to pre-fill menu checkmarks without
65
+ * claiming the option as user-overridden.
66
+ */
67
+ export interface ViewBaseline {
68
+ theme?: ThemeOverride;
69
+ /** `'today'` | `'hide'` | `'YYYY-MM-DD'` | legacy `'auto'` / `'none'` values are accepted. */
70
+ now?: NowOverride | 'auto' | 'none' | string;
71
+ showLinks?: boolean;
72
+ }
73
+
74
+ export interface MountPreviewOptions {
75
+ initialFit?: InitialFit;
76
+ showMinimap?: boolean;
77
+ /**
78
+ * Initial view-menu baseline. Consumers can omit and push later
79
+ * with `handle.setViewBaseline(...)`.
80
+ */
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;
104
+ onGoto?: (loc: DiagnosticGoto) => void;
105
+ onOpenProblems?: () => void;
106
+ onSave?: (req: ExportRequest) => void;
107
+ onCopy?: (req: ExportRequest) => void;
108
+ /**
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.
112
+ */
113
+ onCopyPngFallback?: (body: Uint8Array) => void;
114
+ onViewOptions?: (overrides: ViewOptionsOverrides) => void;
115
+ onFatal?: (message: string) => void;
116
+ }
117
+
118
+ export interface PreviewHandle {
119
+ /** Replace the diagram with new SVG markup. */
120
+ setSvg(svg: string): void;
121
+ /** Switch to the diagnostics overlay; dims any prior SVG. */
122
+ setDiagnostics(rows: DiagnosticRow[]): void;
123
+ /** Show a one-row diagnostics overlay with the supplied message. */
124
+ setFatal(message: string): void;
125
+ /** Re-skin view-menu baselines without claiming user overrides. */
126
+ setViewBaseline(baseline: ViewBaseline, resetOverrides?: boolean): void;
127
+ /** Update the default fit used by the next first-render. */
128
+ setDefaultFit(fit: InitialFit): void;
129
+ /** Toggle minimap behaviour from the host. */
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;
145
+ fitPage(): void;
146
+ fitWidth(): void;
147
+ actualSize(): void;
148
+ getZoom(): number;
149
+ setZoom(scale: number): void;
150
+ /** Remove all listeners + DOM and detach the shell from the root element. */
151
+ dispose(): void;
152
+ }
153
+
154
+ interface InternalState {
155
+ svgString: string | null;
156
+ scale: number;
157
+ naturalWidth: number;
158
+ naturalHeight: number;
159
+ defaultFit: InitialFit;
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;
168
+ showMinimap: boolean;
169
+ minimapDismissedThisSession: boolean;
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' };
175
+ view: {
176
+ theme: ThemeOverride;
177
+ now: NowOverride;
178
+ showLinks: boolean;
179
+ overridden: { theme: boolean; now: boolean; showLinks: boolean };
180
+ };
181
+ }
182
+
183
+ let stylesheetMounted = false;
184
+
185
+ /**
186
+ * Last chrome position saved after a drag, shared across all shells in
187
+ * this JS session. Persists for the lifetime of the page.
188
+ */
189
+ let savedChromePosition: { left: number; top: number } | null = null;
190
+
191
+ function ensureStylesheet(doc: Document): void {
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
+ }
203
+ const style = doc.createElement('style');
204
+ style.setAttribute('data-nl-preview-shell', '');
205
+ style.textContent = PREVIEW_SHELL_CSS;
206
+ doc.head?.appendChild(style);
207
+ stylesheetMounted = true;
208
+ }
209
+
210
+ export function mountPreview(
211
+ rootEl: HTMLElement,
212
+ options: MountPreviewOptions = {},
213
+ ): PreviewHandle {
214
+ ensureStylesheet(rootEl.ownerDocument);
215
+
216
+ const els = buildViewport(rootEl);
217
+ const doc = rootEl.ownerDocument;
218
+ const win = doc.defaultView ?? globalThis;
219
+
220
+ const state: InternalState = {
221
+ svgString: null,
222
+ scale: 1,
223
+ naturalWidth: 0,
224
+ naturalHeight: 0,
225
+ defaultFit: options.initialFit ?? 'fitPage',
226
+ activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
227
+ isDirty: false,
228
+ lastViewportWidth: 0,
229
+ lastViewportHeight: 0,
230
+ showMinimap: options.showMinimap !== false,
231
+ minimapDismissedThisSession: false,
232
+ firstRender: true,
233
+ mode: 'dark',
234
+ locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
235
+ export: { format: 'svg' },
236
+ view: {
237
+ theme: 'auto',
238
+ now: 'today',
239
+ showLinks: true,
240
+ overridden: { theme: false, now: false, showLinks: false },
241
+ },
242
+ };
243
+
244
+ const cleanups: Array<() => void> = [];
245
+ function on<T extends Event>(
246
+ target: EventTarget,
247
+ type: string,
248
+ handler: (e: T) => void,
249
+ opts?: AddEventListenerOptions | boolean,
250
+ ): void {
251
+ target.addEventListener(type, handler as EventListener, opts);
252
+ cleanups.push(() => target.removeEventListener(type, handler as EventListener, opts));
253
+ }
254
+
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 =====
313
+ let fadeTimer: ReturnType<typeof setTimeout> | null = null;
314
+
315
+ function showFloatingUI(): void {
316
+ els.chrome.classList.remove('faded');
317
+ els.minimap.classList.remove('faded');
318
+ if (fadeTimer) clearTimeout(fadeTimer);
319
+ fadeTimer = setTimeout(() => {
320
+ els.chrome.classList.add('faded');
321
+ els.minimap.classList.add('faded');
322
+ }, 2000);
323
+ }
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
+ });
434
+
435
+ // ===== Transform =====
436
+ function applyTransform(): void {
437
+ const w = Math.max(1, Math.round(state.naturalWidth * state.scale));
438
+ const h = Math.max(1, Math.round(state.naturalHeight * state.scale));
439
+ els.canvas.style.width = `${w}px`;
440
+ els.canvas.style.height = `${h}px`;
441
+ els.zoomReset.textContent = `${Math.round(state.scale * 100)}%`;
442
+ updateMinimapRect();
443
+ updateMinimapVisibility();
444
+ }
445
+
446
+ function clampScale(s: number): number {
447
+ if (s < 0.05) return 0.05;
448
+ if (s > 20) return 20;
449
+ return s;
450
+ }
451
+
452
+ function setScale(newScale: number, anchorX?: number, anchorY?: number): void {
453
+ const clamped = clampScale(newScale);
454
+ if (anchorX === undefined || anchorY === undefined || state.scale === 0) {
455
+ state.scale = clamped;
456
+ applyTransform();
457
+ return;
458
+ }
459
+ const ratio = clamped / state.scale;
460
+ const newScrollX = (els.viewport.scrollLeft + anchorX) * ratio - anchorX;
461
+ const newScrollY = (els.viewport.scrollTop + anchorY) * ratio - anchorY;
462
+ state.scale = clamped;
463
+ applyTransform();
464
+ els.viewport.scrollLeft = newScrollX;
465
+ els.viewport.scrollTop = newScrollY;
466
+ }
467
+
468
+ function fitPage(): void {
469
+ if (!state.naturalWidth || !state.naturalHeight) return;
470
+ state.activeFit = 'fitPage';
471
+ state.isDirty = false;
472
+ const sx = els.viewport.clientWidth / state.naturalWidth;
473
+ const sy = els.viewport.clientHeight / state.naturalHeight;
474
+ setScale(Math.min(sx, sy));
475
+ els.viewport.scrollLeft = 0;
476
+ els.viewport.scrollTop = 0;
477
+ }
478
+
479
+ function fitWidth(): void {
480
+ if (!state.naturalWidth) return;
481
+ state.activeFit = 'fitWidth';
482
+ state.isDirty = false;
483
+ setScale(els.viewport.clientWidth / state.naturalWidth);
484
+ els.viewport.scrollLeft = 0;
485
+ els.viewport.scrollTop = 0;
486
+ }
487
+
488
+ function actualSize(): void {
489
+ state.activeFit = 'manual';
490
+ state.isDirty = true;
491
+ setScale(1);
492
+ }
493
+
494
+ function applyDefaultFit(): void {
495
+ if (state.defaultFit === 'fitWidth') fitWidth();
496
+ else if (state.defaultFit === 'actual') actualSize();
497
+ else fitPage();
498
+ }
499
+
500
+ function reapplyActiveFit(): void {
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;
521
+ }
522
+
523
+ // ===== Render handling =====
524
+ function extractNaturalSize(svgEl: SVGSVGElement): void {
525
+ let w = 0;
526
+ let h = 0;
527
+ if (svgEl.viewBox?.baseVal?.width) {
528
+ w = svgEl.viewBox.baseVal.width;
529
+ h = svgEl.viewBox.baseVal.height;
530
+ }
531
+ if (!w && svgEl.width?.baseVal?.value) {
532
+ w = svgEl.width.baseVal.value;
533
+ }
534
+ if (!h && svgEl.height?.baseVal?.value) {
535
+ h = svgEl.height.baseVal.value;
536
+ }
537
+ svgEl.removeAttribute('width');
538
+ svgEl.removeAttribute('height');
539
+ if (!svgEl.getAttribute('viewBox') && w && h) {
540
+ svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
541
+ }
542
+ svgEl.style.width = '100%';
543
+ svgEl.style.height = '100%';
544
+ state.naturalWidth = w || 800;
545
+ state.naturalHeight = h || 600;
546
+ }
547
+
548
+ function setSvg(svgString: string): void {
549
+ state.svgString = svgString;
550
+ els.canvas.innerHTML = svgString;
551
+ const svgEl = els.canvas.querySelector('svg') as SVGSVGElement | null;
552
+ if (!svgEl) return;
553
+ extractNaturalSize(svgEl);
554
+ rebuildMinimap(svgString);
555
+ if (state.firstRender) {
556
+ applyDefaultFit();
557
+ state.firstRender = false;
558
+ } else {
559
+ applyTransform();
560
+ }
561
+ showSvgMode();
562
+ }
563
+
564
+ function showSvgMode(): void {
565
+ els.empty.classList.add('hidden');
566
+ els.canvas.classList.remove('dimmed');
567
+ els.diagnostics.classList.remove('show');
568
+ }
569
+
570
+ function showDiagnosticsMode(rows: DiagnosticRow[]): void {
571
+ els.empty.classList.add('hidden');
572
+ if (state.svgString) {
573
+ els.canvas.classList.add('dimmed');
574
+ }
575
+ populateDiagnostics(rows);
576
+ els.diagnostics.classList.add('show');
577
+ }
578
+
579
+ function showFatal(message: string): void {
580
+ const fakeRow: DiagnosticRow = {
581
+ severity: 'error',
582
+ code: 'preview-error',
583
+ message,
584
+ file: '',
585
+ line: 1,
586
+ column: 1,
587
+ };
588
+ showDiagnosticsMode([fakeRow]);
589
+ }
590
+
591
+ // ===== Diagnostics table =====
592
+ function populateDiagnostics(rows: DiagnosticRow[]): void {
593
+ let errors = 0;
594
+ let warnings = 0;
595
+ for (const r of rows) {
596
+ if (r.severity === 'warning') warnings++;
597
+ else errors++;
598
+ }
599
+ let summary = '';
600
+ if (errors) summary += `${errors}${errors === 1 ? ' error' : ' errors'}`;
601
+ if (errors && warnings) summary += ', ';
602
+ if (warnings) summary += `${warnings}${warnings === 1 ? ' warning' : ' warnings'}`;
603
+ if (!summary) summary = 'No problems';
604
+ els.diagSummary.textContent = summary;
605
+ els.diagSummary.className = 'diag-summary';
606
+ if (!errors && warnings) els.diagSummary.classList.add('warn-only');
607
+ if (!errors && !warnings) els.diagSummary.classList.add('clean');
608
+
609
+ // Hide Open Problems link when there are no issues
610
+ els.openProblems.hidden = errors + warnings === 0;
611
+
612
+ const tbody = els.diagTbody;
613
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
614
+
615
+ for (const r of rows) {
616
+ const tr = doc.createElement('tr');
617
+ tr.dataset.file = r.file || '';
618
+ tr.dataset.line = String(r.line);
619
+ tr.dataset.column = String(r.column);
620
+
621
+ const sevTd = doc.createElement('td');
622
+ sevTd.className = `sev-cell ${r.severity === 'warning' ? 'sev-warning' : 'sev-error'}`;
623
+ sevTd.textContent = r.severity === 'warning' ? '\u26A0' : '!';
624
+ tr.appendChild(sevTd);
625
+
626
+ const locTd = doc.createElement('td');
627
+ locTd.className = 'loc-cell';
628
+ locTd.textContent = `Ln ${r.line}, ${r.column}`;
629
+ tr.appendChild(locTd);
630
+
631
+ const codeTd = doc.createElement('td');
632
+ codeTd.className = 'code-cell';
633
+ const pill = doc.createElement('span');
634
+ pill.className = 'code-pill';
635
+ pill.textContent = r.code;
636
+ codeTd.appendChild(pill);
637
+ tr.appendChild(codeTd);
638
+
639
+ const msgTd = doc.createElement('td');
640
+ msgTd.className = 'msg-cell';
641
+ msgTd.textContent = r.message;
642
+ if (r.suggestion) {
643
+ const sg = doc.createElement('span');
644
+ sg.className = 'suggestion';
645
+ sg.textContent = `Did you mean '${r.suggestion}'?`;
646
+ msgTd.appendChild(sg);
647
+ }
648
+ tr.appendChild(msgTd);
649
+ tbody.appendChild(tr);
650
+ }
651
+ }
652
+
653
+ on(els.diagTbody, 'click', (e: Event) => {
654
+ const target = e.target as HTMLElement | null;
655
+ const tr = target?.closest('tr') as HTMLTableRowElement | null;
656
+ if (!tr) return;
657
+ options.onGoto?.({
658
+ file: tr.dataset.file ?? '',
659
+ line: Number.parseInt(tr.dataset.line ?? '1', 10) || 1,
660
+ column: Number.parseInt(tr.dataset.column ?? '1', 10) || 1,
661
+ });
662
+ });
663
+
664
+ on(els.openProblems, 'click', (e: Event) => {
665
+ e.preventDefault();
666
+ options.onOpenProblems?.();
667
+ });
668
+
669
+ // ===== Minimap =====
670
+ function rebuildMinimap(svgString: string): void {
671
+ if (!els.minimapCanvas) return;
672
+ els.minimapCanvas.innerHTML = svgString;
673
+ const svgEl = els.minimapCanvas.querySelector('svg');
674
+ if (svgEl) {
675
+ svgEl.removeAttribute('width');
676
+ svgEl.removeAttribute('height');
677
+ if (state.naturalWidth && state.naturalHeight && !svgEl.getAttribute('viewBox')) {
678
+ svgEl.setAttribute('viewBox', `0 0 ${state.naturalWidth} ${state.naturalHeight}`);
679
+ }
680
+ svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
681
+ }
682
+ const maxW = 120;
683
+ const maxH = 90;
684
+ if (state.naturalWidth && state.naturalHeight) {
685
+ const ratio = state.naturalWidth / state.naturalHeight;
686
+ let w = maxW;
687
+ let h = maxW / ratio;
688
+ if (h > maxH) {
689
+ h = maxH;
690
+ w = maxH * ratio;
691
+ }
692
+ els.minimap.style.width = `${Math.round(w)}px`;
693
+ (els.minimapCanvas as HTMLElement).style.height = `${Math.round(h)}px`;
694
+ }
695
+ }
696
+
697
+ function updateMinimapRect(): void {
698
+ if (els.minimap.classList.contains('hidden')) return;
699
+ const totalW = state.naturalWidth * state.scale;
700
+ const totalH = state.naturalHeight * state.scale;
701
+ if (!totalW || !totalH) return;
702
+ const miniW = els.minimapCanvas.clientWidth;
703
+ const miniH = els.minimapCanvas.clientHeight;
704
+ const rectW = Math.min(1, els.viewport.clientWidth / totalW) * miniW;
705
+ const rectH = Math.min(1, els.viewport.clientHeight / totalH) * miniH;
706
+ const rectX = (els.viewport.scrollLeft / totalW) * miniW;
707
+ const rectY = (els.viewport.scrollTop / totalH) * miniH;
708
+ els.minimapRect.style.left = `${els.minimapCanvas.offsetLeft + rectX}px`;
709
+ els.minimapRect.style.top = `${els.minimapCanvas.offsetTop + rectY}px`;
710
+ els.minimapRect.style.width = `${rectW}px`;
711
+ els.minimapRect.style.height = `${rectH}px`;
712
+ }
713
+
714
+ function updateMinimapVisibility(): void {
715
+ if (!state.showMinimap || state.minimapDismissedThisSession) {
716
+ els.minimap.classList.add('hidden');
717
+ return;
718
+ }
719
+ const totalW = state.naturalWidth * state.scale;
720
+ const totalH = state.naturalHeight * state.scale;
721
+ const fits =
722
+ totalW <= els.viewport.clientWidth + 1 && totalH <= els.viewport.clientHeight + 1;
723
+ if (fits) {
724
+ els.minimap.classList.add('hidden');
725
+ } else {
726
+ els.minimap.classList.remove('hidden');
727
+ }
728
+ }
729
+
730
+ on(els.minimapClose, 'click', (e: Event) => {
731
+ e.stopPropagation();
732
+ state.minimapDismissedThisSession = true;
733
+ els.minimap.classList.add('hidden');
734
+ });
735
+
736
+ function panToMinimapPoint(clientX: number, clientY: number): void {
737
+ const rect = els.minimapCanvas.getBoundingClientRect();
738
+ const x = (clientX - rect.left) / rect.width;
739
+ const y = (clientY - rect.top) / rect.height;
740
+ const totalW = state.naturalWidth * state.scale;
741
+ const totalH = state.naturalHeight * state.scale;
742
+ els.viewport.scrollLeft = Math.max(0, x * totalW - els.viewport.clientWidth / 2);
743
+ els.viewport.scrollTop = Math.max(0, y * totalH - els.viewport.clientHeight / 2);
744
+ }
745
+
746
+ let miniDragging = false;
747
+ on(els.minimapCanvas, 'mousedown', (e: MouseEvent) => {
748
+ miniDragging = true;
749
+ panToMinimapPoint(e.clientX, e.clientY);
750
+ e.preventDefault();
751
+ });
752
+ on(doc, 'mousemove', (e: MouseEvent) => {
753
+ if (miniDragging) panToMinimapPoint(e.clientX, e.clientY);
754
+ });
755
+ on(doc, 'mouseup', () => {
756
+ miniDragging = false;
757
+ });
758
+
759
+ on(els.viewport, 'scroll', updateMinimapRect);
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
+ }
780
+
781
+ // ===== Wheel zoom =====
782
+ on(
783
+ els.viewport,
784
+ 'wheel',
785
+ (e: WheelEvent) => {
786
+ if (!(e.ctrlKey || e.metaKey)) return;
787
+ e.preventDefault();
788
+ state.activeFit = 'manual';
789
+ state.isDirty = true;
790
+ const rect = els.viewport.getBoundingClientRect();
791
+ const anchorX = e.clientX - rect.left;
792
+ const anchorY = e.clientY - rect.top;
793
+ const factor = Math.exp(-e.deltaY * 0.01);
794
+ setScale(state.scale * factor, anchorX, anchorY);
795
+ },
796
+ { passive: false },
797
+ );
798
+
799
+ // ===== Spacebar pan + keyboard fit presets =====
800
+ let spaceDown = false;
801
+ let dragOrigin: { x: number; y: number; sx: number; sy: number } | null = null;
802
+ function isInputFocus(target: EventTarget | null): boolean {
803
+ if (!target) return false;
804
+ const el = target as HTMLElement;
805
+ if (!el.tagName) return false;
806
+ const t = el.tagName.toUpperCase();
807
+ return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
808
+ }
809
+ on(doc, 'keydown', (e: KeyboardEvent) => {
810
+ if (isInputFocus(e.target)) return;
811
+ if (e.key === ' ' && !spaceDown) {
812
+ spaceDown = true;
813
+ els.viewport.style.cursor = 'grab';
814
+ e.preventDefault();
815
+ } else if (e.key === '1') {
816
+ fitPage();
817
+ } else if (e.key === '2') {
818
+ actualSize();
819
+ } else if (e.key === '3') {
820
+ fitWidth();
821
+ } else if (e.key === '0') {
822
+ fitPage();
823
+ }
824
+ });
825
+ on(doc, 'keyup', (e: KeyboardEvent) => {
826
+ if (e.key === ' ') {
827
+ spaceDown = false;
828
+ els.viewport.style.cursor = '';
829
+ }
830
+ });
831
+ on(els.viewport, 'mousedown', (e: MouseEvent) => {
832
+ if (!spaceDown) return;
833
+ state.isDirty = true;
834
+ dragOrigin = {
835
+ x: e.clientX,
836
+ y: e.clientY,
837
+ sx: els.viewport.scrollLeft,
838
+ sy: els.viewport.scrollTop,
839
+ };
840
+ els.viewport.style.cursor = 'grabbing';
841
+ e.preventDefault();
842
+ });
843
+ on(doc, 'mousemove', (e: MouseEvent) => {
844
+ if (!dragOrigin) return;
845
+ els.viewport.scrollLeft = dragOrigin.sx - (e.clientX - dragOrigin.x);
846
+ els.viewport.scrollTop = dragOrigin.sy - (e.clientY - dragOrigin.y);
847
+ });
848
+ on(doc, 'mouseup', () => {
849
+ if (dragOrigin) {
850
+ dragOrigin = null;
851
+ els.viewport.style.cursor = spaceDown ? 'grab' : '';
852
+ }
853
+ });
854
+
855
+ // ===== Toolbar buttons =====
856
+ on(els.zoomOut, 'click', () => {
857
+ state.activeFit = 'manual';
858
+ state.isDirty = true;
859
+ setScale(state.scale / 1.1);
860
+ });
861
+ on(els.zoomIn, 'click', () => {
862
+ state.activeFit = 'manual';
863
+ state.isDirty = true;
864
+ setScale(state.scale * 1.1);
865
+ });
866
+ on(els.zoomReset, 'click', actualSize);
867
+ on(els.fitWidth, 'click', fitWidth);
868
+ on(els.fitPage, 'click', fitPage);
869
+
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;
876
+ }
877
+
878
+ function closeAllMenus(): void {
879
+ els.moreMenu.hidden = true;
880
+ closeSubMenus();
881
+ }
882
+
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');
915
+ }
916
+
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
+ }
931
+ }
932
+
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');
961
+ if (!btn) return;
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;
967
+ });
968
+
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
+ }
979
+ }
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
+
992
+ async function handleExportAction(action: string): Promise<void> {
993
+ if (!state.svgString) return;
994
+ try {
995
+ if (action === 'save-svg') {
996
+ options.onSave?.({ format: 'svg', body: state.svgString });
997
+ } else if (action === 'copy-svg') {
998
+ try {
999
+ await win.navigator.clipboard.writeText(state.svgString);
1000
+ } catch (err) {
1001
+ options.onFatal?.(`Copy SVG failed: ${(err as Error).message}`);
1002
+ }
1003
+ } else if (action === 'save-png') {
1004
+ const blob = await rasterizePng();
1005
+ const buf = await blob.arrayBuffer();
1006
+ options.onSave?.({ format: 'png', body: new Uint8Array(buf) });
1007
+ } else if (action === 'copy-png') {
1008
+ const blob = await rasterizePng();
1009
+ const ClipboardItemCtor = (win as typeof window).ClipboardItem as
1010
+ | typeof ClipboardItem
1011
+ | undefined;
1012
+ if (typeof ClipboardItemCtor === 'undefined' || !win.navigator.clipboard?.write) {
1013
+ const buf = await blob.arrayBuffer();
1014
+ options.onCopyPngFallback?.(new Uint8Array(buf));
1015
+ return;
1016
+ }
1017
+ try {
1018
+ await win.navigator.clipboard.write([
1019
+ new ClipboardItemCtor({ 'image/png': blob }),
1020
+ ]);
1021
+ } catch {
1022
+ const buf = await blob.arrayBuffer();
1023
+ options.onCopyPngFallback?.(new Uint8Array(buf));
1024
+ }
1025
+ }
1026
+ } catch (err) {
1027
+ options.onFatal?.(`PNG render failed: ${(err as Error).message}`);
1028
+ }
1029
+ }
1030
+
1031
+ function rasterizePng(): Promise<Blob> {
1032
+ return new Promise<Blob>((resolve, reject) => {
1033
+ if (!state.svgString) {
1034
+ reject(new Error('No diagram to rasterize.'));
1035
+ return;
1036
+ }
1037
+ const blob = new Blob([state.svgString], { type: 'image/svg+xml;charset=utf-8' });
1038
+ const url = (win as typeof window).URL.createObjectURL(blob);
1039
+ const img = new (win as typeof window).Image();
1040
+ img.onload = () => {
1041
+ try {
1042
+ const dpr = (win as typeof window).devicePixelRatio || 1;
1043
+ const w = state.naturalWidth || img.naturalWidth || 800;
1044
+ const h = state.naturalHeight || img.naturalHeight || 600;
1045
+ const canvas = doc.createElement('canvas');
1046
+ canvas.width = Math.max(1, Math.round(w * dpr));
1047
+ canvas.height = Math.max(1, Math.round(h * dpr));
1048
+ const ctx = canvas.getContext('2d');
1049
+ if (!ctx) throw new Error('2D canvas context unavailable.');
1050
+ ctx.scale(dpr, dpr);
1051
+ ctx.drawImage(img, 0, 0, w, h);
1052
+ (win as typeof window).URL.revokeObjectURL(url);
1053
+ canvas.toBlob((out) => {
1054
+ if (out) resolve(out);
1055
+ else reject(new Error('canvas.toBlob returned null.'));
1056
+ }, 'image/png');
1057
+ } catch (err) {
1058
+ (win as typeof window).URL.revokeObjectURL(url);
1059
+ reject(err);
1060
+ }
1061
+ };
1062
+ img.onerror = () => {
1063
+ (win as typeof window).URL.revokeObjectURL(url);
1064
+ reject(new Error('Failed to load SVG into <img> for rasterization.'));
1065
+ };
1066
+ img.src = url;
1067
+ });
1068
+ }
1069
+
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
+
1355
+ if (options.viewBaseline) {
1356
+ applyBaseline(state, options.viewBaseline, true);
1357
+ }
1358
+ refreshAll();
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();
1363
+
1364
+ // ===== Imperative API =====
1365
+ return {
1366
+ setSvg,
1367
+ setDiagnostics: showDiagnosticsMode,
1368
+ setFatal: showFatal,
1369
+ setViewBaseline(baseline, resetOverrides = false) {
1370
+ applyBaseline(state, baseline, resetOverrides);
1371
+ refreshAll();
1372
+ },
1373
+ setDefaultFit(fit) {
1374
+ state.defaultFit = fit;
1375
+ },
1376
+ setShowMinimap(show) {
1377
+ state.showMinimap = show;
1378
+ state.minimapDismissedThisSession = false;
1379
+ updateMinimapVisibility();
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
+ },
1392
+ fitPage,
1393
+ fitWidth,
1394
+ actualSize,
1395
+ getZoom() {
1396
+ return state.scale;
1397
+ },
1398
+ setZoom(scale) {
1399
+ state.activeFit = 'manual';
1400
+ state.isDirty = true;
1401
+ setScale(scale);
1402
+ },
1403
+ dispose() {
1404
+ if (fadeTimer) clearTimeout(fadeTimer);
1405
+ if (resizeTimer) clearTimeout(resizeTimer);
1406
+ for (const c of cleanups) c();
1407
+ cleanups.length = 0;
1408
+ rootEl.classList.remove('nl-preview-root');
1409
+ rootEl.removeAttribute('data-nl-mode');
1410
+ rootEl.innerHTML = '';
1411
+ },
1412
+ };
1413
+ }
1414
+
1415
+ function applyBaseline(
1416
+ state: InternalState,
1417
+ baseline: ViewBaseline,
1418
+ resetOverrides: boolean,
1419
+ ): void {
1420
+ if (baseline.theme && !state.view.overridden.theme) {
1421
+ state.view.theme = baseline.theme;
1422
+ }
1423
+ if (baseline.now !== undefined && !state.view.overridden.now) {
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
+ }
1431
+ }
1432
+ if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
1433
+ state.view.showLinks = baseline.showLinks !== false;
1434
+ }
1435
+ if (resetOverrides) {
1436
+ state.view.overridden = { theme: false, now: false, showLinks: false };
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * Test-only helper. Clears the module-level latch that prevents the
1442
+ * stylesheet from being mounted twice. Vitest reuses module instances
1443
+ * across tests, so without this every test after the first one would
1444
+ * skip writing the stylesheet into the test document.
1445
+ */
1446
+ export function __resetPreviewShellStylesheetForTests(): void {
1447
+ stylesheetMounted = false;
1448
+ }