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