@nowline/preview-shell 0.4.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 ADDED
@@ -0,0 +1,814 @@
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
+ export type ThemeOverride = 'auto' | 'light' | 'dark';
17
+ export type NowOverride = 'today' | 'hide';
18
+ export type InitialFit = 'fitPage' | 'fitWidth' | 'actual';
19
+
20
+ /**
21
+ * Locator info posted when the user clicks a diagnostic row. `file`
22
+ * carries the absolute fs path from the originating DiagnosticRow;
23
+ * `line` and `column` are 1-based to match VS Code's Problems panel.
24
+ */
25
+ export interface DiagnosticGoto {
26
+ file: string;
27
+ line: number;
28
+ column: number;
29
+ }
30
+
31
+ /**
32
+ * Save / copy request payload. The shell rasterizes PNG via
33
+ * `<canvas>`; consumers receive the bytes and decide where they go.
34
+ */
35
+ export interface ExportRequest {
36
+ format: 'svg' | 'png';
37
+ body: string | Uint8Array;
38
+ }
39
+
40
+ /**
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.).
44
+ */
45
+ export interface ViewOptionsOverrides {
46
+ theme?: ThemeOverride;
47
+ now?: NowOverride;
48
+ showLinks?: boolean;
49
+ }
50
+
51
+ /**
52
+ * 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.
55
+ */
56
+ export interface ViewBaseline {
57
+ theme?: ThemeOverride;
58
+ /** Maps to one of `today` | `hide` for the checkmark — host values like 'auto' / 'YYYY-MM-DD' collapse to 'today'. */
59
+ now?: NowOverride | 'auto' | 'none' | string;
60
+ showLinks?: boolean;
61
+ }
62
+
63
+ export interface MountPreviewOptions {
64
+ initialFit?: InitialFit;
65
+ showMinimap?: boolean;
66
+ /**
67
+ * Initial view-menu baseline. Consumers can omit and push later
68
+ * with `handle.setViewBaseline(...)`.
69
+ */
70
+ viewBaseline?: ViewBaseline;
71
+ onGoto?: (loc: DiagnosticGoto) => void;
72
+ onOpenProblems?: () => void;
73
+ onSave?: (req: ExportRequest) => void;
74
+ onCopy?: (req: ExportRequest) => void;
75
+ /**
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.
80
+ */
81
+ onCopyPngFallback?: (body: Uint8Array) => void;
82
+ onViewOptions?: (overrides: ViewOptionsOverrides) => void;
83
+ onFatal?: (message: string) => void;
84
+ }
85
+
86
+ export interface PreviewHandle {
87
+ /** Replace the diagram with new SVG markup. */
88
+ setSvg(svg: string): void;
89
+ /** Switch to the diagnostics overlay; dims any prior SVG. */
90
+ setDiagnostics(rows: DiagnosticRow[]): void;
91
+ /** Show a one-row diagnostics overlay with the supplied message. */
92
+ setFatal(message: string): void;
93
+ /** Re-skin the view-menu baselines without claiming user overrides. */
94
+ setViewBaseline(baseline: ViewBaseline, resetOverrides?: boolean): void;
95
+ /** Update the "default fit" used by the next first-render. */
96
+ setDefaultFit(fit: InitialFit): void;
97
+ /** Toggle the minimap behaviour from the host. */
98
+ setShowMinimap(show: boolean): void;
99
+ fitPage(): void;
100
+ fitWidth(): void;
101
+ actualSize(): void;
102
+ getZoom(): number;
103
+ setZoom(scale: number): void;
104
+ /** Remove all listeners + DOM and detach the shell from the root element. */
105
+ dispose(): void;
106
+ }
107
+
108
+ interface InternalState {
109
+ svgString: string | null;
110
+ scale: number;
111
+ naturalWidth: number;
112
+ naturalHeight: number;
113
+ defaultFit: InitialFit;
114
+ activeFit: 'fitPage' | 'fitWidth' | 'manual';
115
+ showMinimap: boolean;
116
+ minimapDismissedThisSession: boolean;
117
+ firstRender: boolean;
118
+ view: {
119
+ theme: ThemeOverride;
120
+ now: NowOverride;
121
+ showLinks: boolean;
122
+ overridden: { theme: boolean; now: boolean; showLinks: boolean };
123
+ };
124
+ }
125
+
126
+ let stylesheetMounted = false;
127
+
128
+ /**
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.
133
+ */
134
+ function ensureStylesheet(doc: Document): void {
135
+ if (stylesheetMounted) return;
136
+ const style = doc.createElement('style');
137
+ style.setAttribute('data-nl-preview-shell', '');
138
+ style.textContent = PREVIEW_SHELL_CSS;
139
+ doc.head?.appendChild(style);
140
+ stylesheetMounted = true;
141
+ }
142
+
143
+ export function mountPreview(
144
+ rootEl: HTMLElement,
145
+ options: MountPreviewOptions = {},
146
+ ): PreviewHandle {
147
+ ensureStylesheet(rootEl.ownerDocument);
148
+
149
+ const els = buildViewport(rootEl);
150
+ const doc = rootEl.ownerDocument;
151
+ const win = doc.defaultView ?? globalThis;
152
+
153
+ const state: InternalState = {
154
+ svgString: null,
155
+ scale: 1,
156
+ naturalWidth: 0,
157
+ naturalHeight: 0,
158
+ defaultFit: options.initialFit ?? 'fitPage',
159
+ activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
160
+ showMinimap: options.showMinimap !== false,
161
+ minimapDismissedThisSession: false,
162
+ firstRender: true,
163
+ view: {
164
+ theme: 'auto',
165
+ now: 'today',
166
+ showLinks: true,
167
+ overridden: { theme: false, now: false, showLinks: false },
168
+ },
169
+ };
170
+
171
+ const cleanups: Array<() => void> = [];
172
+ function on<T extends Event>(
173
+ target: EventTarget,
174
+ type: string,
175
+ handler: (e: T) => void,
176
+ opts?: AddEventListenerOptions | boolean,
177
+ ): void {
178
+ target.addEventListener(type, handler as EventListener, opts);
179
+ cleanups.push(() => target.removeEventListener(type, handler as EventListener, opts));
180
+ }
181
+
182
+ // ===== Toolbar fade =====
183
+ let fadeTimer: ReturnType<typeof setTimeout> | null = null;
184
+ function showToolbar(): void {
185
+ els.chrome.classList.remove('faded');
186
+ if (fadeTimer) clearTimeout(fadeTimer);
187
+ fadeTimer = setTimeout(() => els.chrome.classList.add('faded'), 2000);
188
+ }
189
+ on(doc, 'mousemove', showToolbar);
190
+ showToolbar();
191
+
192
+ // ===== Transform =====
193
+ function applyTransform(): void {
194
+ const w = Math.max(1, Math.round(state.naturalWidth * state.scale));
195
+ const h = Math.max(1, Math.round(state.naturalHeight * state.scale));
196
+ els.canvas.style.width = `${w}px`;
197
+ els.canvas.style.height = `${h}px`;
198
+ els.zoomReset.textContent = `${Math.round(state.scale * 100)}%`;
199
+ updateMinimapRect();
200
+ updateMinimapVisibility();
201
+ }
202
+
203
+ function clampScale(s: number): number {
204
+ if (s < 0.05) return 0.05;
205
+ if (s > 20) return 20;
206
+ return s;
207
+ }
208
+
209
+ function setScale(newScale: number, anchorX?: number, anchorY?: number): void {
210
+ const clamped = clampScale(newScale);
211
+ if (anchorX === undefined || anchorY === undefined || state.scale === 0) {
212
+ state.scale = clamped;
213
+ applyTransform();
214
+ return;
215
+ }
216
+ const ratio = clamped / state.scale;
217
+ const newScrollX = (els.viewport.scrollLeft + anchorX) * ratio - anchorX;
218
+ const newScrollY = (els.viewport.scrollTop + anchorY) * ratio - anchorY;
219
+ state.scale = clamped;
220
+ applyTransform();
221
+ els.viewport.scrollLeft = newScrollX;
222
+ els.viewport.scrollTop = newScrollY;
223
+ }
224
+
225
+ function fitPage(): void {
226
+ if (!state.naturalWidth || !state.naturalHeight) return;
227
+ state.activeFit = 'fitPage';
228
+ const sx = els.viewport.clientWidth / state.naturalWidth;
229
+ const sy = els.viewport.clientHeight / state.naturalHeight;
230
+ setScale(Math.min(sx, sy));
231
+ els.viewport.scrollLeft = 0;
232
+ els.viewport.scrollTop = 0;
233
+ }
234
+
235
+ function fitWidth(): void {
236
+ if (!state.naturalWidth) return;
237
+ state.activeFit = 'fitWidth';
238
+ setScale(els.viewport.clientWidth / state.naturalWidth);
239
+ els.viewport.scrollLeft = 0;
240
+ els.viewport.scrollTop = 0;
241
+ }
242
+
243
+ function actualSize(): void {
244
+ state.activeFit = 'manual';
245
+ setScale(1);
246
+ }
247
+
248
+ function applyDefaultFit(): void {
249
+ if (state.defaultFit === 'fitWidth') fitWidth();
250
+ else if (state.defaultFit === 'actual') actualSize();
251
+ else fitPage();
252
+ }
253
+
254
+ function reapplyActiveFit(): void {
255
+ if (state.activeFit === 'fitPage') fitPage();
256
+ else if (state.activeFit === 'fitWidth') fitWidth();
257
+ }
258
+
259
+ // ===== Render handling =====
260
+ function extractNaturalSize(svgEl: SVGSVGElement): void {
261
+ let w = 0;
262
+ let h = 0;
263
+ if (svgEl.viewBox?.baseVal?.width) {
264
+ w = svgEl.viewBox.baseVal.width;
265
+ h = svgEl.viewBox.baseVal.height;
266
+ }
267
+ if (!w && svgEl.width?.baseVal?.value) {
268
+ w = svgEl.width.baseVal.value;
269
+ }
270
+ if (!h && svgEl.height?.baseVal?.value) {
271
+ h = svgEl.height.baseVal.value;
272
+ }
273
+ svgEl.removeAttribute('width');
274
+ svgEl.removeAttribute('height');
275
+ if (!svgEl.getAttribute('viewBox') && w && h) {
276
+ svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
277
+ }
278
+ svgEl.style.width = '100%';
279
+ svgEl.style.height = '100%';
280
+ state.naturalWidth = w || 800;
281
+ state.naturalHeight = h || 600;
282
+ }
283
+
284
+ function setSvg(svgString: string): void {
285
+ state.svgString = svgString;
286
+ els.canvas.innerHTML = svgString;
287
+ const svgEl = els.canvas.querySelector('svg') as SVGSVGElement | null;
288
+ if (!svgEl) return;
289
+ extractNaturalSize(svgEl);
290
+ rebuildMinimap(svgString);
291
+ if (state.firstRender) {
292
+ applyDefaultFit();
293
+ state.firstRender = false;
294
+ } else {
295
+ applyTransform();
296
+ }
297
+ showSvgMode();
298
+ }
299
+
300
+ function showSvgMode(): void {
301
+ els.empty.classList.add('hidden');
302
+ els.canvas.classList.remove('dimmed');
303
+ els.diagnostics.classList.remove('show');
304
+ }
305
+
306
+ function showDiagnosticsMode(rows: DiagnosticRow[]): void {
307
+ els.empty.classList.add('hidden');
308
+ if (state.svgString) {
309
+ els.canvas.classList.add('dimmed');
310
+ }
311
+ populateDiagnostics(rows);
312
+ els.diagnostics.classList.add('show');
313
+ }
314
+
315
+ function showFatal(message: string): void {
316
+ const fakeRow: DiagnosticRow = {
317
+ severity: 'error',
318
+ code: 'preview-error',
319
+ message,
320
+ file: '',
321
+ line: 1,
322
+ column: 1,
323
+ };
324
+ showDiagnosticsMode([fakeRow]);
325
+ }
326
+
327
+ // ===== Diagnostics table =====
328
+ function populateDiagnostics(rows: DiagnosticRow[]): void {
329
+ let errors = 0;
330
+ let warnings = 0;
331
+ for (const r of rows) {
332
+ if (r.severity === 'warning') warnings++;
333
+ else errors++;
334
+ }
335
+ let summary = '';
336
+ if (errors) summary += `${errors}${errors === 1 ? ' error' : ' errors'}`;
337
+ if (errors && warnings) summary += ', ';
338
+ if (warnings) summary += `${warnings}${warnings === 1 ? ' warning' : ' warnings'}`;
339
+ if (!summary) summary = 'No problems';
340
+ els.diagSummary.textContent = summary;
341
+ els.diagSummary.className = 'diag-summary';
342
+ if (!errors && warnings) els.diagSummary.classList.add('warn-only');
343
+ if (!errors && !warnings) els.diagSummary.classList.add('clean');
344
+
345
+ const tbody = els.diagTbody;
346
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
347
+
348
+ for (const r of rows) {
349
+ const tr = doc.createElement('tr');
350
+ tr.dataset.file = r.file || '';
351
+ tr.dataset.line = String(r.line);
352
+ tr.dataset.column = String(r.column);
353
+
354
+ const sevTd = doc.createElement('td');
355
+ sevTd.className = `sev-cell ${r.severity === 'warning' ? 'sev-warning' : 'sev-error'}`;
356
+ sevTd.textContent = r.severity === 'warning' ? '\u26A0' : '!';
357
+ tr.appendChild(sevTd);
358
+
359
+ const locTd = doc.createElement('td');
360
+ locTd.className = 'loc-cell';
361
+ locTd.textContent = `Ln ${r.line}, ${r.column}`;
362
+ tr.appendChild(locTd);
363
+
364
+ const codeTd = doc.createElement('td');
365
+ codeTd.className = 'code-cell';
366
+ const pill = doc.createElement('span');
367
+ pill.className = 'code-pill';
368
+ pill.textContent = r.code;
369
+ codeTd.appendChild(pill);
370
+ tr.appendChild(codeTd);
371
+
372
+ const msgTd = doc.createElement('td');
373
+ msgTd.className = 'msg-cell';
374
+ msgTd.textContent = r.message;
375
+ if (r.suggestion) {
376
+ const sg = doc.createElement('span');
377
+ sg.className = 'suggestion';
378
+ sg.textContent = `Did you mean '${r.suggestion}'?`;
379
+ msgTd.appendChild(sg);
380
+ }
381
+ tr.appendChild(msgTd);
382
+ tbody.appendChild(tr);
383
+ }
384
+ }
385
+
386
+ on(els.diagTbody, 'click', (e: Event) => {
387
+ const target = e.target as HTMLElement | null;
388
+ const tr = target?.closest('tr') as HTMLTableRowElement | null;
389
+ if (!tr) return;
390
+ options.onGoto?.({
391
+ file: tr.dataset.file ?? '',
392
+ line: Number.parseInt(tr.dataset.line ?? '1', 10) || 1,
393
+ column: Number.parseInt(tr.dataset.column ?? '1', 10) || 1,
394
+ });
395
+ });
396
+
397
+ on(els.openProblems, 'click', (e: Event) => {
398
+ e.preventDefault();
399
+ options.onOpenProblems?.();
400
+ });
401
+
402
+ // ===== Minimap =====
403
+ function rebuildMinimap(svgString: string): void {
404
+ if (!els.minimapCanvas) return;
405
+ els.minimapCanvas.innerHTML = svgString;
406
+ const svgEl = els.minimapCanvas.querySelector('svg');
407
+ if (svgEl) {
408
+ svgEl.removeAttribute('width');
409
+ svgEl.removeAttribute('height');
410
+ if (state.naturalWidth && state.naturalHeight && !svgEl.getAttribute('viewBox')) {
411
+ svgEl.setAttribute('viewBox', `0 0 ${state.naturalWidth} ${state.naturalHeight}`);
412
+ }
413
+ svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
414
+ }
415
+ const maxW = 160;
416
+ const maxH = 120;
417
+ if (state.naturalWidth && state.naturalHeight) {
418
+ const ratio = state.naturalWidth / state.naturalHeight;
419
+ let w = maxW;
420
+ let h = maxW / ratio;
421
+ if (h > maxH) {
422
+ h = maxH;
423
+ w = maxH * ratio;
424
+ }
425
+ els.minimap.style.width = `${Math.round(w)}px`;
426
+ (els.minimapCanvas as HTMLElement).style.height = `${Math.round(h)}px`;
427
+ }
428
+ }
429
+
430
+ function updateMinimapRect(): void {
431
+ if (els.minimap.classList.contains('hidden')) return;
432
+ const totalW = state.naturalWidth * state.scale;
433
+ const totalH = state.naturalHeight * state.scale;
434
+ if (!totalW || !totalH) return;
435
+ const miniW = els.minimapCanvas.clientWidth;
436
+ const miniH = els.minimapCanvas.clientHeight;
437
+ const rectW = Math.min(1, els.viewport.clientWidth / totalW) * miniW;
438
+ const rectH = Math.min(1, els.viewport.clientHeight / totalH) * miniH;
439
+ const rectX = (els.viewport.scrollLeft / totalW) * miniW;
440
+ const rectY = (els.viewport.scrollTop / totalH) * miniH;
441
+ els.minimapRect.style.left = `${els.minimapCanvas.offsetLeft + rectX}px`;
442
+ els.minimapRect.style.top = `${els.minimapCanvas.offsetTop + rectY}px`;
443
+ els.minimapRect.style.width = `${rectW}px`;
444
+ els.minimapRect.style.height = `${rectH}px`;
445
+ }
446
+
447
+ function updateMinimapVisibility(): void {
448
+ if (!state.showMinimap || state.minimapDismissedThisSession) {
449
+ els.minimap.classList.add('hidden');
450
+ return;
451
+ }
452
+ const totalW = state.naturalWidth * state.scale;
453
+ const totalH = state.naturalHeight * state.scale;
454
+ const fits =
455
+ totalW <= els.viewport.clientWidth + 1 && totalH <= els.viewport.clientHeight + 1;
456
+ if (fits) {
457
+ els.minimap.classList.add('hidden');
458
+ } else {
459
+ els.minimap.classList.remove('hidden');
460
+ }
461
+ }
462
+
463
+ on(els.minimapClose, 'click', (e: Event) => {
464
+ e.stopPropagation();
465
+ state.minimapDismissedThisSession = true;
466
+ els.minimap.classList.add('hidden');
467
+ });
468
+
469
+ function panToMinimapPoint(clientX: number, clientY: number): void {
470
+ const rect = els.minimapCanvas.getBoundingClientRect();
471
+ const x = (clientX - rect.left) / rect.width;
472
+ const y = (clientY - rect.top) / rect.height;
473
+ const totalW = state.naturalWidth * state.scale;
474
+ const totalH = state.naturalHeight * state.scale;
475
+ els.viewport.scrollLeft = Math.max(0, x * totalW - els.viewport.clientWidth / 2);
476
+ els.viewport.scrollTop = Math.max(0, y * totalH - els.viewport.clientHeight / 2);
477
+ }
478
+
479
+ let miniDragging = false;
480
+ on(els.minimapCanvas, 'mousedown', (e: MouseEvent) => {
481
+ miniDragging = true;
482
+ panToMinimapPoint(e.clientX, e.clientY);
483
+ e.preventDefault();
484
+ });
485
+ on(doc, 'mousemove', (e: MouseEvent) => {
486
+ if (miniDragging) panToMinimapPoint(e.clientX, e.clientY);
487
+ });
488
+ on(doc, 'mouseup', () => {
489
+ miniDragging = false;
490
+ });
491
+
492
+ on(els.viewport, 'scroll', updateMinimapRect);
493
+ on(win, 'resize', () => {
494
+ reapplyActiveFit();
495
+ updateMinimapRect();
496
+ updateMinimapVisibility();
497
+ });
498
+
499
+ // ===== Wheel zoom =====
500
+ on(
501
+ els.viewport,
502
+ 'wheel',
503
+ (e: WheelEvent) => {
504
+ if (!(e.ctrlKey || e.metaKey)) return;
505
+ e.preventDefault();
506
+ state.activeFit = 'manual';
507
+ const rect = els.viewport.getBoundingClientRect();
508
+ const anchorX = e.clientX - rect.left;
509
+ const anchorY = e.clientY - rect.top;
510
+ const factor = Math.exp(-e.deltaY * 0.01);
511
+ setScale(state.scale * factor, anchorX, anchorY);
512
+ },
513
+ { passive: false },
514
+ );
515
+
516
+ // ===== Spacebar pan + keyboard fit presets =====
517
+ let spaceDown = false;
518
+ let dragOrigin: { x: number; y: number; sx: number; sy: number } | null = null;
519
+ function isInputFocus(target: EventTarget | null): boolean {
520
+ if (!target) return false;
521
+ const el = target as HTMLElement;
522
+ if (!el.tagName) return false;
523
+ const t = el.tagName.toUpperCase();
524
+ return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
525
+ }
526
+ on(doc, 'keydown', (e: KeyboardEvent) => {
527
+ if (isInputFocus(e.target)) return;
528
+ if (e.key === ' ' && !spaceDown) {
529
+ spaceDown = true;
530
+ els.viewport.style.cursor = 'grab';
531
+ e.preventDefault();
532
+ } else if (e.key === '1') {
533
+ fitPage();
534
+ } else if (e.key === '2') {
535
+ actualSize();
536
+ } else if (e.key === '3') {
537
+ fitWidth();
538
+ } else if (e.key === '0') {
539
+ fitPage();
540
+ }
541
+ });
542
+ on(doc, 'keyup', (e: KeyboardEvent) => {
543
+ if (e.key === ' ') {
544
+ spaceDown = false;
545
+ els.viewport.style.cursor = '';
546
+ }
547
+ });
548
+ on(els.viewport, 'mousedown', (e: MouseEvent) => {
549
+ if (!spaceDown) return;
550
+ dragOrigin = {
551
+ x: e.clientX,
552
+ y: e.clientY,
553
+ sx: els.viewport.scrollLeft,
554
+ sy: els.viewport.scrollTop,
555
+ };
556
+ els.viewport.style.cursor = 'grabbing';
557
+ e.preventDefault();
558
+ });
559
+ on(doc, 'mousemove', (e: MouseEvent) => {
560
+ if (!dragOrigin) return;
561
+ els.viewport.scrollLeft = dragOrigin.sx - (e.clientX - dragOrigin.x);
562
+ els.viewport.scrollTop = dragOrigin.sy - (e.clientY - dragOrigin.y);
563
+ });
564
+ on(doc, 'mouseup', () => {
565
+ if (dragOrigin) {
566
+ dragOrigin = null;
567
+ els.viewport.style.cursor = spaceDown ? 'grab' : '';
568
+ }
569
+ });
570
+
571
+ // ===== Toolbar buttons =====
572
+ on(els.zoomOut, 'click', () => {
573
+ state.activeFit = 'manual';
574
+ setScale(state.scale / 1.1);
575
+ });
576
+ on(els.zoomIn, 'click', () => {
577
+ state.activeFit = 'manual';
578
+ setScale(state.scale * 1.1);
579
+ });
580
+ on(els.zoomReset, 'click', actualSize);
581
+ on(els.fitWidth, 'click', fitWidth);
582
+ on(els.fitPage, 'click', fitPage);
583
+
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
+ });
593
+ }
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());
604
+ }
605
+
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');
617
+ }
618
+ }
619
+
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);
626
+ }
627
+
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;
631
+ 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();
647
+ });
648
+
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
+ });
658
+ }
659
+
660
+ async function handleExportAction(action: string): Promise<void> {
661
+ if (!state.svgString) return;
662
+ try {
663
+ if (action === 'save-svg') {
664
+ options.onSave?.({ format: 'svg', body: state.svgString });
665
+ } else if (action === 'copy-svg') {
666
+ try {
667
+ await win.navigator.clipboard.writeText(state.svgString);
668
+ } catch (err) {
669
+ options.onFatal?.(`Copy SVG failed: ${(err as Error).message}`);
670
+ }
671
+ } else if (action === 'save-png') {
672
+ const blob = await rasterizePng();
673
+ const buf = await blob.arrayBuffer();
674
+ options.onSave?.({ format: 'png', body: new Uint8Array(buf) });
675
+ } else if (action === 'copy-png') {
676
+ const blob = await rasterizePng();
677
+ const ClipboardItemCtor = (win as typeof window).ClipboardItem as
678
+ | typeof ClipboardItem
679
+ | undefined;
680
+ if (typeof ClipboardItemCtor === 'undefined' || !win.navigator.clipboard?.write) {
681
+ const buf = await blob.arrayBuffer();
682
+ options.onCopyPngFallback?.(new Uint8Array(buf));
683
+ return;
684
+ }
685
+ try {
686
+ await win.navigator.clipboard.write([
687
+ new ClipboardItemCtor({ 'image/png': blob }),
688
+ ]);
689
+ } catch {
690
+ const buf = await blob.arrayBuffer();
691
+ options.onCopyPngFallback?.(new Uint8Array(buf));
692
+ }
693
+ }
694
+ } catch (err) {
695
+ options.onFatal?.(`PNG render failed: ${(err as Error).message}`);
696
+ }
697
+ }
698
+
699
+ function rasterizePng(): Promise<Blob> {
700
+ return new Promise<Blob>((resolve, reject) => {
701
+ if (!state.svgString) {
702
+ reject(new Error('No diagram to rasterize.'));
703
+ return;
704
+ }
705
+ const blob = new Blob([state.svgString], { type: 'image/svg+xml;charset=utf-8' });
706
+ const url = (win as typeof window).URL.createObjectURL(blob);
707
+ const img = new (win as typeof window).Image();
708
+ img.onload = () => {
709
+ try {
710
+ const dpr = (win as typeof window).devicePixelRatio || 1;
711
+ const w = state.naturalWidth || img.naturalWidth || 800;
712
+ const h = state.naturalHeight || img.naturalHeight || 600;
713
+ const canvas = doc.createElement('canvas');
714
+ canvas.width = Math.max(1, Math.round(w * dpr));
715
+ canvas.height = Math.max(1, Math.round(h * dpr));
716
+ const ctx = canvas.getContext('2d');
717
+ if (!ctx) throw new Error('2D canvas context unavailable.');
718
+ ctx.scale(dpr, dpr);
719
+ ctx.drawImage(img, 0, 0, w, h);
720
+ (win as typeof window).URL.revokeObjectURL(url);
721
+ canvas.toBlob((out) => {
722
+ if (out) resolve(out);
723
+ else reject(new Error('canvas.toBlob returned null.'));
724
+ }, 'image/png');
725
+ } catch (err) {
726
+ (win as typeof window).URL.revokeObjectURL(url);
727
+ reject(err);
728
+ }
729
+ };
730
+ img.onerror = () => {
731
+ (win as typeof window).URL.revokeObjectURL(url);
732
+ reject(new Error('Failed to load SVG into <img> for rasterization.'));
733
+ };
734
+ img.src = url;
735
+ });
736
+ }
737
+
738
+ // ===== Apply initial baselines =====
739
+ if (options.viewBaseline) {
740
+ applyBaseline(state, options.viewBaseline, true);
741
+ refreshViewMenu();
742
+ } else {
743
+ refreshViewMenu();
744
+ }
745
+ updateMinimapVisibility();
746
+
747
+ // ===== Imperative API =====
748
+ return {
749
+ setSvg,
750
+ setDiagnostics: showDiagnosticsMode,
751
+ setFatal: showFatal,
752
+ setViewBaseline(baseline, resetOverrides = false) {
753
+ applyBaseline(state, baseline, resetOverrides);
754
+ refreshViewMenu();
755
+ },
756
+ setDefaultFit(fit) {
757
+ state.defaultFit = fit;
758
+ },
759
+ setShowMinimap(show) {
760
+ state.showMinimap = show;
761
+ state.minimapDismissedThisSession = false;
762
+ updateMinimapVisibility();
763
+ },
764
+ fitPage,
765
+ fitWidth,
766
+ actualSize,
767
+ getZoom() {
768
+ return state.scale;
769
+ },
770
+ setZoom(scale) {
771
+ state.activeFit = 'manual';
772
+ setScale(scale);
773
+ },
774
+ dispose() {
775
+ if (fadeTimer) clearTimeout(fadeTimer);
776
+ for (const c of cleanups) c();
777
+ cleanups.length = 0;
778
+ rootEl.classList.remove('nl-preview-root');
779
+ rootEl.innerHTML = '';
780
+ },
781
+ };
782
+ }
783
+
784
+ function applyBaseline(
785
+ state: InternalState,
786
+ baseline: ViewBaseline,
787
+ resetOverrides: boolean,
788
+ ): void {
789
+ if (baseline.theme && !state.view.overridden.theme) {
790
+ state.view.theme = baseline.theme;
791
+ }
792
+ 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';
797
+ }
798
+ if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
799
+ state.view.showLinks = baseline.showLinks !== false;
800
+ }
801
+ if (resetOverrides) {
802
+ state.view.overridden = { theme: false, now: false, showLinks: false };
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Test-only helper. Clears the module-level latch that prevents the
808
+ * stylesheet from being mounted twice. Vitest reuses module instances
809
+ * across tests, so without this every test after the first one would
810
+ * skip writing the stylesheet into the test document.
811
+ */
812
+ export function __resetPreviewShellStylesheetForTests(): void {
813
+ stylesheetMounted = false;
814
+ }