@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/LICENSE +190 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/markup.d.ts +56 -0
- package/dist/markup.d.ts.map +1 -0
- package/dist/markup.js +131 -0
- package/dist/markup.js.map +1 -0
- package/dist/mount.d.ts +139 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +1192 -0
- package/dist/mount.js.map +1 -0
- package/dist/styles.d.ts +17 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +521 -0
- package/dist/styles.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +23 -0
- package/src/markup.ts +192 -0
- package/src/mount.ts +1448 -0
- package/src/styles.ts +522 -0
package/dist/mount.js
ADDED
|
@@ -0,0 +1,1192 @@
|
|
|
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
|
+
import { buildViewport } from './markup.js';
|
|
12
|
+
import { PREVIEW_SHELL_CSS } from './styles.js';
|
|
13
|
+
let stylesheetMounted = false;
|
|
14
|
+
/**
|
|
15
|
+
* Last chrome position saved after a drag, shared across all shells in
|
|
16
|
+
* this JS session. Persists for the lifetime of the page.
|
|
17
|
+
*/
|
|
18
|
+
let savedChromePosition = null;
|
|
19
|
+
function ensureStylesheet(doc) {
|
|
20
|
+
if (stylesheetMounted)
|
|
21
|
+
return;
|
|
22
|
+
// A CSP-restricted consumer (e.g. the VS Code webview, whose
|
|
23
|
+
// `style-src` is nonce-only with no `'unsafe-inline'`) pre-injects
|
|
24
|
+
// this stylesheet into the host HTML with a nonce, marked by the
|
|
25
|
+
// `data-nl-preview-shell` attribute. A second `<style>` created here
|
|
26
|
+
// would carry no nonce and be refused by the CSP, leaving the
|
|
27
|
+
// toolbar unstyled — so defer to the host-provided copy when present.
|
|
28
|
+
if (doc.querySelector('style[data-nl-preview-shell]')) {
|
|
29
|
+
stylesheetMounted = true;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const style = doc.createElement('style');
|
|
33
|
+
style.setAttribute('data-nl-preview-shell', '');
|
|
34
|
+
style.textContent = PREVIEW_SHELL_CSS;
|
|
35
|
+
doc.head?.appendChild(style);
|
|
36
|
+
stylesheetMounted = true;
|
|
37
|
+
}
|
|
38
|
+
export function mountPreview(rootEl, options = {}) {
|
|
39
|
+
ensureStylesheet(rootEl.ownerDocument);
|
|
40
|
+
const els = buildViewport(rootEl);
|
|
41
|
+
const doc = rootEl.ownerDocument;
|
|
42
|
+
const win = doc.defaultView ?? globalThis;
|
|
43
|
+
const state = {
|
|
44
|
+
svgString: null,
|
|
45
|
+
scale: 1,
|
|
46
|
+
naturalWidth: 0,
|
|
47
|
+
naturalHeight: 0,
|
|
48
|
+
defaultFit: options.initialFit ?? 'fitPage',
|
|
49
|
+
activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
|
|
50
|
+
isDirty: false,
|
|
51
|
+
lastViewportWidth: 0,
|
|
52
|
+
lastViewportHeight: 0,
|
|
53
|
+
showMinimap: options.showMinimap !== false,
|
|
54
|
+
minimapDismissedThisSession: false,
|
|
55
|
+
firstRender: true,
|
|
56
|
+
mode: 'dark',
|
|
57
|
+
locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
|
|
58
|
+
export: { format: 'svg' },
|
|
59
|
+
view: {
|
|
60
|
+
theme: 'auto',
|
|
61
|
+
now: 'today',
|
|
62
|
+
showLinks: true,
|
|
63
|
+
overridden: { theme: false, now: false, showLinks: false },
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const cleanups = [];
|
|
67
|
+
function on(target, type, handler, opts) {
|
|
68
|
+
target.addEventListener(type, handler, opts);
|
|
69
|
+
cleanups.push(() => target.removeEventListener(type, handler, opts));
|
|
70
|
+
}
|
|
71
|
+
// ===== Mode resolution =====
|
|
72
|
+
function resolveSystemMode() {
|
|
73
|
+
const body = doc.body;
|
|
74
|
+
if (body) {
|
|
75
|
+
if (body.classList.contains('vscode-dark') ||
|
|
76
|
+
body.classList.contains('vscode-high-contrast-dark'))
|
|
77
|
+
return 'dark';
|
|
78
|
+
if (body.classList.contains('vscode-light') ||
|
|
79
|
+
body.classList.contains('vscode-high-contrast-light') ||
|
|
80
|
+
body.classList.contains('vscode-high-contrast'))
|
|
81
|
+
return 'light';
|
|
82
|
+
}
|
|
83
|
+
if (typeof win.matchMedia === 'function') {
|
|
84
|
+
return win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
85
|
+
}
|
|
86
|
+
return 'dark';
|
|
87
|
+
}
|
|
88
|
+
function applyMode(resolved) {
|
|
89
|
+
state.mode = resolved;
|
|
90
|
+
rootEl.setAttribute('data-nl-mode', resolved);
|
|
91
|
+
}
|
|
92
|
+
const rawMode = options.mode ?? 'system';
|
|
93
|
+
applyMode(rawMode === 'system' ? resolveSystemMode() : rawMode);
|
|
94
|
+
if (rawMode === 'system') {
|
|
95
|
+
if (typeof MutationObserver !== 'undefined' && doc.body) {
|
|
96
|
+
const bodyObs = new MutationObserver(() => applyMode(resolveSystemMode()));
|
|
97
|
+
bodyObs.observe(doc.body, { attributes: true, attributeFilter: ['class'] });
|
|
98
|
+
cleanups.push(() => bodyObs.disconnect());
|
|
99
|
+
}
|
|
100
|
+
const mq = typeof win.matchMedia === 'function'
|
|
101
|
+
? win.matchMedia('(prefers-color-scheme: dark)')
|
|
102
|
+
: null;
|
|
103
|
+
if (mq) {
|
|
104
|
+
const onMqChange = () => {
|
|
105
|
+
const body = doc.body;
|
|
106
|
+
const hasVsCode = body &&
|
|
107
|
+
(body.classList.contains('vscode-dark') ||
|
|
108
|
+
body.classList.contains('vscode-light') ||
|
|
109
|
+
body.classList.contains('vscode-high-contrast') ||
|
|
110
|
+
body.classList.contains('vscode-high-contrast-dark') ||
|
|
111
|
+
body.classList.contains('vscode-high-contrast-light'));
|
|
112
|
+
if (!hasVsCode)
|
|
113
|
+
applyMode(mq.matches ? 'dark' : 'light');
|
|
114
|
+
};
|
|
115
|
+
mq.addEventListener('change', onMqChange);
|
|
116
|
+
cleanups.push(() => mq.removeEventListener('change', onMqChange));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ===== Floating UI fade =====
|
|
120
|
+
let fadeTimer = null;
|
|
121
|
+
function showFloatingUI() {
|
|
122
|
+
els.chrome.classList.remove('faded');
|
|
123
|
+
els.minimap.classList.remove('faded');
|
|
124
|
+
if (fadeTimer)
|
|
125
|
+
clearTimeout(fadeTimer);
|
|
126
|
+
fadeTimer = setTimeout(() => {
|
|
127
|
+
els.chrome.classList.add('faded');
|
|
128
|
+
els.minimap.classList.add('faded');
|
|
129
|
+
}, 2000);
|
|
130
|
+
}
|
|
131
|
+
on(doc, 'mousemove', showFloatingUI);
|
|
132
|
+
showFloatingUI();
|
|
133
|
+
// ===== Collapse / restore =====
|
|
134
|
+
// « collapses the toolbar to a translucent puck (just the drag grip
|
|
135
|
+
// + a » restore arrow); » expands it back to the full toolbar.
|
|
136
|
+
on(els.collapseBtn, 'click', (e) => {
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
closeAllMenus();
|
|
139
|
+
els.chrome.classList.add('collapsed');
|
|
140
|
+
repositionChrome();
|
|
141
|
+
});
|
|
142
|
+
on(els.restoreBtn, 'click', (e) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
els.chrome.classList.remove('collapsed');
|
|
145
|
+
repositionChrome();
|
|
146
|
+
});
|
|
147
|
+
// ===== Toolbar positioning + drag =====
|
|
148
|
+
// The chrome defaults to the upper-right corner and tracks it on
|
|
149
|
+
// resize (`chromePinnedRight`). Once dragged it becomes free-floating
|
|
150
|
+
// and is merely clamped back into view. Either way the position is
|
|
151
|
+
// always expressed as explicit `left`/`top` so a narrowing viewport
|
|
152
|
+
// shifts the toolbar left instead of squishing the row.
|
|
153
|
+
let chromePinnedRight = savedChromePosition === null;
|
|
154
|
+
let chromeDragStart = null;
|
|
155
|
+
function getGutter() {
|
|
156
|
+
const val = getComputedStyle(rootEl).getPropertyValue('--nl-preview-gutter').trim();
|
|
157
|
+
return parseInt(val, 10) || 8;
|
|
158
|
+
}
|
|
159
|
+
function repositionChrome() {
|
|
160
|
+
const g = getGutter();
|
|
161
|
+
const cw = els.chrome.offsetWidth;
|
|
162
|
+
const ch = els.chrome.offsetHeight;
|
|
163
|
+
const maxLeft = Math.max(g, rootEl.clientWidth - cw - g);
|
|
164
|
+
const maxTop = Math.max(g, rootEl.clientHeight - ch - g);
|
|
165
|
+
let left;
|
|
166
|
+
let top;
|
|
167
|
+
if (chromePinnedRight) {
|
|
168
|
+
left = rootEl.clientWidth - cw - g;
|
|
169
|
+
top = g;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
left = parseInt(els.chrome.style.left, 10);
|
|
173
|
+
top = parseInt(els.chrome.style.top, 10);
|
|
174
|
+
if (Number.isNaN(left))
|
|
175
|
+
left = savedChromePosition?.left ?? g;
|
|
176
|
+
if (Number.isNaN(top))
|
|
177
|
+
top = savedChromePosition?.top ?? g;
|
|
178
|
+
}
|
|
179
|
+
els.chrome.style.right = 'auto';
|
|
180
|
+
els.chrome.style.left = `${Math.max(g, Math.min(left, maxLeft))}px`;
|
|
181
|
+
els.chrome.style.top = `${Math.max(g, Math.min(top, maxTop))}px`;
|
|
182
|
+
}
|
|
183
|
+
function ensureChromeLeftTopAnchoring() {
|
|
184
|
+
if (els.chrome.style.left !== '')
|
|
185
|
+
return;
|
|
186
|
+
els.chrome.style.right = 'auto';
|
|
187
|
+
els.chrome.style.left = `${els.chrome.offsetLeft}px`;
|
|
188
|
+
els.chrome.style.top = `${els.chrome.offsetTop}px`;
|
|
189
|
+
}
|
|
190
|
+
on(els.toolbarHandle, 'pointerdown', (e) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
chromePinnedRight = false;
|
|
193
|
+
ensureChromeLeftTopAnchoring();
|
|
194
|
+
chromeDragStart = {
|
|
195
|
+
pointerX: e.clientX,
|
|
196
|
+
pointerY: e.clientY,
|
|
197
|
+
chromeLeft: parseInt(els.chrome.style.left, 10) || 0,
|
|
198
|
+
chromeTop: parseInt(els.chrome.style.top, 10) || 0,
|
|
199
|
+
};
|
|
200
|
+
els.chrome.classList.add('dragging');
|
|
201
|
+
els.toolbarHandle.setPointerCapture(e.pointerId);
|
|
202
|
+
});
|
|
203
|
+
on(els.toolbarHandle, 'pointermove', (e) => {
|
|
204
|
+
if (!chromeDragStart)
|
|
205
|
+
return;
|
|
206
|
+
const dx = e.clientX - chromeDragStart.pointerX;
|
|
207
|
+
const dy = e.clientY - chromeDragStart.pointerY;
|
|
208
|
+
const rawLeft = chromeDragStart.chromeLeft + dx;
|
|
209
|
+
const rawTop = chromeDragStart.chromeTop + dy;
|
|
210
|
+
const g = getGutter();
|
|
211
|
+
const maxLeft = Math.max(g, rootEl.clientWidth - els.chrome.offsetWidth - g);
|
|
212
|
+
const maxTop = Math.max(g, rootEl.clientHeight - els.chrome.offsetHeight - g);
|
|
213
|
+
els.chrome.style.left = `${Math.max(g, Math.min(rawLeft, maxLeft))}px`;
|
|
214
|
+
els.chrome.style.top = `${Math.max(g, Math.min(rawTop, maxTop))}px`;
|
|
215
|
+
});
|
|
216
|
+
function endChromeDrag() {
|
|
217
|
+
if (!chromeDragStart)
|
|
218
|
+
return;
|
|
219
|
+
chromeDragStart = null;
|
|
220
|
+
els.chrome.classList.remove('dragging');
|
|
221
|
+
savedChromePosition = {
|
|
222
|
+
left: parseInt(els.chrome.style.left, 10) || 0,
|
|
223
|
+
top: parseInt(els.chrome.style.top, 10) || 0,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
on(els.toolbarHandle, 'pointerup', endChromeDrag);
|
|
227
|
+
on(els.toolbarHandle, 'pointercancel', () => {
|
|
228
|
+
chromeDragStart = null;
|
|
229
|
+
els.chrome.classList.remove('dragging');
|
|
230
|
+
});
|
|
231
|
+
// ===== Transform =====
|
|
232
|
+
function applyTransform() {
|
|
233
|
+
const w = Math.max(1, Math.round(state.naturalWidth * state.scale));
|
|
234
|
+
const h = Math.max(1, Math.round(state.naturalHeight * state.scale));
|
|
235
|
+
els.canvas.style.width = `${w}px`;
|
|
236
|
+
els.canvas.style.height = `${h}px`;
|
|
237
|
+
els.zoomReset.textContent = `${Math.round(state.scale * 100)}%`;
|
|
238
|
+
updateMinimapRect();
|
|
239
|
+
updateMinimapVisibility();
|
|
240
|
+
}
|
|
241
|
+
function clampScale(s) {
|
|
242
|
+
if (s < 0.05)
|
|
243
|
+
return 0.05;
|
|
244
|
+
if (s > 20)
|
|
245
|
+
return 20;
|
|
246
|
+
return s;
|
|
247
|
+
}
|
|
248
|
+
function setScale(newScale, anchorX, anchorY) {
|
|
249
|
+
const clamped = clampScale(newScale);
|
|
250
|
+
if (anchorX === undefined || anchorY === undefined || state.scale === 0) {
|
|
251
|
+
state.scale = clamped;
|
|
252
|
+
applyTransform();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const ratio = clamped / state.scale;
|
|
256
|
+
const newScrollX = (els.viewport.scrollLeft + anchorX) * ratio - anchorX;
|
|
257
|
+
const newScrollY = (els.viewport.scrollTop + anchorY) * ratio - anchorY;
|
|
258
|
+
state.scale = clamped;
|
|
259
|
+
applyTransform();
|
|
260
|
+
els.viewport.scrollLeft = newScrollX;
|
|
261
|
+
els.viewport.scrollTop = newScrollY;
|
|
262
|
+
}
|
|
263
|
+
function fitPage() {
|
|
264
|
+
if (!state.naturalWidth || !state.naturalHeight)
|
|
265
|
+
return;
|
|
266
|
+
state.activeFit = 'fitPage';
|
|
267
|
+
state.isDirty = false;
|
|
268
|
+
const sx = els.viewport.clientWidth / state.naturalWidth;
|
|
269
|
+
const sy = els.viewport.clientHeight / state.naturalHeight;
|
|
270
|
+
setScale(Math.min(sx, sy));
|
|
271
|
+
els.viewport.scrollLeft = 0;
|
|
272
|
+
els.viewport.scrollTop = 0;
|
|
273
|
+
}
|
|
274
|
+
function fitWidth() {
|
|
275
|
+
if (!state.naturalWidth)
|
|
276
|
+
return;
|
|
277
|
+
state.activeFit = 'fitWidth';
|
|
278
|
+
state.isDirty = false;
|
|
279
|
+
setScale(els.viewport.clientWidth / state.naturalWidth);
|
|
280
|
+
els.viewport.scrollLeft = 0;
|
|
281
|
+
els.viewport.scrollTop = 0;
|
|
282
|
+
}
|
|
283
|
+
function actualSize() {
|
|
284
|
+
state.activeFit = 'manual';
|
|
285
|
+
state.isDirty = true;
|
|
286
|
+
setScale(1);
|
|
287
|
+
}
|
|
288
|
+
function applyDefaultFit() {
|
|
289
|
+
if (state.defaultFit === 'fitWidth')
|
|
290
|
+
fitWidth();
|
|
291
|
+
else if (state.defaultFit === 'actual')
|
|
292
|
+
actualSize();
|
|
293
|
+
else
|
|
294
|
+
fitPage();
|
|
295
|
+
}
|
|
296
|
+
function reapplyActiveFit() {
|
|
297
|
+
const newW = els.viewport.clientWidth;
|
|
298
|
+
const newH = els.viewport.clientHeight;
|
|
299
|
+
if (state.isDirty) {
|
|
300
|
+
const oldW = state.lastViewportWidth || newW;
|
|
301
|
+
const oldH = state.lastViewportHeight || newH;
|
|
302
|
+
if (state.scale > 0 && (oldW !== newW || oldH !== newH)) {
|
|
303
|
+
const cx = (els.viewport.scrollLeft + oldW / 2) / state.scale;
|
|
304
|
+
const cy = (els.viewport.scrollTop + oldH / 2) / state.scale;
|
|
305
|
+
els.viewport.scrollLeft = Math.max(0, cx * state.scale - newW / 2);
|
|
306
|
+
els.viewport.scrollTop = Math.max(0, cy * state.scale - newH / 2);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else if (state.activeFit === 'fitPage') {
|
|
310
|
+
fitPage();
|
|
311
|
+
}
|
|
312
|
+
else if (state.activeFit === 'fitWidth') {
|
|
313
|
+
fitWidth();
|
|
314
|
+
}
|
|
315
|
+
state.lastViewportWidth = newW;
|
|
316
|
+
state.lastViewportHeight = newH;
|
|
317
|
+
}
|
|
318
|
+
// ===== Render handling =====
|
|
319
|
+
function extractNaturalSize(svgEl) {
|
|
320
|
+
let w = 0;
|
|
321
|
+
let h = 0;
|
|
322
|
+
if (svgEl.viewBox?.baseVal?.width) {
|
|
323
|
+
w = svgEl.viewBox.baseVal.width;
|
|
324
|
+
h = svgEl.viewBox.baseVal.height;
|
|
325
|
+
}
|
|
326
|
+
if (!w && svgEl.width?.baseVal?.value) {
|
|
327
|
+
w = svgEl.width.baseVal.value;
|
|
328
|
+
}
|
|
329
|
+
if (!h && svgEl.height?.baseVal?.value) {
|
|
330
|
+
h = svgEl.height.baseVal.value;
|
|
331
|
+
}
|
|
332
|
+
svgEl.removeAttribute('width');
|
|
333
|
+
svgEl.removeAttribute('height');
|
|
334
|
+
if (!svgEl.getAttribute('viewBox') && w && h) {
|
|
335
|
+
svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
336
|
+
}
|
|
337
|
+
svgEl.style.width = '100%';
|
|
338
|
+
svgEl.style.height = '100%';
|
|
339
|
+
state.naturalWidth = w || 800;
|
|
340
|
+
state.naturalHeight = h || 600;
|
|
341
|
+
}
|
|
342
|
+
function setSvg(svgString) {
|
|
343
|
+
state.svgString = svgString;
|
|
344
|
+
els.canvas.innerHTML = svgString;
|
|
345
|
+
const svgEl = els.canvas.querySelector('svg');
|
|
346
|
+
if (!svgEl)
|
|
347
|
+
return;
|
|
348
|
+
extractNaturalSize(svgEl);
|
|
349
|
+
rebuildMinimap(svgString);
|
|
350
|
+
if (state.firstRender) {
|
|
351
|
+
applyDefaultFit();
|
|
352
|
+
state.firstRender = false;
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
applyTransform();
|
|
356
|
+
}
|
|
357
|
+
showSvgMode();
|
|
358
|
+
}
|
|
359
|
+
function showSvgMode() {
|
|
360
|
+
els.empty.classList.add('hidden');
|
|
361
|
+
els.canvas.classList.remove('dimmed');
|
|
362
|
+
els.diagnostics.classList.remove('show');
|
|
363
|
+
}
|
|
364
|
+
function showDiagnosticsMode(rows) {
|
|
365
|
+
els.empty.classList.add('hidden');
|
|
366
|
+
if (state.svgString) {
|
|
367
|
+
els.canvas.classList.add('dimmed');
|
|
368
|
+
}
|
|
369
|
+
populateDiagnostics(rows);
|
|
370
|
+
els.diagnostics.classList.add('show');
|
|
371
|
+
}
|
|
372
|
+
function showFatal(message) {
|
|
373
|
+
const fakeRow = {
|
|
374
|
+
severity: 'error',
|
|
375
|
+
code: 'preview-error',
|
|
376
|
+
message,
|
|
377
|
+
file: '',
|
|
378
|
+
line: 1,
|
|
379
|
+
column: 1,
|
|
380
|
+
};
|
|
381
|
+
showDiagnosticsMode([fakeRow]);
|
|
382
|
+
}
|
|
383
|
+
// ===== Diagnostics table =====
|
|
384
|
+
function populateDiagnostics(rows) {
|
|
385
|
+
let errors = 0;
|
|
386
|
+
let warnings = 0;
|
|
387
|
+
for (const r of rows) {
|
|
388
|
+
if (r.severity === 'warning')
|
|
389
|
+
warnings++;
|
|
390
|
+
else
|
|
391
|
+
errors++;
|
|
392
|
+
}
|
|
393
|
+
let summary = '';
|
|
394
|
+
if (errors)
|
|
395
|
+
summary += `${errors}${errors === 1 ? ' error' : ' errors'}`;
|
|
396
|
+
if (errors && warnings)
|
|
397
|
+
summary += ', ';
|
|
398
|
+
if (warnings)
|
|
399
|
+
summary += `${warnings}${warnings === 1 ? ' warning' : ' warnings'}`;
|
|
400
|
+
if (!summary)
|
|
401
|
+
summary = 'No problems';
|
|
402
|
+
els.diagSummary.textContent = summary;
|
|
403
|
+
els.diagSummary.className = 'diag-summary';
|
|
404
|
+
if (!errors && warnings)
|
|
405
|
+
els.diagSummary.classList.add('warn-only');
|
|
406
|
+
if (!errors && !warnings)
|
|
407
|
+
els.diagSummary.classList.add('clean');
|
|
408
|
+
// Hide Open Problems link when there are no issues
|
|
409
|
+
els.openProblems.hidden = errors + warnings === 0;
|
|
410
|
+
const tbody = els.diagTbody;
|
|
411
|
+
while (tbody.firstChild)
|
|
412
|
+
tbody.removeChild(tbody.firstChild);
|
|
413
|
+
for (const r of rows) {
|
|
414
|
+
const tr = doc.createElement('tr');
|
|
415
|
+
tr.dataset.file = r.file || '';
|
|
416
|
+
tr.dataset.line = String(r.line);
|
|
417
|
+
tr.dataset.column = String(r.column);
|
|
418
|
+
const sevTd = doc.createElement('td');
|
|
419
|
+
sevTd.className = `sev-cell ${r.severity === 'warning' ? 'sev-warning' : 'sev-error'}`;
|
|
420
|
+
sevTd.textContent = r.severity === 'warning' ? '\u26A0' : '!';
|
|
421
|
+
tr.appendChild(sevTd);
|
|
422
|
+
const locTd = doc.createElement('td');
|
|
423
|
+
locTd.className = 'loc-cell';
|
|
424
|
+
locTd.textContent = `Ln ${r.line}, ${r.column}`;
|
|
425
|
+
tr.appendChild(locTd);
|
|
426
|
+
const codeTd = doc.createElement('td');
|
|
427
|
+
codeTd.className = 'code-cell';
|
|
428
|
+
const pill = doc.createElement('span');
|
|
429
|
+
pill.className = 'code-pill';
|
|
430
|
+
pill.textContent = r.code;
|
|
431
|
+
codeTd.appendChild(pill);
|
|
432
|
+
tr.appendChild(codeTd);
|
|
433
|
+
const msgTd = doc.createElement('td');
|
|
434
|
+
msgTd.className = 'msg-cell';
|
|
435
|
+
msgTd.textContent = r.message;
|
|
436
|
+
if (r.suggestion) {
|
|
437
|
+
const sg = doc.createElement('span');
|
|
438
|
+
sg.className = 'suggestion';
|
|
439
|
+
sg.textContent = `Did you mean '${r.suggestion}'?`;
|
|
440
|
+
msgTd.appendChild(sg);
|
|
441
|
+
}
|
|
442
|
+
tr.appendChild(msgTd);
|
|
443
|
+
tbody.appendChild(tr);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
on(els.diagTbody, 'click', (e) => {
|
|
447
|
+
const target = e.target;
|
|
448
|
+
const tr = target?.closest('tr');
|
|
449
|
+
if (!tr)
|
|
450
|
+
return;
|
|
451
|
+
options.onGoto?.({
|
|
452
|
+
file: tr.dataset.file ?? '',
|
|
453
|
+
line: Number.parseInt(tr.dataset.line ?? '1', 10) || 1,
|
|
454
|
+
column: Number.parseInt(tr.dataset.column ?? '1', 10) || 1,
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
on(els.openProblems, 'click', (e) => {
|
|
458
|
+
e.preventDefault();
|
|
459
|
+
options.onOpenProblems?.();
|
|
460
|
+
});
|
|
461
|
+
// ===== Minimap =====
|
|
462
|
+
function rebuildMinimap(svgString) {
|
|
463
|
+
if (!els.minimapCanvas)
|
|
464
|
+
return;
|
|
465
|
+
els.minimapCanvas.innerHTML = svgString;
|
|
466
|
+
const svgEl = els.minimapCanvas.querySelector('svg');
|
|
467
|
+
if (svgEl) {
|
|
468
|
+
svgEl.removeAttribute('width');
|
|
469
|
+
svgEl.removeAttribute('height');
|
|
470
|
+
if (state.naturalWidth && state.naturalHeight && !svgEl.getAttribute('viewBox')) {
|
|
471
|
+
svgEl.setAttribute('viewBox', `0 0 ${state.naturalWidth} ${state.naturalHeight}`);
|
|
472
|
+
}
|
|
473
|
+
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
474
|
+
}
|
|
475
|
+
const maxW = 120;
|
|
476
|
+
const maxH = 90;
|
|
477
|
+
if (state.naturalWidth && state.naturalHeight) {
|
|
478
|
+
const ratio = state.naturalWidth / state.naturalHeight;
|
|
479
|
+
let w = maxW;
|
|
480
|
+
let h = maxW / ratio;
|
|
481
|
+
if (h > maxH) {
|
|
482
|
+
h = maxH;
|
|
483
|
+
w = maxH * ratio;
|
|
484
|
+
}
|
|
485
|
+
els.minimap.style.width = `${Math.round(w)}px`;
|
|
486
|
+
els.minimapCanvas.style.height = `${Math.round(h)}px`;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function updateMinimapRect() {
|
|
490
|
+
if (els.minimap.classList.contains('hidden'))
|
|
491
|
+
return;
|
|
492
|
+
const totalW = state.naturalWidth * state.scale;
|
|
493
|
+
const totalH = state.naturalHeight * state.scale;
|
|
494
|
+
if (!totalW || !totalH)
|
|
495
|
+
return;
|
|
496
|
+
const miniW = els.minimapCanvas.clientWidth;
|
|
497
|
+
const miniH = els.minimapCanvas.clientHeight;
|
|
498
|
+
const rectW = Math.min(1, els.viewport.clientWidth / totalW) * miniW;
|
|
499
|
+
const rectH = Math.min(1, els.viewport.clientHeight / totalH) * miniH;
|
|
500
|
+
const rectX = (els.viewport.scrollLeft / totalW) * miniW;
|
|
501
|
+
const rectY = (els.viewport.scrollTop / totalH) * miniH;
|
|
502
|
+
els.minimapRect.style.left = `${els.minimapCanvas.offsetLeft + rectX}px`;
|
|
503
|
+
els.minimapRect.style.top = `${els.minimapCanvas.offsetTop + rectY}px`;
|
|
504
|
+
els.minimapRect.style.width = `${rectW}px`;
|
|
505
|
+
els.minimapRect.style.height = `${rectH}px`;
|
|
506
|
+
}
|
|
507
|
+
function updateMinimapVisibility() {
|
|
508
|
+
if (!state.showMinimap || state.minimapDismissedThisSession) {
|
|
509
|
+
els.minimap.classList.add('hidden');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const totalW = state.naturalWidth * state.scale;
|
|
513
|
+
const totalH = state.naturalHeight * state.scale;
|
|
514
|
+
const fits = totalW <= els.viewport.clientWidth + 1 && totalH <= els.viewport.clientHeight + 1;
|
|
515
|
+
if (fits) {
|
|
516
|
+
els.minimap.classList.add('hidden');
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
els.minimap.classList.remove('hidden');
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
on(els.minimapClose, 'click', (e) => {
|
|
523
|
+
e.stopPropagation();
|
|
524
|
+
state.minimapDismissedThisSession = true;
|
|
525
|
+
els.minimap.classList.add('hidden');
|
|
526
|
+
});
|
|
527
|
+
function panToMinimapPoint(clientX, clientY) {
|
|
528
|
+
const rect = els.minimapCanvas.getBoundingClientRect();
|
|
529
|
+
const x = (clientX - rect.left) / rect.width;
|
|
530
|
+
const y = (clientY - rect.top) / rect.height;
|
|
531
|
+
const totalW = state.naturalWidth * state.scale;
|
|
532
|
+
const totalH = state.naturalHeight * state.scale;
|
|
533
|
+
els.viewport.scrollLeft = Math.max(0, x * totalW - els.viewport.clientWidth / 2);
|
|
534
|
+
els.viewport.scrollTop = Math.max(0, y * totalH - els.viewport.clientHeight / 2);
|
|
535
|
+
}
|
|
536
|
+
let miniDragging = false;
|
|
537
|
+
on(els.minimapCanvas, 'mousedown', (e) => {
|
|
538
|
+
miniDragging = true;
|
|
539
|
+
panToMinimapPoint(e.clientX, e.clientY);
|
|
540
|
+
e.preventDefault();
|
|
541
|
+
});
|
|
542
|
+
on(doc, 'mousemove', (e) => {
|
|
543
|
+
if (miniDragging)
|
|
544
|
+
panToMinimapPoint(e.clientX, e.clientY);
|
|
545
|
+
});
|
|
546
|
+
on(doc, 'mouseup', () => {
|
|
547
|
+
miniDragging = false;
|
|
548
|
+
});
|
|
549
|
+
on(els.viewport, 'scroll', updateMinimapRect);
|
|
550
|
+
let resizeTimer = null;
|
|
551
|
+
function handleResize() {
|
|
552
|
+
if (resizeTimer)
|
|
553
|
+
clearTimeout(resizeTimer);
|
|
554
|
+
resizeTimer = setTimeout(() => {
|
|
555
|
+
resizeTimer = null;
|
|
556
|
+
reapplyActiveFit();
|
|
557
|
+
updateMinimapRect();
|
|
558
|
+
updateMinimapVisibility();
|
|
559
|
+
repositionChrome();
|
|
560
|
+
}, 50);
|
|
561
|
+
}
|
|
562
|
+
on(win, 'resize', handleResize);
|
|
563
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
564
|
+
const ro = new ResizeObserver(handleResize);
|
|
565
|
+
ro.observe(els.viewport);
|
|
566
|
+
cleanups.push(() => ro.disconnect());
|
|
567
|
+
}
|
|
568
|
+
// ===== Wheel zoom =====
|
|
569
|
+
on(els.viewport, 'wheel', (e) => {
|
|
570
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
571
|
+
return;
|
|
572
|
+
e.preventDefault();
|
|
573
|
+
state.activeFit = 'manual';
|
|
574
|
+
state.isDirty = true;
|
|
575
|
+
const rect = els.viewport.getBoundingClientRect();
|
|
576
|
+
const anchorX = e.clientX - rect.left;
|
|
577
|
+
const anchorY = e.clientY - rect.top;
|
|
578
|
+
const factor = Math.exp(-e.deltaY * 0.01);
|
|
579
|
+
setScale(state.scale * factor, anchorX, anchorY);
|
|
580
|
+
}, { passive: false });
|
|
581
|
+
// ===== Spacebar pan + keyboard fit presets =====
|
|
582
|
+
let spaceDown = false;
|
|
583
|
+
let dragOrigin = null;
|
|
584
|
+
function isInputFocus(target) {
|
|
585
|
+
if (!target)
|
|
586
|
+
return false;
|
|
587
|
+
const el = target;
|
|
588
|
+
if (!el.tagName)
|
|
589
|
+
return false;
|
|
590
|
+
const t = el.tagName.toUpperCase();
|
|
591
|
+
return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
|
|
592
|
+
}
|
|
593
|
+
on(doc, 'keydown', (e) => {
|
|
594
|
+
if (isInputFocus(e.target))
|
|
595
|
+
return;
|
|
596
|
+
if (e.key === ' ' && !spaceDown) {
|
|
597
|
+
spaceDown = true;
|
|
598
|
+
els.viewport.style.cursor = 'grab';
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
}
|
|
601
|
+
else if (e.key === '1') {
|
|
602
|
+
fitPage();
|
|
603
|
+
}
|
|
604
|
+
else if (e.key === '2') {
|
|
605
|
+
actualSize();
|
|
606
|
+
}
|
|
607
|
+
else if (e.key === '3') {
|
|
608
|
+
fitWidth();
|
|
609
|
+
}
|
|
610
|
+
else if (e.key === '0') {
|
|
611
|
+
fitPage();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
on(doc, 'keyup', (e) => {
|
|
615
|
+
if (e.key === ' ') {
|
|
616
|
+
spaceDown = false;
|
|
617
|
+
els.viewport.style.cursor = '';
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
on(els.viewport, 'mousedown', (e) => {
|
|
621
|
+
if (!spaceDown)
|
|
622
|
+
return;
|
|
623
|
+
state.isDirty = true;
|
|
624
|
+
dragOrigin = {
|
|
625
|
+
x: e.clientX,
|
|
626
|
+
y: e.clientY,
|
|
627
|
+
sx: els.viewport.scrollLeft,
|
|
628
|
+
sy: els.viewport.scrollTop,
|
|
629
|
+
};
|
|
630
|
+
els.viewport.style.cursor = 'grabbing';
|
|
631
|
+
e.preventDefault();
|
|
632
|
+
});
|
|
633
|
+
on(doc, 'mousemove', (e) => {
|
|
634
|
+
if (!dragOrigin)
|
|
635
|
+
return;
|
|
636
|
+
els.viewport.scrollLeft = dragOrigin.sx - (e.clientX - dragOrigin.x);
|
|
637
|
+
els.viewport.scrollTop = dragOrigin.sy - (e.clientY - dragOrigin.y);
|
|
638
|
+
});
|
|
639
|
+
on(doc, 'mouseup', () => {
|
|
640
|
+
if (dragOrigin) {
|
|
641
|
+
dragOrigin = null;
|
|
642
|
+
els.viewport.style.cursor = spaceDown ? 'grab' : '';
|
|
643
|
+
}
|
|
644
|
+
});
|
|
645
|
+
// ===== Toolbar buttons =====
|
|
646
|
+
on(els.zoomOut, 'click', () => {
|
|
647
|
+
state.activeFit = 'manual';
|
|
648
|
+
state.isDirty = true;
|
|
649
|
+
setScale(state.scale / 1.1);
|
|
650
|
+
});
|
|
651
|
+
on(els.zoomIn, 'click', () => {
|
|
652
|
+
state.activeFit = 'manual';
|
|
653
|
+
state.isDirty = true;
|
|
654
|
+
setScale(state.scale * 1.1);
|
|
655
|
+
});
|
|
656
|
+
on(els.zoomReset, 'click', actualSize);
|
|
657
|
+
on(els.fitWidth, 'click', fitWidth);
|
|
658
|
+
on(els.fitPage, 'click', fitPage);
|
|
659
|
+
// ===== Menu state helpers =====
|
|
660
|
+
function closeSubMenus() {
|
|
661
|
+
els.formatMenu.hidden = true;
|
|
662
|
+
els.themeMenu.hidden = true;
|
|
663
|
+
els.nowPicker.hidden = true;
|
|
664
|
+
els.linksMenu.hidden = true;
|
|
665
|
+
}
|
|
666
|
+
function closeAllMenus() {
|
|
667
|
+
els.moreMenu.hidden = true;
|
|
668
|
+
closeSubMenus();
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Keep a just-opened flyout (more-menu, a sub-dropdown, or the
|
|
672
|
+
* calendar) inside the preview root + gutters. `defaultSide` is the
|
|
673
|
+
* edge the panel is anchored to in CSS; if opening that way would
|
|
674
|
+
* spill past the opposite gutter the panel is flipped, then any
|
|
675
|
+
* residual horizontal overflow is nudged back with a transform and
|
|
676
|
+
* excess height is capped with an internal scroll.
|
|
677
|
+
*
|
|
678
|
+
* `clampVertical` is off for the more-menu so capping its height
|
|
679
|
+
* never clips the calendar that pops out of it.
|
|
680
|
+
*/
|
|
681
|
+
function placeFlyout(panel, defaultSide, clampVertical = true) {
|
|
682
|
+
panel.classList.remove('flip');
|
|
683
|
+
panel.style.maxHeight = '';
|
|
684
|
+
panel.style.overflowY = '';
|
|
685
|
+
panel.style.transform = '';
|
|
686
|
+
const root = rootEl.getBoundingClientRect();
|
|
687
|
+
// Skip when the host hasn't laid out yet (e.g. happy-dom tests).
|
|
688
|
+
if (root.width === 0 && root.height === 0)
|
|
689
|
+
return;
|
|
690
|
+
const g = getGutter();
|
|
691
|
+
let box = panel.getBoundingClientRect();
|
|
692
|
+
const fitsWidth = box.width <= root.width - 2 * g;
|
|
693
|
+
if (fitsWidth && defaultSide === 'left' && box.right > root.right - g) {
|
|
694
|
+
panel.classList.add('flip');
|
|
695
|
+
}
|
|
696
|
+
else if (fitsWidth && defaultSide === 'right' && box.left < root.left + g) {
|
|
697
|
+
panel.classList.add('flip');
|
|
698
|
+
}
|
|
699
|
+
box = panel.getBoundingClientRect();
|
|
700
|
+
let dx = 0;
|
|
701
|
+
if (box.right > root.right - g)
|
|
702
|
+
dx = root.right - g - box.right;
|
|
703
|
+
if (box.left + dx < root.left + g)
|
|
704
|
+
dx = root.left + g - box.left;
|
|
705
|
+
if (dx !== 0)
|
|
706
|
+
panel.style.transform = `translateX(${Math.round(dx)}px)`;
|
|
707
|
+
if (clampVertical) {
|
|
708
|
+
box = panel.getBoundingClientRect();
|
|
709
|
+
const avail = root.bottom - g - box.top;
|
|
710
|
+
if (box.height > avail && avail > 80) {
|
|
711
|
+
panel.style.maxHeight = `${Math.round(avail)}px`;
|
|
712
|
+
panel.style.overflowY = 'auto';
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
on(doc, 'click', closeAllMenus);
|
|
717
|
+
on(els.moreMenu, 'click', (e) => e.stopPropagation());
|
|
718
|
+
// ===== More-menu toggle =====
|
|
719
|
+
on(els.moreToggle, 'click', (e) => {
|
|
720
|
+
e.stopPropagation();
|
|
721
|
+
const opening = els.moreMenu.hidden;
|
|
722
|
+
closeAllMenus();
|
|
723
|
+
if (opening) {
|
|
724
|
+
els.moreMenu.hidden = false;
|
|
725
|
+
placeFlyout(els.moreMenu, 'right', /* clampVertical */ false);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
// ===== Format dropdown =====
|
|
729
|
+
on(els.formatToggle, 'click', (e) => {
|
|
730
|
+
e.stopPropagation();
|
|
731
|
+
const opening = els.formatMenu.hidden;
|
|
732
|
+
closeSubMenus();
|
|
733
|
+
if (opening) {
|
|
734
|
+
els.formatMenu.hidden = false;
|
|
735
|
+
placeFlyout(els.formatMenu, 'left');
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
on(els.formatMenu, 'click', (e) => {
|
|
739
|
+
e.stopPropagation();
|
|
740
|
+
const btn = e.target.closest('.format-opt');
|
|
741
|
+
if (!btn)
|
|
742
|
+
return;
|
|
743
|
+
const value = btn.getAttribute('data-value');
|
|
744
|
+
if (!value)
|
|
745
|
+
return;
|
|
746
|
+
state.export.format = value;
|
|
747
|
+
refreshFormatToggle();
|
|
748
|
+
els.formatMenu.hidden = true;
|
|
749
|
+
});
|
|
750
|
+
function refreshFormatToggle() {
|
|
751
|
+
els.formatToggle.textContent = `${state.export.format.toUpperCase()} \u25be`;
|
|
752
|
+
for (const btn of Array.from(els.formatMenu.querySelectorAll('.format-opt'))) {
|
|
753
|
+
btn.setAttribute('data-active', (btn.getAttribute('data-value') === state.export.format).toString());
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// ===== Copy / export actions =====
|
|
757
|
+
on(els.copyAction, 'click', () => {
|
|
758
|
+
closeAllMenus();
|
|
759
|
+
void handleExportAction(`copy-${state.export.format}`);
|
|
760
|
+
});
|
|
761
|
+
on(els.exportAction, 'click', () => {
|
|
762
|
+
closeAllMenus();
|
|
763
|
+
void handleExportAction(`save-${state.export.format}`);
|
|
764
|
+
});
|
|
765
|
+
async function handleExportAction(action) {
|
|
766
|
+
if (!state.svgString)
|
|
767
|
+
return;
|
|
768
|
+
try {
|
|
769
|
+
if (action === 'save-svg') {
|
|
770
|
+
options.onSave?.({ format: 'svg', body: state.svgString });
|
|
771
|
+
}
|
|
772
|
+
else if (action === 'copy-svg') {
|
|
773
|
+
try {
|
|
774
|
+
await win.navigator.clipboard.writeText(state.svgString);
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
options.onFatal?.(`Copy SVG failed: ${err.message}`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else if (action === 'save-png') {
|
|
781
|
+
const blob = await rasterizePng();
|
|
782
|
+
const buf = await blob.arrayBuffer();
|
|
783
|
+
options.onSave?.({ format: 'png', body: new Uint8Array(buf) });
|
|
784
|
+
}
|
|
785
|
+
else if (action === 'copy-png') {
|
|
786
|
+
const blob = await rasterizePng();
|
|
787
|
+
const ClipboardItemCtor = win.ClipboardItem;
|
|
788
|
+
if (typeof ClipboardItemCtor === 'undefined' || !win.navigator.clipboard?.write) {
|
|
789
|
+
const buf = await blob.arrayBuffer();
|
|
790
|
+
options.onCopyPngFallback?.(new Uint8Array(buf));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
await win.navigator.clipboard.write([
|
|
795
|
+
new ClipboardItemCtor({ 'image/png': blob }),
|
|
796
|
+
]);
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
const buf = await blob.arrayBuffer();
|
|
800
|
+
options.onCopyPngFallback?.(new Uint8Array(buf));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
options.onFatal?.(`PNG render failed: ${err.message}`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
function rasterizePng() {
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
if (!state.svgString) {
|
|
811
|
+
reject(new Error('No diagram to rasterize.'));
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const blob = new Blob([state.svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
815
|
+
const url = win.URL.createObjectURL(blob);
|
|
816
|
+
const img = new win.Image();
|
|
817
|
+
img.onload = () => {
|
|
818
|
+
try {
|
|
819
|
+
const dpr = win.devicePixelRatio || 1;
|
|
820
|
+
const w = state.naturalWidth || img.naturalWidth || 800;
|
|
821
|
+
const h = state.naturalHeight || img.naturalHeight || 600;
|
|
822
|
+
const canvas = doc.createElement('canvas');
|
|
823
|
+
canvas.width = Math.max(1, Math.round(w * dpr));
|
|
824
|
+
canvas.height = Math.max(1, Math.round(h * dpr));
|
|
825
|
+
const ctx = canvas.getContext('2d');
|
|
826
|
+
if (!ctx)
|
|
827
|
+
throw new Error('2D canvas context unavailable.');
|
|
828
|
+
ctx.scale(dpr, dpr);
|
|
829
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
830
|
+
win.URL.revokeObjectURL(url);
|
|
831
|
+
canvas.toBlob((out) => {
|
|
832
|
+
if (out)
|
|
833
|
+
resolve(out);
|
|
834
|
+
else
|
|
835
|
+
reject(new Error('canvas.toBlob returned null.'));
|
|
836
|
+
}, 'image/png');
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
win.URL.revokeObjectURL(url);
|
|
840
|
+
reject(err);
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
img.onerror = () => {
|
|
844
|
+
win.URL.revokeObjectURL(url);
|
|
845
|
+
reject(new Error('Failed to load SVG into <img> for rasterization.'));
|
|
846
|
+
};
|
|
847
|
+
img.src = url;
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
// ===== Theme dropdown =====
|
|
851
|
+
function buildThemeMenu(themes) {
|
|
852
|
+
els.themeMenu.innerHTML = '';
|
|
853
|
+
const autoLi = doc.createElement('li');
|
|
854
|
+
const autoBtn = doc.createElement('button');
|
|
855
|
+
autoBtn.className = 'btn theme-opt';
|
|
856
|
+
autoBtn.setAttribute('data-value', 'auto');
|
|
857
|
+
autoBtn.textContent = 'Auto';
|
|
858
|
+
autoLi.appendChild(autoBtn);
|
|
859
|
+
els.themeMenu.appendChild(autoLi);
|
|
860
|
+
for (const theme of themes) {
|
|
861
|
+
const li = doc.createElement('li');
|
|
862
|
+
const btn = doc.createElement('button');
|
|
863
|
+
btn.className = 'btn theme-opt';
|
|
864
|
+
btn.setAttribute('data-value', theme);
|
|
865
|
+
const chip = doc.createElement('code');
|
|
866
|
+
chip.className = 'code-chip';
|
|
867
|
+
chip.textContent = theme;
|
|
868
|
+
btn.appendChild(chip);
|
|
869
|
+
li.appendChild(btn);
|
|
870
|
+
els.themeMenu.appendChild(li);
|
|
871
|
+
}
|
|
872
|
+
refreshThemeToggle();
|
|
873
|
+
}
|
|
874
|
+
on(els.themeToggle, 'click', (e) => {
|
|
875
|
+
e.stopPropagation();
|
|
876
|
+
const opening = els.themeMenu.hidden;
|
|
877
|
+
closeSubMenus();
|
|
878
|
+
if (opening) {
|
|
879
|
+
els.themeMenu.hidden = false;
|
|
880
|
+
placeFlyout(els.themeMenu, 'left');
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
on(els.themeMenu, 'click', (e) => {
|
|
884
|
+
e.stopPropagation();
|
|
885
|
+
const btn = e.target.closest('.theme-opt');
|
|
886
|
+
if (!btn)
|
|
887
|
+
return;
|
|
888
|
+
const value = btn.getAttribute('data-value');
|
|
889
|
+
if (!value)
|
|
890
|
+
return;
|
|
891
|
+
state.view.theme = value;
|
|
892
|
+
state.view.overridden.theme = true;
|
|
893
|
+
refreshThemeToggle();
|
|
894
|
+
els.themeMenu.hidden = true;
|
|
895
|
+
postViewOverrides();
|
|
896
|
+
});
|
|
897
|
+
function refreshThemeToggle() {
|
|
898
|
+
if (state.view.theme === 'auto') {
|
|
899
|
+
els.themeToggle.textContent = `Auto \u25be`;
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
els.themeToggle.innerHTML = '';
|
|
903
|
+
const chip = doc.createElement('code');
|
|
904
|
+
chip.className = 'code-chip';
|
|
905
|
+
chip.textContent = state.view.theme;
|
|
906
|
+
els.themeToggle.appendChild(chip);
|
|
907
|
+
els.themeToggle.appendChild(doc.createTextNode(` \u25be`));
|
|
908
|
+
}
|
|
909
|
+
for (const btn of Array.from(els.themeMenu.querySelectorAll('.theme-opt'))) {
|
|
910
|
+
btn.setAttribute('data-active', (btn.getAttribute('data-value') === state.view.theme).toString());
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// ===== Now picker (calendar) =====
|
|
914
|
+
let calendarYear = new Date().getFullYear();
|
|
915
|
+
let calendarMonth = new Date().getMonth();
|
|
916
|
+
function buildCalendar() {
|
|
917
|
+
const picker = els.nowPicker;
|
|
918
|
+
picker.innerHTML = '';
|
|
919
|
+
const year = calendarYear;
|
|
920
|
+
const month = calendarMonth;
|
|
921
|
+
const nav = doc.createElement('div');
|
|
922
|
+
nav.className = 'cal-nav';
|
|
923
|
+
const prevBtn = doc.createElement('button');
|
|
924
|
+
prevBtn.className = 'btn cal-nav-btn';
|
|
925
|
+
prevBtn.textContent = '\u2039';
|
|
926
|
+
prevBtn.addEventListener('click', (e) => {
|
|
927
|
+
e.stopPropagation();
|
|
928
|
+
calendarMonth--;
|
|
929
|
+
if (calendarMonth < 0) {
|
|
930
|
+
calendarMonth = 11;
|
|
931
|
+
calendarYear--;
|
|
932
|
+
}
|
|
933
|
+
buildCalendar();
|
|
934
|
+
});
|
|
935
|
+
const heading = doc.createElement('span');
|
|
936
|
+
heading.className = 'cal-heading';
|
|
937
|
+
heading.textContent = new Intl.DateTimeFormat(state.locale, {
|
|
938
|
+
month: 'long',
|
|
939
|
+
year: 'numeric',
|
|
940
|
+
}).format(new Date(year, month, 1));
|
|
941
|
+
const nextBtn = doc.createElement('button');
|
|
942
|
+
nextBtn.className = 'btn cal-nav-btn';
|
|
943
|
+
nextBtn.textContent = '\u203a';
|
|
944
|
+
nextBtn.addEventListener('click', (e) => {
|
|
945
|
+
e.stopPropagation();
|
|
946
|
+
calendarMonth++;
|
|
947
|
+
if (calendarMonth > 11) {
|
|
948
|
+
calendarMonth = 0;
|
|
949
|
+
calendarYear++;
|
|
950
|
+
}
|
|
951
|
+
buildCalendar();
|
|
952
|
+
});
|
|
953
|
+
nav.appendChild(prevBtn);
|
|
954
|
+
nav.appendChild(heading);
|
|
955
|
+
nav.appendChild(nextBtn);
|
|
956
|
+
picker.appendChild(nav);
|
|
957
|
+
const grid = doc.createElement('div');
|
|
958
|
+
grid.className = 'cal-grid';
|
|
959
|
+
for (const d of ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']) {
|
|
960
|
+
const cell = doc.createElement('span');
|
|
961
|
+
cell.className = 'cal-dow';
|
|
962
|
+
cell.textContent = d;
|
|
963
|
+
grid.appendChild(cell);
|
|
964
|
+
}
|
|
965
|
+
const firstDow = new Date(year, month, 1).getDay();
|
|
966
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
967
|
+
for (let i = 0; i < firstDow; i++) {
|
|
968
|
+
const empty = doc.createElement('span');
|
|
969
|
+
empty.className = 'cal-empty';
|
|
970
|
+
grid.appendChild(empty);
|
|
971
|
+
}
|
|
972
|
+
const now = new Date();
|
|
973
|
+
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
974
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
975
|
+
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
976
|
+
const btn = doc.createElement('button');
|
|
977
|
+
btn.className = 'btn cal-day';
|
|
978
|
+
btn.textContent = String(d);
|
|
979
|
+
if (dateStr === todayStr)
|
|
980
|
+
btn.classList.add('is-today');
|
|
981
|
+
if (state.view.now === dateStr)
|
|
982
|
+
btn.classList.add('is-selected');
|
|
983
|
+
btn.addEventListener('click', (e) => {
|
|
984
|
+
e.stopPropagation();
|
|
985
|
+
applyNow(dateStr);
|
|
986
|
+
});
|
|
987
|
+
grid.appendChild(btn);
|
|
988
|
+
}
|
|
989
|
+
picker.appendChild(grid);
|
|
990
|
+
const footer = doc.createElement('div');
|
|
991
|
+
footer.className = 'cal-footer';
|
|
992
|
+
const todayBtnEl = doc.createElement('button');
|
|
993
|
+
todayBtnEl.className = 'btn cal-footer-btn';
|
|
994
|
+
todayBtnEl.textContent = 'Today';
|
|
995
|
+
todayBtnEl.addEventListener('click', (e) => {
|
|
996
|
+
e.stopPropagation();
|
|
997
|
+
applyNow('today');
|
|
998
|
+
});
|
|
999
|
+
const noneBtnEl = doc.createElement('button');
|
|
1000
|
+
noneBtnEl.className = 'btn cal-footer-btn';
|
|
1001
|
+
noneBtnEl.textContent = 'None';
|
|
1002
|
+
noneBtnEl.addEventListener('click', (e) => {
|
|
1003
|
+
e.stopPropagation();
|
|
1004
|
+
applyNow('hide');
|
|
1005
|
+
});
|
|
1006
|
+
footer.appendChild(todayBtnEl);
|
|
1007
|
+
footer.appendChild(noneBtnEl);
|
|
1008
|
+
picker.appendChild(footer);
|
|
1009
|
+
}
|
|
1010
|
+
function applyNow(value) {
|
|
1011
|
+
state.view.now = value;
|
|
1012
|
+
state.view.overridden.now = true;
|
|
1013
|
+
refreshNowToggle();
|
|
1014
|
+
els.nowPicker.hidden = true;
|
|
1015
|
+
postViewOverrides();
|
|
1016
|
+
}
|
|
1017
|
+
on(els.nowToggle, 'click', (e) => {
|
|
1018
|
+
e.stopPropagation();
|
|
1019
|
+
const opening = els.nowPicker.hidden;
|
|
1020
|
+
closeSubMenus();
|
|
1021
|
+
if (opening) {
|
|
1022
|
+
buildCalendar();
|
|
1023
|
+
els.nowPicker.hidden = false;
|
|
1024
|
+
placeFlyout(els.nowPicker, 'left');
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
function refreshNowToggle() {
|
|
1028
|
+
let label;
|
|
1029
|
+
if (state.view.now === 'today') {
|
|
1030
|
+
label = 'Today';
|
|
1031
|
+
}
|
|
1032
|
+
else if (state.view.now === 'hide') {
|
|
1033
|
+
label = 'None';
|
|
1034
|
+
}
|
|
1035
|
+
else {
|
|
1036
|
+
try {
|
|
1037
|
+
label = new Intl.DateTimeFormat(state.locale, { dateStyle: 'medium' }).format(new Date(`${state.view.now}T00:00:00`));
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
label = state.view.now;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
els.nowLabel.textContent = label;
|
|
1044
|
+
}
|
|
1045
|
+
// ===== Show-links dropdown =====
|
|
1046
|
+
on(els.linksToggle, 'click', (e) => {
|
|
1047
|
+
e.stopPropagation();
|
|
1048
|
+
const opening = els.linksMenu.hidden;
|
|
1049
|
+
closeSubMenus();
|
|
1050
|
+
if (opening) {
|
|
1051
|
+
els.linksMenu.hidden = false;
|
|
1052
|
+
placeFlyout(els.linksMenu, 'left');
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
on(els.linksMenu, 'click', (e) => {
|
|
1056
|
+
e.stopPropagation();
|
|
1057
|
+
const btn = e.target.closest('.links-opt');
|
|
1058
|
+
if (!btn)
|
|
1059
|
+
return;
|
|
1060
|
+
const value = btn.getAttribute('data-value');
|
|
1061
|
+
if (value === null)
|
|
1062
|
+
return;
|
|
1063
|
+
state.view.showLinks = value === 'true';
|
|
1064
|
+
state.view.overridden.showLinks = true;
|
|
1065
|
+
refreshLinksToggle();
|
|
1066
|
+
els.linksMenu.hidden = true;
|
|
1067
|
+
postViewOverrides();
|
|
1068
|
+
});
|
|
1069
|
+
function refreshLinksToggle() {
|
|
1070
|
+
els.linksToggle.textContent = `${state.view.showLinks ? 'Yes' : 'No'} \u25be`;
|
|
1071
|
+
for (const btn of Array.from(els.linksMenu.querySelectorAll('.links-opt'))) {
|
|
1072
|
+
btn.setAttribute('data-active', (btn.getAttribute('data-value') === (state.view.showLinks ? 'true' : 'false')).toString());
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// ===== View-options helpers =====
|
|
1076
|
+
function postViewOverrides() {
|
|
1077
|
+
const overrides = {};
|
|
1078
|
+
if (state.view.overridden.theme)
|
|
1079
|
+
overrides.theme = state.view.theme;
|
|
1080
|
+
if (state.view.overridden.now)
|
|
1081
|
+
overrides.now = state.view.now;
|
|
1082
|
+
if (state.view.overridden.showLinks)
|
|
1083
|
+
overrides.showLinks = state.view.showLinks;
|
|
1084
|
+
options.onViewOptions?.(overrides);
|
|
1085
|
+
}
|
|
1086
|
+
function refreshAll() {
|
|
1087
|
+
refreshThemeToggle();
|
|
1088
|
+
refreshNowToggle();
|
|
1089
|
+
refreshLinksToggle();
|
|
1090
|
+
refreshFormatToggle();
|
|
1091
|
+
}
|
|
1092
|
+
// ===== Theme control visibility =====
|
|
1093
|
+
if (options.themeControl === 'hide') {
|
|
1094
|
+
const themeRow = els.themeMenu.closest('.theme-control-row');
|
|
1095
|
+
if (themeRow)
|
|
1096
|
+
themeRow.style.display = 'none';
|
|
1097
|
+
}
|
|
1098
|
+
// ===== Apply initial theme menu + baselines =====
|
|
1099
|
+
buildThemeMenu(options.availableThemes ?? ['light', 'dark', 'grayscale']);
|
|
1100
|
+
if (options.viewBaseline) {
|
|
1101
|
+
applyBaseline(state, options.viewBaseline, true);
|
|
1102
|
+
}
|
|
1103
|
+
refreshAll();
|
|
1104
|
+
updateMinimapVisibility();
|
|
1105
|
+
// Anchor the toolbar explicitly (default: upper-right) so resize
|
|
1106
|
+
// shifts it left rather than the CSS `right` anchor squishing it.
|
|
1107
|
+
repositionChrome();
|
|
1108
|
+
// ===== Imperative API =====
|
|
1109
|
+
return {
|
|
1110
|
+
setSvg,
|
|
1111
|
+
setDiagnostics: showDiagnosticsMode,
|
|
1112
|
+
setFatal: showFatal,
|
|
1113
|
+
setViewBaseline(baseline, resetOverrides = false) {
|
|
1114
|
+
applyBaseline(state, baseline, resetOverrides);
|
|
1115
|
+
refreshAll();
|
|
1116
|
+
},
|
|
1117
|
+
setDefaultFit(fit) {
|
|
1118
|
+
state.defaultFit = fit;
|
|
1119
|
+
},
|
|
1120
|
+
setShowMinimap(show) {
|
|
1121
|
+
state.showMinimap = show;
|
|
1122
|
+
state.minimapDismissedThisSession = false;
|
|
1123
|
+
updateMinimapVisibility();
|
|
1124
|
+
},
|
|
1125
|
+
setMode(mode) {
|
|
1126
|
+
const resolved = mode === 'system' ? resolveSystemMode() : mode;
|
|
1127
|
+
applyMode(resolved);
|
|
1128
|
+
},
|
|
1129
|
+
setAvailableThemes(themes) {
|
|
1130
|
+
buildThemeMenu(themes);
|
|
1131
|
+
},
|
|
1132
|
+
setLocale(locale) {
|
|
1133
|
+
state.locale = locale;
|
|
1134
|
+
refreshNowToggle();
|
|
1135
|
+
},
|
|
1136
|
+
fitPage,
|
|
1137
|
+
fitWidth,
|
|
1138
|
+
actualSize,
|
|
1139
|
+
getZoom() {
|
|
1140
|
+
return state.scale;
|
|
1141
|
+
},
|
|
1142
|
+
setZoom(scale) {
|
|
1143
|
+
state.activeFit = 'manual';
|
|
1144
|
+
state.isDirty = true;
|
|
1145
|
+
setScale(scale);
|
|
1146
|
+
},
|
|
1147
|
+
dispose() {
|
|
1148
|
+
if (fadeTimer)
|
|
1149
|
+
clearTimeout(fadeTimer);
|
|
1150
|
+
if (resizeTimer)
|
|
1151
|
+
clearTimeout(resizeTimer);
|
|
1152
|
+
for (const c of cleanups)
|
|
1153
|
+
c();
|
|
1154
|
+
cleanups.length = 0;
|
|
1155
|
+
rootEl.classList.remove('nl-preview-root');
|
|
1156
|
+
rootEl.removeAttribute('data-nl-mode');
|
|
1157
|
+
rootEl.innerHTML = '';
|
|
1158
|
+
},
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
function applyBaseline(state, baseline, resetOverrides) {
|
|
1162
|
+
if (baseline.theme && !state.view.overridden.theme) {
|
|
1163
|
+
state.view.theme = baseline.theme;
|
|
1164
|
+
}
|
|
1165
|
+
if (baseline.now !== undefined && !state.view.overridden.now) {
|
|
1166
|
+
if (baseline.now === 'none' || baseline.now === 'hide') {
|
|
1167
|
+
state.view.now = 'hide';
|
|
1168
|
+
}
|
|
1169
|
+
else if (typeof baseline.now === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(baseline.now)) {
|
|
1170
|
+
state.view.now = baseline.now;
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
state.view.now = 'today';
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
|
|
1177
|
+
state.view.showLinks = baseline.showLinks !== false;
|
|
1178
|
+
}
|
|
1179
|
+
if (resetOverrides) {
|
|
1180
|
+
state.view.overridden = { theme: false, now: false, showLinks: false };
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Test-only helper. Clears the module-level latch that prevents the
|
|
1185
|
+
* stylesheet from being mounted twice. Vitest reuses module instances
|
|
1186
|
+
* across tests, so without this every test after the first one would
|
|
1187
|
+
* skip writing the stylesheet into the test document.
|
|
1188
|
+
*/
|
|
1189
|
+
export function __resetPreviewShellStylesheetForTests() {
|
|
1190
|
+
stylesheetMounted = false;
|
|
1191
|
+
}
|
|
1192
|
+
//# sourceMappingURL=mount.js.map
|