@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/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 +28 -0
- package/dist/markup.d.ts.map +1 -0
- package/dist/markup.js +99 -0
- package/dist/markup.js.map +1 -0
- package/dist/mount.d.ts +95 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +695 -0
- package/dist/mount.js.map +1 -0
- package/dist/styles.d.ts +16 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +292 -0
- package/dist/styles.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +23 -0
- package/src/markup.ts +129 -0
- package/src/mount.ts +814 -0
- package/src/styles.ts +293 -0
package/dist/mount.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
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
|
+
* Ensure the shared `<style>` block is in the document `<head>`. Safe
|
|
16
|
+
* to call repeatedly — only the first call writes. The stylesheet
|
|
17
|
+
* scopes every selector under `.nl-preview-root` so multiple shells
|
|
18
|
+
* can coexist with workbench / app styles.
|
|
19
|
+
*/
|
|
20
|
+
function ensureStylesheet(doc) {
|
|
21
|
+
if (stylesheetMounted)
|
|
22
|
+
return;
|
|
23
|
+
const style = doc.createElement('style');
|
|
24
|
+
style.setAttribute('data-nl-preview-shell', '');
|
|
25
|
+
style.textContent = PREVIEW_SHELL_CSS;
|
|
26
|
+
doc.head?.appendChild(style);
|
|
27
|
+
stylesheetMounted = true;
|
|
28
|
+
}
|
|
29
|
+
export function mountPreview(rootEl, options = {}) {
|
|
30
|
+
ensureStylesheet(rootEl.ownerDocument);
|
|
31
|
+
const els = buildViewport(rootEl);
|
|
32
|
+
const doc = rootEl.ownerDocument;
|
|
33
|
+
const win = doc.defaultView ?? globalThis;
|
|
34
|
+
const state = {
|
|
35
|
+
svgString: null,
|
|
36
|
+
scale: 1,
|
|
37
|
+
naturalWidth: 0,
|
|
38
|
+
naturalHeight: 0,
|
|
39
|
+
defaultFit: options.initialFit ?? 'fitPage',
|
|
40
|
+
activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
|
|
41
|
+
showMinimap: options.showMinimap !== false,
|
|
42
|
+
minimapDismissedThisSession: false,
|
|
43
|
+
firstRender: true,
|
|
44
|
+
view: {
|
|
45
|
+
theme: 'auto',
|
|
46
|
+
now: 'today',
|
|
47
|
+
showLinks: true,
|
|
48
|
+
overridden: { theme: false, now: false, showLinks: false },
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const cleanups = [];
|
|
52
|
+
function on(target, type, handler, opts) {
|
|
53
|
+
target.addEventListener(type, handler, opts);
|
|
54
|
+
cleanups.push(() => target.removeEventListener(type, handler, opts));
|
|
55
|
+
}
|
|
56
|
+
// ===== Toolbar fade =====
|
|
57
|
+
let fadeTimer = null;
|
|
58
|
+
function showToolbar() {
|
|
59
|
+
els.chrome.classList.remove('faded');
|
|
60
|
+
if (fadeTimer)
|
|
61
|
+
clearTimeout(fadeTimer);
|
|
62
|
+
fadeTimer = setTimeout(() => els.chrome.classList.add('faded'), 2000);
|
|
63
|
+
}
|
|
64
|
+
on(doc, 'mousemove', showToolbar);
|
|
65
|
+
showToolbar();
|
|
66
|
+
// ===== Transform =====
|
|
67
|
+
function applyTransform() {
|
|
68
|
+
const w = Math.max(1, Math.round(state.naturalWidth * state.scale));
|
|
69
|
+
const h = Math.max(1, Math.round(state.naturalHeight * state.scale));
|
|
70
|
+
els.canvas.style.width = `${w}px`;
|
|
71
|
+
els.canvas.style.height = `${h}px`;
|
|
72
|
+
els.zoomReset.textContent = `${Math.round(state.scale * 100)}%`;
|
|
73
|
+
updateMinimapRect();
|
|
74
|
+
updateMinimapVisibility();
|
|
75
|
+
}
|
|
76
|
+
function clampScale(s) {
|
|
77
|
+
if (s < 0.05)
|
|
78
|
+
return 0.05;
|
|
79
|
+
if (s > 20)
|
|
80
|
+
return 20;
|
|
81
|
+
return s;
|
|
82
|
+
}
|
|
83
|
+
function setScale(newScale, anchorX, anchorY) {
|
|
84
|
+
const clamped = clampScale(newScale);
|
|
85
|
+
if (anchorX === undefined || anchorY === undefined || state.scale === 0) {
|
|
86
|
+
state.scale = clamped;
|
|
87
|
+
applyTransform();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const ratio = clamped / state.scale;
|
|
91
|
+
const newScrollX = (els.viewport.scrollLeft + anchorX) * ratio - anchorX;
|
|
92
|
+
const newScrollY = (els.viewport.scrollTop + anchorY) * ratio - anchorY;
|
|
93
|
+
state.scale = clamped;
|
|
94
|
+
applyTransform();
|
|
95
|
+
els.viewport.scrollLeft = newScrollX;
|
|
96
|
+
els.viewport.scrollTop = newScrollY;
|
|
97
|
+
}
|
|
98
|
+
function fitPage() {
|
|
99
|
+
if (!state.naturalWidth || !state.naturalHeight)
|
|
100
|
+
return;
|
|
101
|
+
state.activeFit = 'fitPage';
|
|
102
|
+
const sx = els.viewport.clientWidth / state.naturalWidth;
|
|
103
|
+
const sy = els.viewport.clientHeight / state.naturalHeight;
|
|
104
|
+
setScale(Math.min(sx, sy));
|
|
105
|
+
els.viewport.scrollLeft = 0;
|
|
106
|
+
els.viewport.scrollTop = 0;
|
|
107
|
+
}
|
|
108
|
+
function fitWidth() {
|
|
109
|
+
if (!state.naturalWidth)
|
|
110
|
+
return;
|
|
111
|
+
state.activeFit = 'fitWidth';
|
|
112
|
+
setScale(els.viewport.clientWidth / state.naturalWidth);
|
|
113
|
+
els.viewport.scrollLeft = 0;
|
|
114
|
+
els.viewport.scrollTop = 0;
|
|
115
|
+
}
|
|
116
|
+
function actualSize() {
|
|
117
|
+
state.activeFit = 'manual';
|
|
118
|
+
setScale(1);
|
|
119
|
+
}
|
|
120
|
+
function applyDefaultFit() {
|
|
121
|
+
if (state.defaultFit === 'fitWidth')
|
|
122
|
+
fitWidth();
|
|
123
|
+
else if (state.defaultFit === 'actual')
|
|
124
|
+
actualSize();
|
|
125
|
+
else
|
|
126
|
+
fitPage();
|
|
127
|
+
}
|
|
128
|
+
function reapplyActiveFit() {
|
|
129
|
+
if (state.activeFit === 'fitPage')
|
|
130
|
+
fitPage();
|
|
131
|
+
else if (state.activeFit === 'fitWidth')
|
|
132
|
+
fitWidth();
|
|
133
|
+
}
|
|
134
|
+
// ===== Render handling =====
|
|
135
|
+
function extractNaturalSize(svgEl) {
|
|
136
|
+
let w = 0;
|
|
137
|
+
let h = 0;
|
|
138
|
+
if (svgEl.viewBox?.baseVal?.width) {
|
|
139
|
+
w = svgEl.viewBox.baseVal.width;
|
|
140
|
+
h = svgEl.viewBox.baseVal.height;
|
|
141
|
+
}
|
|
142
|
+
if (!w && svgEl.width?.baseVal?.value) {
|
|
143
|
+
w = svgEl.width.baseVal.value;
|
|
144
|
+
}
|
|
145
|
+
if (!h && svgEl.height?.baseVal?.value) {
|
|
146
|
+
h = svgEl.height.baseVal.value;
|
|
147
|
+
}
|
|
148
|
+
svgEl.removeAttribute('width');
|
|
149
|
+
svgEl.removeAttribute('height');
|
|
150
|
+
if (!svgEl.getAttribute('viewBox') && w && h) {
|
|
151
|
+
svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`);
|
|
152
|
+
}
|
|
153
|
+
svgEl.style.width = '100%';
|
|
154
|
+
svgEl.style.height = '100%';
|
|
155
|
+
state.naturalWidth = w || 800;
|
|
156
|
+
state.naturalHeight = h || 600;
|
|
157
|
+
}
|
|
158
|
+
function setSvg(svgString) {
|
|
159
|
+
state.svgString = svgString;
|
|
160
|
+
els.canvas.innerHTML = svgString;
|
|
161
|
+
const svgEl = els.canvas.querySelector('svg');
|
|
162
|
+
if (!svgEl)
|
|
163
|
+
return;
|
|
164
|
+
extractNaturalSize(svgEl);
|
|
165
|
+
rebuildMinimap(svgString);
|
|
166
|
+
if (state.firstRender) {
|
|
167
|
+
applyDefaultFit();
|
|
168
|
+
state.firstRender = false;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
applyTransform();
|
|
172
|
+
}
|
|
173
|
+
showSvgMode();
|
|
174
|
+
}
|
|
175
|
+
function showSvgMode() {
|
|
176
|
+
els.empty.classList.add('hidden');
|
|
177
|
+
els.canvas.classList.remove('dimmed');
|
|
178
|
+
els.diagnostics.classList.remove('show');
|
|
179
|
+
}
|
|
180
|
+
function showDiagnosticsMode(rows) {
|
|
181
|
+
els.empty.classList.add('hidden');
|
|
182
|
+
if (state.svgString) {
|
|
183
|
+
els.canvas.classList.add('dimmed');
|
|
184
|
+
}
|
|
185
|
+
populateDiagnostics(rows);
|
|
186
|
+
els.diagnostics.classList.add('show');
|
|
187
|
+
}
|
|
188
|
+
function showFatal(message) {
|
|
189
|
+
const fakeRow = {
|
|
190
|
+
severity: 'error',
|
|
191
|
+
code: 'preview-error',
|
|
192
|
+
message,
|
|
193
|
+
file: '',
|
|
194
|
+
line: 1,
|
|
195
|
+
column: 1,
|
|
196
|
+
};
|
|
197
|
+
showDiagnosticsMode([fakeRow]);
|
|
198
|
+
}
|
|
199
|
+
// ===== Diagnostics table =====
|
|
200
|
+
function populateDiagnostics(rows) {
|
|
201
|
+
let errors = 0;
|
|
202
|
+
let warnings = 0;
|
|
203
|
+
for (const r of rows) {
|
|
204
|
+
if (r.severity === 'warning')
|
|
205
|
+
warnings++;
|
|
206
|
+
else
|
|
207
|
+
errors++;
|
|
208
|
+
}
|
|
209
|
+
let summary = '';
|
|
210
|
+
if (errors)
|
|
211
|
+
summary += `${errors}${errors === 1 ? ' error' : ' errors'}`;
|
|
212
|
+
if (errors && warnings)
|
|
213
|
+
summary += ', ';
|
|
214
|
+
if (warnings)
|
|
215
|
+
summary += `${warnings}${warnings === 1 ? ' warning' : ' warnings'}`;
|
|
216
|
+
if (!summary)
|
|
217
|
+
summary = 'No problems';
|
|
218
|
+
els.diagSummary.textContent = summary;
|
|
219
|
+
els.diagSummary.className = 'diag-summary';
|
|
220
|
+
if (!errors && warnings)
|
|
221
|
+
els.diagSummary.classList.add('warn-only');
|
|
222
|
+
if (!errors && !warnings)
|
|
223
|
+
els.diagSummary.classList.add('clean');
|
|
224
|
+
const tbody = els.diagTbody;
|
|
225
|
+
while (tbody.firstChild)
|
|
226
|
+
tbody.removeChild(tbody.firstChild);
|
|
227
|
+
for (const r of rows) {
|
|
228
|
+
const tr = doc.createElement('tr');
|
|
229
|
+
tr.dataset.file = r.file || '';
|
|
230
|
+
tr.dataset.line = String(r.line);
|
|
231
|
+
tr.dataset.column = String(r.column);
|
|
232
|
+
const sevTd = doc.createElement('td');
|
|
233
|
+
sevTd.className = `sev-cell ${r.severity === 'warning' ? 'sev-warning' : 'sev-error'}`;
|
|
234
|
+
sevTd.textContent = r.severity === 'warning' ? '\u26A0' : '!';
|
|
235
|
+
tr.appendChild(sevTd);
|
|
236
|
+
const locTd = doc.createElement('td');
|
|
237
|
+
locTd.className = 'loc-cell';
|
|
238
|
+
locTd.textContent = `Ln ${r.line}, ${r.column}`;
|
|
239
|
+
tr.appendChild(locTd);
|
|
240
|
+
const codeTd = doc.createElement('td');
|
|
241
|
+
codeTd.className = 'code-cell';
|
|
242
|
+
const pill = doc.createElement('span');
|
|
243
|
+
pill.className = 'code-pill';
|
|
244
|
+
pill.textContent = r.code;
|
|
245
|
+
codeTd.appendChild(pill);
|
|
246
|
+
tr.appendChild(codeTd);
|
|
247
|
+
const msgTd = doc.createElement('td');
|
|
248
|
+
msgTd.className = 'msg-cell';
|
|
249
|
+
msgTd.textContent = r.message;
|
|
250
|
+
if (r.suggestion) {
|
|
251
|
+
const sg = doc.createElement('span');
|
|
252
|
+
sg.className = 'suggestion';
|
|
253
|
+
sg.textContent = `Did you mean '${r.suggestion}'?`;
|
|
254
|
+
msgTd.appendChild(sg);
|
|
255
|
+
}
|
|
256
|
+
tr.appendChild(msgTd);
|
|
257
|
+
tbody.appendChild(tr);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
on(els.diagTbody, 'click', (e) => {
|
|
261
|
+
const target = e.target;
|
|
262
|
+
const tr = target?.closest('tr');
|
|
263
|
+
if (!tr)
|
|
264
|
+
return;
|
|
265
|
+
options.onGoto?.({
|
|
266
|
+
file: tr.dataset.file ?? '',
|
|
267
|
+
line: Number.parseInt(tr.dataset.line ?? '1', 10) || 1,
|
|
268
|
+
column: Number.parseInt(tr.dataset.column ?? '1', 10) || 1,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
on(els.openProblems, 'click', (e) => {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
options.onOpenProblems?.();
|
|
274
|
+
});
|
|
275
|
+
// ===== Minimap =====
|
|
276
|
+
function rebuildMinimap(svgString) {
|
|
277
|
+
if (!els.minimapCanvas)
|
|
278
|
+
return;
|
|
279
|
+
els.minimapCanvas.innerHTML = svgString;
|
|
280
|
+
const svgEl = els.minimapCanvas.querySelector('svg');
|
|
281
|
+
if (svgEl) {
|
|
282
|
+
svgEl.removeAttribute('width');
|
|
283
|
+
svgEl.removeAttribute('height');
|
|
284
|
+
if (state.naturalWidth && state.naturalHeight && !svgEl.getAttribute('viewBox')) {
|
|
285
|
+
svgEl.setAttribute('viewBox', `0 0 ${state.naturalWidth} ${state.naturalHeight}`);
|
|
286
|
+
}
|
|
287
|
+
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
288
|
+
}
|
|
289
|
+
const maxW = 160;
|
|
290
|
+
const maxH = 120;
|
|
291
|
+
if (state.naturalWidth && state.naturalHeight) {
|
|
292
|
+
const ratio = state.naturalWidth / state.naturalHeight;
|
|
293
|
+
let w = maxW;
|
|
294
|
+
let h = maxW / ratio;
|
|
295
|
+
if (h > maxH) {
|
|
296
|
+
h = maxH;
|
|
297
|
+
w = maxH * ratio;
|
|
298
|
+
}
|
|
299
|
+
els.minimap.style.width = `${Math.round(w)}px`;
|
|
300
|
+
els.minimapCanvas.style.height = `${Math.round(h)}px`;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function updateMinimapRect() {
|
|
304
|
+
if (els.minimap.classList.contains('hidden'))
|
|
305
|
+
return;
|
|
306
|
+
const totalW = state.naturalWidth * state.scale;
|
|
307
|
+
const totalH = state.naturalHeight * state.scale;
|
|
308
|
+
if (!totalW || !totalH)
|
|
309
|
+
return;
|
|
310
|
+
const miniW = els.minimapCanvas.clientWidth;
|
|
311
|
+
const miniH = els.minimapCanvas.clientHeight;
|
|
312
|
+
const rectW = Math.min(1, els.viewport.clientWidth / totalW) * miniW;
|
|
313
|
+
const rectH = Math.min(1, els.viewport.clientHeight / totalH) * miniH;
|
|
314
|
+
const rectX = (els.viewport.scrollLeft / totalW) * miniW;
|
|
315
|
+
const rectY = (els.viewport.scrollTop / totalH) * miniH;
|
|
316
|
+
els.minimapRect.style.left = `${els.minimapCanvas.offsetLeft + rectX}px`;
|
|
317
|
+
els.minimapRect.style.top = `${els.minimapCanvas.offsetTop + rectY}px`;
|
|
318
|
+
els.minimapRect.style.width = `${rectW}px`;
|
|
319
|
+
els.minimapRect.style.height = `${rectH}px`;
|
|
320
|
+
}
|
|
321
|
+
function updateMinimapVisibility() {
|
|
322
|
+
if (!state.showMinimap || state.minimapDismissedThisSession) {
|
|
323
|
+
els.minimap.classList.add('hidden');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const totalW = state.naturalWidth * state.scale;
|
|
327
|
+
const totalH = state.naturalHeight * state.scale;
|
|
328
|
+
const fits = totalW <= els.viewport.clientWidth + 1 && totalH <= els.viewport.clientHeight + 1;
|
|
329
|
+
if (fits) {
|
|
330
|
+
els.minimap.classList.add('hidden');
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
els.minimap.classList.remove('hidden');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
on(els.minimapClose, 'click', (e) => {
|
|
337
|
+
e.stopPropagation();
|
|
338
|
+
state.minimapDismissedThisSession = true;
|
|
339
|
+
els.minimap.classList.add('hidden');
|
|
340
|
+
});
|
|
341
|
+
function panToMinimapPoint(clientX, clientY) {
|
|
342
|
+
const rect = els.minimapCanvas.getBoundingClientRect();
|
|
343
|
+
const x = (clientX - rect.left) / rect.width;
|
|
344
|
+
const y = (clientY - rect.top) / rect.height;
|
|
345
|
+
const totalW = state.naturalWidth * state.scale;
|
|
346
|
+
const totalH = state.naturalHeight * state.scale;
|
|
347
|
+
els.viewport.scrollLeft = Math.max(0, x * totalW - els.viewport.clientWidth / 2);
|
|
348
|
+
els.viewport.scrollTop = Math.max(0, y * totalH - els.viewport.clientHeight / 2);
|
|
349
|
+
}
|
|
350
|
+
let miniDragging = false;
|
|
351
|
+
on(els.minimapCanvas, 'mousedown', (e) => {
|
|
352
|
+
miniDragging = true;
|
|
353
|
+
panToMinimapPoint(e.clientX, e.clientY);
|
|
354
|
+
e.preventDefault();
|
|
355
|
+
});
|
|
356
|
+
on(doc, 'mousemove', (e) => {
|
|
357
|
+
if (miniDragging)
|
|
358
|
+
panToMinimapPoint(e.clientX, e.clientY);
|
|
359
|
+
});
|
|
360
|
+
on(doc, 'mouseup', () => {
|
|
361
|
+
miniDragging = false;
|
|
362
|
+
});
|
|
363
|
+
on(els.viewport, 'scroll', updateMinimapRect);
|
|
364
|
+
on(win, 'resize', () => {
|
|
365
|
+
reapplyActiveFit();
|
|
366
|
+
updateMinimapRect();
|
|
367
|
+
updateMinimapVisibility();
|
|
368
|
+
});
|
|
369
|
+
// ===== Wheel zoom =====
|
|
370
|
+
on(els.viewport, 'wheel', (e) => {
|
|
371
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
372
|
+
return;
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
state.activeFit = 'manual';
|
|
375
|
+
const rect = els.viewport.getBoundingClientRect();
|
|
376
|
+
const anchorX = e.clientX - rect.left;
|
|
377
|
+
const anchorY = e.clientY - rect.top;
|
|
378
|
+
const factor = Math.exp(-e.deltaY * 0.01);
|
|
379
|
+
setScale(state.scale * factor, anchorX, anchorY);
|
|
380
|
+
}, { passive: false });
|
|
381
|
+
// ===== Spacebar pan + keyboard fit presets =====
|
|
382
|
+
let spaceDown = false;
|
|
383
|
+
let dragOrigin = null;
|
|
384
|
+
function isInputFocus(target) {
|
|
385
|
+
if (!target)
|
|
386
|
+
return false;
|
|
387
|
+
const el = target;
|
|
388
|
+
if (!el.tagName)
|
|
389
|
+
return false;
|
|
390
|
+
const t = el.tagName.toUpperCase();
|
|
391
|
+
return t === 'INPUT' || t === 'TEXTAREA' || el.isContentEditable;
|
|
392
|
+
}
|
|
393
|
+
on(doc, 'keydown', (e) => {
|
|
394
|
+
if (isInputFocus(e.target))
|
|
395
|
+
return;
|
|
396
|
+
if (e.key === ' ' && !spaceDown) {
|
|
397
|
+
spaceDown = true;
|
|
398
|
+
els.viewport.style.cursor = 'grab';
|
|
399
|
+
e.preventDefault();
|
|
400
|
+
}
|
|
401
|
+
else if (e.key === '1') {
|
|
402
|
+
fitPage();
|
|
403
|
+
}
|
|
404
|
+
else if (e.key === '2') {
|
|
405
|
+
actualSize();
|
|
406
|
+
}
|
|
407
|
+
else if (e.key === '3') {
|
|
408
|
+
fitWidth();
|
|
409
|
+
}
|
|
410
|
+
else if (e.key === '0') {
|
|
411
|
+
fitPage();
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
on(doc, 'keyup', (e) => {
|
|
415
|
+
if (e.key === ' ') {
|
|
416
|
+
spaceDown = false;
|
|
417
|
+
els.viewport.style.cursor = '';
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
on(els.viewport, 'mousedown', (e) => {
|
|
421
|
+
if (!spaceDown)
|
|
422
|
+
return;
|
|
423
|
+
dragOrigin = {
|
|
424
|
+
x: e.clientX,
|
|
425
|
+
y: e.clientY,
|
|
426
|
+
sx: els.viewport.scrollLeft,
|
|
427
|
+
sy: els.viewport.scrollTop,
|
|
428
|
+
};
|
|
429
|
+
els.viewport.style.cursor = 'grabbing';
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
});
|
|
432
|
+
on(doc, 'mousemove', (e) => {
|
|
433
|
+
if (!dragOrigin)
|
|
434
|
+
return;
|
|
435
|
+
els.viewport.scrollLeft = dragOrigin.sx - (e.clientX - dragOrigin.x);
|
|
436
|
+
els.viewport.scrollTop = dragOrigin.sy - (e.clientY - dragOrigin.y);
|
|
437
|
+
});
|
|
438
|
+
on(doc, 'mouseup', () => {
|
|
439
|
+
if (dragOrigin) {
|
|
440
|
+
dragOrigin = null;
|
|
441
|
+
els.viewport.style.cursor = spaceDown ? 'grab' : '';
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// ===== Toolbar buttons =====
|
|
445
|
+
on(els.zoomOut, 'click', () => {
|
|
446
|
+
state.activeFit = 'manual';
|
|
447
|
+
setScale(state.scale / 1.1);
|
|
448
|
+
});
|
|
449
|
+
on(els.zoomIn, 'click', () => {
|
|
450
|
+
state.activeFit = 'manual';
|
|
451
|
+
setScale(state.scale * 1.1);
|
|
452
|
+
});
|
|
453
|
+
on(els.zoomReset, 'click', actualSize);
|
|
454
|
+
on(els.fitWidth, 'click', fitWidth);
|
|
455
|
+
on(els.fitPage, 'click', fitPage);
|
|
456
|
+
// ===== Dropdowns =====
|
|
457
|
+
function setupDropdown(toggle, menu) {
|
|
458
|
+
on(toggle, 'click', (e) => {
|
|
459
|
+
e.stopPropagation();
|
|
460
|
+
els.saveMenu.hidden = true;
|
|
461
|
+
els.copyMenu.hidden = true;
|
|
462
|
+
els.viewMenu.hidden = true;
|
|
463
|
+
menu.hidden = !menu.hidden;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
setupDropdown(els.viewToggle, els.viewMenu);
|
|
467
|
+
setupDropdown(els.saveToggle, els.saveMenu);
|
|
468
|
+
setupDropdown(els.copyToggle, els.copyMenu);
|
|
469
|
+
on(doc, 'click', () => {
|
|
470
|
+
els.saveMenu.hidden = true;
|
|
471
|
+
els.copyMenu.hidden = true;
|
|
472
|
+
els.viewMenu.hidden = true;
|
|
473
|
+
});
|
|
474
|
+
for (const m of [els.saveMenu, els.copyMenu, els.viewMenu]) {
|
|
475
|
+
on(m, 'click', (e) => e.stopPropagation());
|
|
476
|
+
}
|
|
477
|
+
// ===== View options =====
|
|
478
|
+
function refreshViewMenu() {
|
|
479
|
+
const items = els.viewMenu.querySelectorAll('.view-opt');
|
|
480
|
+
for (const item of Array.from(items)) {
|
|
481
|
+
const opt = item.getAttribute('data-opt');
|
|
482
|
+
const value = item.getAttribute('data-value');
|
|
483
|
+
let active = false;
|
|
484
|
+
if (opt === 'theme')
|
|
485
|
+
active = state.view.theme === value;
|
|
486
|
+
else if (opt === 'now')
|
|
487
|
+
active = state.view.now === value;
|
|
488
|
+
else if (opt === 'showLinks')
|
|
489
|
+
active = state.view.showLinks;
|
|
490
|
+
item.setAttribute('data-active', active ? 'true' : 'false');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
function postViewOverrides() {
|
|
494
|
+
const overrides = {};
|
|
495
|
+
if (state.view.overridden.theme)
|
|
496
|
+
overrides.theme = state.view.theme;
|
|
497
|
+
if (state.view.overridden.now)
|
|
498
|
+
overrides.now = state.view.now;
|
|
499
|
+
if (state.view.overridden.showLinks)
|
|
500
|
+
overrides.showLinks = state.view.showLinks;
|
|
501
|
+
options.onViewOptions?.(overrides);
|
|
502
|
+
}
|
|
503
|
+
on(els.viewMenu, 'click', (e) => {
|
|
504
|
+
const target = e.target;
|
|
505
|
+
const btn = target?.closest('.view-opt');
|
|
506
|
+
if (!btn)
|
|
507
|
+
return;
|
|
508
|
+
const opt = btn.getAttribute('data-opt');
|
|
509
|
+
const value = btn.getAttribute('data-value');
|
|
510
|
+
if (opt === 'theme') {
|
|
511
|
+
state.view.theme = value;
|
|
512
|
+
state.view.overridden.theme = true;
|
|
513
|
+
}
|
|
514
|
+
else if (opt === 'now') {
|
|
515
|
+
state.view.now = value;
|
|
516
|
+
state.view.overridden.now = true;
|
|
517
|
+
}
|
|
518
|
+
else if (opt === 'showLinks') {
|
|
519
|
+
state.view.showLinks = !state.view.showLinks;
|
|
520
|
+
state.view.overridden.showLinks = true;
|
|
521
|
+
}
|
|
522
|
+
refreshViewMenu();
|
|
523
|
+
els.viewMenu.hidden = true;
|
|
524
|
+
postViewOverrides();
|
|
525
|
+
});
|
|
526
|
+
// ===== Save / copy actions =====
|
|
527
|
+
const actionButtons = els.root.querySelectorAll('[data-action]');
|
|
528
|
+
for (const btn of Array.from(actionButtons)) {
|
|
529
|
+
on(btn, 'click', () => {
|
|
530
|
+
const action = btn.getAttribute('data-action');
|
|
531
|
+
els.saveMenu.hidden = true;
|
|
532
|
+
els.copyMenu.hidden = true;
|
|
533
|
+
if (action)
|
|
534
|
+
void handleExportAction(action);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
async function handleExportAction(action) {
|
|
538
|
+
if (!state.svgString)
|
|
539
|
+
return;
|
|
540
|
+
try {
|
|
541
|
+
if (action === 'save-svg') {
|
|
542
|
+
options.onSave?.({ format: 'svg', body: state.svgString });
|
|
543
|
+
}
|
|
544
|
+
else if (action === 'copy-svg') {
|
|
545
|
+
try {
|
|
546
|
+
await win.navigator.clipboard.writeText(state.svgString);
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
options.onFatal?.(`Copy SVG failed: ${err.message}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else if (action === 'save-png') {
|
|
553
|
+
const blob = await rasterizePng();
|
|
554
|
+
const buf = await blob.arrayBuffer();
|
|
555
|
+
options.onSave?.({ format: 'png', body: new Uint8Array(buf) });
|
|
556
|
+
}
|
|
557
|
+
else if (action === 'copy-png') {
|
|
558
|
+
const blob = await rasterizePng();
|
|
559
|
+
const ClipboardItemCtor = win.ClipboardItem;
|
|
560
|
+
if (typeof ClipboardItemCtor === 'undefined' || !win.navigator.clipboard?.write) {
|
|
561
|
+
const buf = await blob.arrayBuffer();
|
|
562
|
+
options.onCopyPngFallback?.(new Uint8Array(buf));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
try {
|
|
566
|
+
await win.navigator.clipboard.write([
|
|
567
|
+
new ClipboardItemCtor({ 'image/png': blob }),
|
|
568
|
+
]);
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
const buf = await blob.arrayBuffer();
|
|
572
|
+
options.onCopyPngFallback?.(new Uint8Array(buf));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch (err) {
|
|
577
|
+
options.onFatal?.(`PNG render failed: ${err.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function rasterizePng() {
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
if (!state.svgString) {
|
|
583
|
+
reject(new Error('No diagram to rasterize.'));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const blob = new Blob([state.svgString], { type: 'image/svg+xml;charset=utf-8' });
|
|
587
|
+
const url = win.URL.createObjectURL(blob);
|
|
588
|
+
const img = new win.Image();
|
|
589
|
+
img.onload = () => {
|
|
590
|
+
try {
|
|
591
|
+
const dpr = win.devicePixelRatio || 1;
|
|
592
|
+
const w = state.naturalWidth || img.naturalWidth || 800;
|
|
593
|
+
const h = state.naturalHeight || img.naturalHeight || 600;
|
|
594
|
+
const canvas = doc.createElement('canvas');
|
|
595
|
+
canvas.width = Math.max(1, Math.round(w * dpr));
|
|
596
|
+
canvas.height = Math.max(1, Math.round(h * dpr));
|
|
597
|
+
const ctx = canvas.getContext('2d');
|
|
598
|
+
if (!ctx)
|
|
599
|
+
throw new Error('2D canvas context unavailable.');
|
|
600
|
+
ctx.scale(dpr, dpr);
|
|
601
|
+
ctx.drawImage(img, 0, 0, w, h);
|
|
602
|
+
win.URL.revokeObjectURL(url);
|
|
603
|
+
canvas.toBlob((out) => {
|
|
604
|
+
if (out)
|
|
605
|
+
resolve(out);
|
|
606
|
+
else
|
|
607
|
+
reject(new Error('canvas.toBlob returned null.'));
|
|
608
|
+
}, 'image/png');
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
win.URL.revokeObjectURL(url);
|
|
612
|
+
reject(err);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
img.onerror = () => {
|
|
616
|
+
win.URL.revokeObjectURL(url);
|
|
617
|
+
reject(new Error('Failed to load SVG into <img> for rasterization.'));
|
|
618
|
+
};
|
|
619
|
+
img.src = url;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
// ===== Apply initial baselines =====
|
|
623
|
+
if (options.viewBaseline) {
|
|
624
|
+
applyBaseline(state, options.viewBaseline, true);
|
|
625
|
+
refreshViewMenu();
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
refreshViewMenu();
|
|
629
|
+
}
|
|
630
|
+
updateMinimapVisibility();
|
|
631
|
+
// ===== Imperative API =====
|
|
632
|
+
return {
|
|
633
|
+
setSvg,
|
|
634
|
+
setDiagnostics: showDiagnosticsMode,
|
|
635
|
+
setFatal: showFatal,
|
|
636
|
+
setViewBaseline(baseline, resetOverrides = false) {
|
|
637
|
+
applyBaseline(state, baseline, resetOverrides);
|
|
638
|
+
refreshViewMenu();
|
|
639
|
+
},
|
|
640
|
+
setDefaultFit(fit) {
|
|
641
|
+
state.defaultFit = fit;
|
|
642
|
+
},
|
|
643
|
+
setShowMinimap(show) {
|
|
644
|
+
state.showMinimap = show;
|
|
645
|
+
state.minimapDismissedThisSession = false;
|
|
646
|
+
updateMinimapVisibility();
|
|
647
|
+
},
|
|
648
|
+
fitPage,
|
|
649
|
+
fitWidth,
|
|
650
|
+
actualSize,
|
|
651
|
+
getZoom() {
|
|
652
|
+
return state.scale;
|
|
653
|
+
},
|
|
654
|
+
setZoom(scale) {
|
|
655
|
+
state.activeFit = 'manual';
|
|
656
|
+
setScale(scale);
|
|
657
|
+
},
|
|
658
|
+
dispose() {
|
|
659
|
+
if (fadeTimer)
|
|
660
|
+
clearTimeout(fadeTimer);
|
|
661
|
+
for (const c of cleanups)
|
|
662
|
+
c();
|
|
663
|
+
cleanups.length = 0;
|
|
664
|
+
rootEl.classList.remove('nl-preview-root');
|
|
665
|
+
rootEl.innerHTML = '';
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function applyBaseline(state, baseline, resetOverrides) {
|
|
670
|
+
if (baseline.theme && !state.view.overridden.theme) {
|
|
671
|
+
state.view.theme = baseline.theme;
|
|
672
|
+
}
|
|
673
|
+
if (baseline.now !== undefined && !state.view.overridden.now) {
|
|
674
|
+
// Setting is 'auto'/'none'/YYYY-MM-DD; the toolbar only exposes
|
|
675
|
+
// today/hide, so collapse anything that isn't 'none' to 'today'
|
|
676
|
+
// for the displayed checkmark.
|
|
677
|
+
state.view.now = baseline.now === 'none' ? 'hide' : 'today';
|
|
678
|
+
}
|
|
679
|
+
if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
|
|
680
|
+
state.view.showLinks = baseline.showLinks !== false;
|
|
681
|
+
}
|
|
682
|
+
if (resetOverrides) {
|
|
683
|
+
state.view.overridden = { theme: false, now: false, showLinks: false };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Test-only helper. Clears the module-level latch that prevents the
|
|
688
|
+
* stylesheet from being mounted twice. Vitest reuses module instances
|
|
689
|
+
* across tests, so without this every test after the first one would
|
|
690
|
+
* skip writing the stylesheet into the test document.
|
|
691
|
+
*/
|
|
692
|
+
export function __resetPreviewShellStylesheetForTests() {
|
|
693
|
+
stylesheetMounted = false;
|
|
694
|
+
}
|
|
695
|
+
//# sourceMappingURL=mount.js.map
|