@nowline/preview-shell 0.4.2 → 0.5.1
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 -7
- package/dist/markup.d.ts.map +1 -1
- package/dist/markup.js +72 -42
- 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 +599 -109
- 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 +282 -61
- package/dist/styles.js.map +1 -1
- package/package.json +2 -2
- package/src/markup.ts +109 -49
- package/src/mount.ts +741 -116
- package/src/styles.ts +282 -61
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,19 @@ 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
|
-
minimapDismissedThisSession: boolean;
|
|
117
169
|
firstRender: boolean;
|
|
170
|
+
/** Resolved chrome color scheme — set by mode resolution, never by diagram theme. */
|
|
171
|
+
mode: 'light' | 'dark';
|
|
172
|
+
locale: string;
|
|
173
|
+
export: { format: 'svg' | 'png' };
|
|
118
174
|
view: {
|
|
119
175
|
theme: ThemeOverride;
|
|
120
176
|
now: NowOverride;
|
|
@@ -126,13 +182,23 @@ interface InternalState {
|
|
|
126
182
|
let stylesheetMounted = false;
|
|
127
183
|
|
|
128
184
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* scopes every selector under `.nl-preview-root` so multiple shells
|
|
132
|
-
* can coexist with workbench / app styles.
|
|
185
|
+
* Last chrome position saved after a drag, shared across all shells in
|
|
186
|
+
* this JS session. Persists for the lifetime of the page.
|
|
133
187
|
*/
|
|
188
|
+
let savedChromePosition: { left: number; top: number } | null = null;
|
|
189
|
+
|
|
134
190
|
function ensureStylesheet(doc: Document): void {
|
|
135
191
|
if (stylesheetMounted) return;
|
|
192
|
+
// A CSP-restricted consumer (e.g. the VS Code webview, whose
|
|
193
|
+
// `style-src` is nonce-only with no `'unsafe-inline'`) pre-injects
|
|
194
|
+
// this stylesheet into the host HTML with a nonce, marked by the
|
|
195
|
+
// `data-nl-preview-shell` attribute. A second `<style>` created here
|
|
196
|
+
// would carry no nonce and be refused by the CSP, leaving the
|
|
197
|
+
// toolbar unstyled — so defer to the host-provided copy when present.
|
|
198
|
+
if (doc.querySelector('style[data-nl-preview-shell]')) {
|
|
199
|
+
stylesheetMounted = true;
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
136
202
|
const style = doc.createElement('style');
|
|
137
203
|
style.setAttribute('data-nl-preview-shell', '');
|
|
138
204
|
style.textContent = PREVIEW_SHELL_CSS;
|
|
@@ -157,9 +223,14 @@ export function mountPreview(
|
|
|
157
223
|
naturalHeight: 0,
|
|
158
224
|
defaultFit: options.initialFit ?? 'fitPage',
|
|
159
225
|
activeFit: options.initialFit === 'fitWidth' ? 'fitWidth' : 'fitPage',
|
|
226
|
+
isDirty: false,
|
|
227
|
+
lastViewportWidth: 0,
|
|
228
|
+
lastViewportHeight: 0,
|
|
160
229
|
showMinimap: options.showMinimap !== false,
|
|
161
|
-
minimapDismissedThisSession: false,
|
|
162
230
|
firstRender: true,
|
|
231
|
+
mode: 'dark',
|
|
232
|
+
locale: options.locale ?? (typeof navigator !== 'undefined' ? navigator.language : 'en'),
|
|
233
|
+
export: { format: 'svg' },
|
|
163
234
|
view: {
|
|
164
235
|
theme: 'auto',
|
|
165
236
|
now: 'today',
|
|
@@ -179,15 +250,185 @@ export function mountPreview(
|
|
|
179
250
|
cleanups.push(() => target.removeEventListener(type, handler as EventListener, opts));
|
|
180
251
|
}
|
|
181
252
|
|
|
182
|
-
// =====
|
|
253
|
+
// ===== Mode resolution =====
|
|
254
|
+
function resolveSystemMode(): 'light' | 'dark' {
|
|
255
|
+
const body = doc.body;
|
|
256
|
+
if (body) {
|
|
257
|
+
if (
|
|
258
|
+
body.classList.contains('vscode-dark') ||
|
|
259
|
+
body.classList.contains('vscode-high-contrast-dark')
|
|
260
|
+
)
|
|
261
|
+
return 'dark';
|
|
262
|
+
if (
|
|
263
|
+
body.classList.contains('vscode-light') ||
|
|
264
|
+
body.classList.contains('vscode-high-contrast-light') ||
|
|
265
|
+
body.classList.contains('vscode-high-contrast')
|
|
266
|
+
)
|
|
267
|
+
return 'light';
|
|
268
|
+
}
|
|
269
|
+
if (typeof win.matchMedia === 'function') {
|
|
270
|
+
return win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
271
|
+
}
|
|
272
|
+
return 'dark';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function applyMode(resolved: 'light' | 'dark'): void {
|
|
276
|
+
state.mode = resolved;
|
|
277
|
+
rootEl.setAttribute('data-nl-mode', resolved);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const rawMode = options.mode ?? 'system';
|
|
281
|
+
applyMode(rawMode === 'system' ? resolveSystemMode() : rawMode);
|
|
282
|
+
|
|
283
|
+
if (rawMode === 'system') {
|
|
284
|
+
if (typeof MutationObserver !== 'undefined' && doc.body) {
|
|
285
|
+
const bodyObs = new MutationObserver(() => applyMode(resolveSystemMode()));
|
|
286
|
+
bodyObs.observe(doc.body, { attributes: true, attributeFilter: ['class'] });
|
|
287
|
+
cleanups.push(() => bodyObs.disconnect());
|
|
288
|
+
}
|
|
289
|
+
const mq =
|
|
290
|
+
typeof win.matchMedia === 'function'
|
|
291
|
+
? win.matchMedia('(prefers-color-scheme: dark)')
|
|
292
|
+
: null;
|
|
293
|
+
if (mq) {
|
|
294
|
+
const onMqChange = (): void => {
|
|
295
|
+
const body = doc.body;
|
|
296
|
+
const hasVsCode =
|
|
297
|
+
body &&
|
|
298
|
+
(body.classList.contains('vscode-dark') ||
|
|
299
|
+
body.classList.contains('vscode-light') ||
|
|
300
|
+
body.classList.contains('vscode-high-contrast') ||
|
|
301
|
+
body.classList.contains('vscode-high-contrast-dark') ||
|
|
302
|
+
body.classList.contains('vscode-high-contrast-light'));
|
|
303
|
+
if (!hasVsCode) applyMode(mq.matches ? 'dark' : 'light');
|
|
304
|
+
};
|
|
305
|
+
mq.addEventListener('change', onMqChange);
|
|
306
|
+
cleanups.push(() => mq.removeEventListener('change', onMqChange));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ===== Floating UI fade =====
|
|
183
311
|
let fadeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
184
|
-
|
|
312
|
+
|
|
313
|
+
function showFloatingUI(): void {
|
|
185
314
|
els.chrome.classList.remove('faded');
|
|
315
|
+
els.minimap.classList.remove('faded');
|
|
186
316
|
if (fadeTimer) clearTimeout(fadeTimer);
|
|
187
|
-
fadeTimer = setTimeout(() =>
|
|
317
|
+
fadeTimer = setTimeout(() => {
|
|
318
|
+
els.chrome.classList.add('faded');
|
|
319
|
+
els.minimap.classList.add('faded');
|
|
320
|
+
}, 2000);
|
|
188
321
|
}
|
|
189
|
-
|
|
190
|
-
|
|
322
|
+
|
|
323
|
+
on(doc, 'mousemove', showFloatingUI);
|
|
324
|
+
showFloatingUI();
|
|
325
|
+
|
|
326
|
+
// ===== Collapse / restore =====
|
|
327
|
+
// « collapses the toolbar to a translucent puck (just the drag grip
|
|
328
|
+
// + a » restore arrow); » expands it back to the full toolbar.
|
|
329
|
+
on(els.collapseBtn, 'click', (e: MouseEvent) => {
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
closeAllMenus();
|
|
332
|
+
els.chrome.classList.add('collapsed');
|
|
333
|
+
repositionChrome();
|
|
334
|
+
});
|
|
335
|
+
on(els.restoreBtn, 'click', (e: MouseEvent) => {
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
els.chrome.classList.remove('collapsed');
|
|
338
|
+
repositionChrome();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ===== Toolbar positioning + drag =====
|
|
342
|
+
// The chrome defaults to the upper-right corner and tracks it on
|
|
343
|
+
// resize (`chromePinnedRight`). Once dragged it becomes free-floating
|
|
344
|
+
// and is merely clamped back into view. Either way the position is
|
|
345
|
+
// always expressed as explicit `left`/`top` so a narrowing viewport
|
|
346
|
+
// shifts the toolbar left instead of squishing the row.
|
|
347
|
+
let chromePinnedRight = savedChromePosition === null;
|
|
348
|
+
|
|
349
|
+
let chromeDragStart: {
|
|
350
|
+
pointerX: number;
|
|
351
|
+
pointerY: number;
|
|
352
|
+
chromeLeft: number;
|
|
353
|
+
chromeTop: number;
|
|
354
|
+
} | null = null;
|
|
355
|
+
|
|
356
|
+
function getGutter(): number {
|
|
357
|
+
const val = getComputedStyle(rootEl).getPropertyValue('--nl-preview-gutter').trim();
|
|
358
|
+
return parseInt(val, 10) || 8;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function repositionChrome(): void {
|
|
362
|
+
const g = getGutter();
|
|
363
|
+
const cw = els.chrome.offsetWidth;
|
|
364
|
+
const ch = els.chrome.offsetHeight;
|
|
365
|
+
const maxLeft = Math.max(g, rootEl.clientWidth - cw - g);
|
|
366
|
+
const maxTop = Math.max(g, rootEl.clientHeight - ch - g);
|
|
367
|
+
let left: number;
|
|
368
|
+
let top: number;
|
|
369
|
+
if (chromePinnedRight) {
|
|
370
|
+
left = rootEl.clientWidth - cw - g;
|
|
371
|
+
top = g;
|
|
372
|
+
} else {
|
|
373
|
+
left = parseInt(els.chrome.style.left, 10);
|
|
374
|
+
top = parseInt(els.chrome.style.top, 10);
|
|
375
|
+
if (Number.isNaN(left)) left = savedChromePosition?.left ?? g;
|
|
376
|
+
if (Number.isNaN(top)) top = savedChromePosition?.top ?? g;
|
|
377
|
+
}
|
|
378
|
+
els.chrome.style.right = 'auto';
|
|
379
|
+
els.chrome.style.left = `${Math.max(g, Math.min(left, maxLeft))}px`;
|
|
380
|
+
els.chrome.style.top = `${Math.max(g, Math.min(top, maxTop))}px`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function ensureChromeLeftTopAnchoring(): void {
|
|
384
|
+
if (els.chrome.style.left !== '') return;
|
|
385
|
+
els.chrome.style.right = 'auto';
|
|
386
|
+
els.chrome.style.left = `${els.chrome.offsetLeft}px`;
|
|
387
|
+
els.chrome.style.top = `${els.chrome.offsetTop}px`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
on<PointerEvent>(els.toolbarHandle, 'pointerdown', (e) => {
|
|
391
|
+
e.preventDefault();
|
|
392
|
+
chromePinnedRight = false;
|
|
393
|
+
ensureChromeLeftTopAnchoring();
|
|
394
|
+
chromeDragStart = {
|
|
395
|
+
pointerX: e.clientX,
|
|
396
|
+
pointerY: e.clientY,
|
|
397
|
+
chromeLeft: parseInt(els.chrome.style.left, 10) || 0,
|
|
398
|
+
chromeTop: parseInt(els.chrome.style.top, 10) || 0,
|
|
399
|
+
};
|
|
400
|
+
els.chrome.classList.add('dragging');
|
|
401
|
+
els.toolbarHandle.setPointerCapture(e.pointerId);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
on<PointerEvent>(els.toolbarHandle, 'pointermove', (e) => {
|
|
405
|
+
if (!chromeDragStart) return;
|
|
406
|
+
const dx = e.clientX - chromeDragStart.pointerX;
|
|
407
|
+
const dy = e.clientY - chromeDragStart.pointerY;
|
|
408
|
+
const rawLeft = chromeDragStart.chromeLeft + dx;
|
|
409
|
+
const rawTop = chromeDragStart.chromeTop + dy;
|
|
410
|
+
const g = getGutter();
|
|
411
|
+
const maxLeft = Math.max(g, rootEl.clientWidth - els.chrome.offsetWidth - g);
|
|
412
|
+
const maxTop = Math.max(g, rootEl.clientHeight - els.chrome.offsetHeight - g);
|
|
413
|
+
els.chrome.style.left = `${Math.max(g, Math.min(rawLeft, maxLeft))}px`;
|
|
414
|
+
els.chrome.style.top = `${Math.max(g, Math.min(rawTop, maxTop))}px`;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
function endChromeDrag(): void {
|
|
418
|
+
if (!chromeDragStart) return;
|
|
419
|
+
chromeDragStart = null;
|
|
420
|
+
els.chrome.classList.remove('dragging');
|
|
421
|
+
savedChromePosition = {
|
|
422
|
+
left: parseInt(els.chrome.style.left, 10) || 0,
|
|
423
|
+
top: parseInt(els.chrome.style.top, 10) || 0,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
on<PointerEvent>(els.toolbarHandle, 'pointerup', endChromeDrag);
|
|
428
|
+
on<PointerEvent>(els.toolbarHandle, 'pointercancel', () => {
|
|
429
|
+
chromeDragStart = null;
|
|
430
|
+
els.chrome.classList.remove('dragging');
|
|
431
|
+
});
|
|
191
432
|
|
|
192
433
|
// ===== Transform =====
|
|
193
434
|
function applyTransform(): void {
|
|
@@ -225,6 +466,7 @@ export function mountPreview(
|
|
|
225
466
|
function fitPage(): void {
|
|
226
467
|
if (!state.naturalWidth || !state.naturalHeight) return;
|
|
227
468
|
state.activeFit = 'fitPage';
|
|
469
|
+
state.isDirty = false;
|
|
228
470
|
const sx = els.viewport.clientWidth / state.naturalWidth;
|
|
229
471
|
const sy = els.viewport.clientHeight / state.naturalHeight;
|
|
230
472
|
setScale(Math.min(sx, sy));
|
|
@@ -235,6 +477,7 @@ export function mountPreview(
|
|
|
235
477
|
function fitWidth(): void {
|
|
236
478
|
if (!state.naturalWidth) return;
|
|
237
479
|
state.activeFit = 'fitWidth';
|
|
480
|
+
state.isDirty = false;
|
|
238
481
|
setScale(els.viewport.clientWidth / state.naturalWidth);
|
|
239
482
|
els.viewport.scrollLeft = 0;
|
|
240
483
|
els.viewport.scrollTop = 0;
|
|
@@ -242,6 +485,7 @@ export function mountPreview(
|
|
|
242
485
|
|
|
243
486
|
function actualSize(): void {
|
|
244
487
|
state.activeFit = 'manual';
|
|
488
|
+
state.isDirty = true;
|
|
245
489
|
setScale(1);
|
|
246
490
|
}
|
|
247
491
|
|
|
@@ -252,8 +496,26 @@ export function mountPreview(
|
|
|
252
496
|
}
|
|
253
497
|
|
|
254
498
|
function reapplyActiveFit(): void {
|
|
255
|
-
|
|
256
|
-
|
|
499
|
+
const newW = els.viewport.clientWidth;
|
|
500
|
+
const newH = els.viewport.clientHeight;
|
|
501
|
+
|
|
502
|
+
if (state.isDirty) {
|
|
503
|
+
const oldW = state.lastViewportWidth || newW;
|
|
504
|
+
const oldH = state.lastViewportHeight || newH;
|
|
505
|
+
if (state.scale > 0 && (oldW !== newW || oldH !== newH)) {
|
|
506
|
+
const cx = (els.viewport.scrollLeft + oldW / 2) / state.scale;
|
|
507
|
+
const cy = (els.viewport.scrollTop + oldH / 2) / state.scale;
|
|
508
|
+
els.viewport.scrollLeft = Math.max(0, cx * state.scale - newW / 2);
|
|
509
|
+
els.viewport.scrollTop = Math.max(0, cy * state.scale - newH / 2);
|
|
510
|
+
}
|
|
511
|
+
} else if (state.activeFit === 'fitPage') {
|
|
512
|
+
fitPage();
|
|
513
|
+
} else if (state.activeFit === 'fitWidth') {
|
|
514
|
+
fitWidth();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
state.lastViewportWidth = newW;
|
|
518
|
+
state.lastViewportHeight = newH;
|
|
257
519
|
}
|
|
258
520
|
|
|
259
521
|
// ===== Render handling =====
|
|
@@ -342,6 +604,9 @@ export function mountPreview(
|
|
|
342
604
|
if (!errors && warnings) els.diagSummary.classList.add('warn-only');
|
|
343
605
|
if (!errors && !warnings) els.diagSummary.classList.add('clean');
|
|
344
606
|
|
|
607
|
+
// Hide Open Problems link when there are no issues
|
|
608
|
+
els.openProblems.hidden = errors + warnings === 0;
|
|
609
|
+
|
|
345
610
|
const tbody = els.diagTbody;
|
|
346
611
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
|
347
612
|
|
|
@@ -412,8 +677,8 @@ export function mountPreview(
|
|
|
412
677
|
}
|
|
413
678
|
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
|
414
679
|
}
|
|
415
|
-
const maxW =
|
|
416
|
-
const maxH =
|
|
680
|
+
const maxW = 120;
|
|
681
|
+
const maxH = 90;
|
|
417
682
|
if (state.naturalWidth && state.naturalHeight) {
|
|
418
683
|
const ratio = state.naturalWidth / state.naturalHeight;
|
|
419
684
|
let w = maxW;
|
|
@@ -445,7 +710,7 @@ export function mountPreview(
|
|
|
445
710
|
}
|
|
446
711
|
|
|
447
712
|
function updateMinimapVisibility(): void {
|
|
448
|
-
if (!state.showMinimap
|
|
713
|
+
if (!state.showMinimap) {
|
|
449
714
|
els.minimap.classList.add('hidden');
|
|
450
715
|
return;
|
|
451
716
|
}
|
|
@@ -460,12 +725,6 @@ export function mountPreview(
|
|
|
460
725
|
}
|
|
461
726
|
}
|
|
462
727
|
|
|
463
|
-
on(els.minimapClose, 'click', (e: Event) => {
|
|
464
|
-
e.stopPropagation();
|
|
465
|
-
state.minimapDismissedThisSession = true;
|
|
466
|
-
els.minimap.classList.add('hidden');
|
|
467
|
-
});
|
|
468
|
-
|
|
469
728
|
function panToMinimapPoint(clientX: number, clientY: number): void {
|
|
470
729
|
const rect = els.minimapCanvas.getBoundingClientRect();
|
|
471
730
|
const x = (clientX - rect.left) / rect.width;
|
|
@@ -490,11 +749,26 @@ export function mountPreview(
|
|
|
490
749
|
});
|
|
491
750
|
|
|
492
751
|
on(els.viewport, 'scroll', updateMinimapRect);
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
752
|
+
|
|
753
|
+
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
754
|
+
function handleResize(): void {
|
|
755
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
756
|
+
resizeTimer = setTimeout(() => {
|
|
757
|
+
resizeTimer = null;
|
|
758
|
+
reapplyActiveFit();
|
|
759
|
+
updateMinimapRect();
|
|
760
|
+
updateMinimapVisibility();
|
|
761
|
+
repositionChrome();
|
|
762
|
+
}, 50);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
on(win, 'resize', handleResize);
|
|
766
|
+
|
|
767
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
768
|
+
const ro = new ResizeObserver(handleResize);
|
|
769
|
+
ro.observe(els.viewport);
|
|
770
|
+
cleanups.push(() => ro.disconnect());
|
|
771
|
+
}
|
|
498
772
|
|
|
499
773
|
// ===== Wheel zoom =====
|
|
500
774
|
on(
|
|
@@ -504,6 +778,7 @@ export function mountPreview(
|
|
|
504
778
|
if (!(e.ctrlKey || e.metaKey)) return;
|
|
505
779
|
e.preventDefault();
|
|
506
780
|
state.activeFit = 'manual';
|
|
781
|
+
state.isDirty = true;
|
|
507
782
|
const rect = els.viewport.getBoundingClientRect();
|
|
508
783
|
const anchorX = e.clientX - rect.left;
|
|
509
784
|
const anchorY = e.clientY - rect.top;
|
|
@@ -547,6 +822,7 @@ export function mountPreview(
|
|
|
547
822
|
});
|
|
548
823
|
on(els.viewport, 'mousedown', (e: MouseEvent) => {
|
|
549
824
|
if (!spaceDown) return;
|
|
825
|
+
state.isDirty = true;
|
|
550
826
|
dragOrigin = {
|
|
551
827
|
x: e.clientX,
|
|
552
828
|
y: e.clientY,
|
|
@@ -571,92 +847,140 @@ export function mountPreview(
|
|
|
571
847
|
// ===== Toolbar buttons =====
|
|
572
848
|
on(els.zoomOut, 'click', () => {
|
|
573
849
|
state.activeFit = 'manual';
|
|
850
|
+
state.isDirty = true;
|
|
574
851
|
setScale(state.scale / 1.1);
|
|
575
852
|
});
|
|
576
853
|
on(els.zoomIn, 'click', () => {
|
|
577
854
|
state.activeFit = 'manual';
|
|
855
|
+
state.isDirty = true;
|
|
578
856
|
setScale(state.scale * 1.1);
|
|
579
857
|
});
|
|
580
858
|
on(els.zoomReset, 'click', actualSize);
|
|
581
859
|
on(els.fitWidth, 'click', fitWidth);
|
|
582
860
|
on(els.fitPage, 'click', fitPage);
|
|
583
861
|
|
|
584
|
-
// =====
|
|
585
|
-
function
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
els.viewMenu.hidden = true;
|
|
591
|
-
menu.hidden = !menu.hidden;
|
|
592
|
-
});
|
|
862
|
+
// ===== Menu state helpers =====
|
|
863
|
+
function closeSubMenus(): void {
|
|
864
|
+
els.formatMenu.hidden = true;
|
|
865
|
+
els.themeMenu.hidden = true;
|
|
866
|
+
els.nowPicker.hidden = true;
|
|
867
|
+
els.linksMenu.hidden = true;
|
|
593
868
|
}
|
|
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());
|
|
869
|
+
|
|
870
|
+
function closeAllMenus(): void {
|
|
871
|
+
els.moreMenu.hidden = true;
|
|
872
|
+
closeSubMenus();
|
|
604
873
|
}
|
|
605
874
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
875
|
+
/**
|
|
876
|
+
* Keep a just-opened flyout (more-menu, a sub-dropdown, or the
|
|
877
|
+
* calendar) inside the preview root + gutters. `defaultSide` is the
|
|
878
|
+
* edge the panel is anchored to in CSS; if opening that way would
|
|
879
|
+
* spill past the opposite gutter the panel is flipped, then any
|
|
880
|
+
* residual horizontal overflow is nudged back with a transform and
|
|
881
|
+
* excess height is capped with an internal scroll.
|
|
882
|
+
*
|
|
883
|
+
* `clampVertical` is off for the more-menu so capping its height
|
|
884
|
+
* never clips the calendar that pops out of it.
|
|
885
|
+
*/
|
|
886
|
+
function placeFlyout(
|
|
887
|
+
panel: HTMLElement,
|
|
888
|
+
defaultSide: 'left' | 'right',
|
|
889
|
+
clampVertical = true,
|
|
890
|
+
): void {
|
|
891
|
+
panel.classList.remove('flip');
|
|
892
|
+
panel.style.maxHeight = '';
|
|
893
|
+
panel.style.overflowY = '';
|
|
894
|
+
panel.style.transform = '';
|
|
895
|
+
|
|
896
|
+
const root = rootEl.getBoundingClientRect();
|
|
897
|
+
// Skip when the host hasn't laid out yet (e.g. happy-dom tests).
|
|
898
|
+
if (root.width === 0 && root.height === 0) return;
|
|
899
|
+
const g = getGutter();
|
|
900
|
+
|
|
901
|
+
let box = panel.getBoundingClientRect();
|
|
902
|
+
const fitsWidth = box.width <= root.width - 2 * g;
|
|
903
|
+
if (fitsWidth && defaultSide === 'left' && box.right > root.right - g) {
|
|
904
|
+
panel.classList.add('flip');
|
|
905
|
+
} else if (fitsWidth && defaultSide === 'right' && box.left < root.left + g) {
|
|
906
|
+
panel.classList.add('flip');
|
|
617
907
|
}
|
|
618
|
-
}
|
|
619
908
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (
|
|
623
|
-
if (
|
|
624
|
-
if (
|
|
625
|
-
|
|
909
|
+
box = panel.getBoundingClientRect();
|
|
910
|
+
let dx = 0;
|
|
911
|
+
if (box.right > root.right - g) dx = root.right - g - box.right;
|
|
912
|
+
if (box.left + dx < root.left + g) dx = root.left + g - box.left;
|
|
913
|
+
if (dx !== 0) panel.style.transform = `translateX(${Math.round(dx)}px)`;
|
|
914
|
+
|
|
915
|
+
if (clampVertical) {
|
|
916
|
+
box = panel.getBoundingClientRect();
|
|
917
|
+
const avail = root.bottom - g - box.top;
|
|
918
|
+
if (box.height > avail && avail > 80) {
|
|
919
|
+
panel.style.maxHeight = `${Math.round(avail)}px`;
|
|
920
|
+
panel.style.overflowY = 'auto';
|
|
921
|
+
}
|
|
922
|
+
}
|
|
626
923
|
}
|
|
627
924
|
|
|
628
|
-
on(
|
|
629
|
-
|
|
630
|
-
|
|
925
|
+
on(doc, 'click', closeAllMenus);
|
|
926
|
+
on(els.moreMenu, 'click', (e: Event) => e.stopPropagation());
|
|
927
|
+
|
|
928
|
+
// ===== More-menu toggle =====
|
|
929
|
+
on(els.moreToggle, 'click', (e: MouseEvent) => {
|
|
930
|
+
e.stopPropagation();
|
|
931
|
+
const opening = els.moreMenu.hidden;
|
|
932
|
+
closeAllMenus();
|
|
933
|
+
if (opening) {
|
|
934
|
+
els.moreMenu.hidden = false;
|
|
935
|
+
placeFlyout(els.moreMenu, 'right', /* clampVertical */ false);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// ===== Format dropdown =====
|
|
940
|
+
on(els.formatToggle, 'click', (e: MouseEvent) => {
|
|
941
|
+
e.stopPropagation();
|
|
942
|
+
const opening = els.formatMenu.hidden;
|
|
943
|
+
closeSubMenus();
|
|
944
|
+
if (opening) {
|
|
945
|
+
els.formatMenu.hidden = false;
|
|
946
|
+
placeFlyout(els.formatMenu, 'left');
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
on(els.formatMenu, 'click', (e: Event) => {
|
|
951
|
+
e.stopPropagation();
|
|
952
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.format-opt');
|
|
631
953
|
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();
|
|
954
|
+
const value = btn.getAttribute('data-value') as 'svg' | 'png' | null;
|
|
955
|
+
if (!value) return;
|
|
956
|
+
state.export.format = value;
|
|
957
|
+
refreshFormatToggle();
|
|
958
|
+
els.formatMenu.hidden = true;
|
|
647
959
|
});
|
|
648
960
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
961
|
+
function refreshFormatToggle(): void {
|
|
962
|
+
els.formatToggle.textContent = `${state.export.format.toUpperCase()} \u25be`;
|
|
963
|
+
for (const btn of Array.from(
|
|
964
|
+
els.formatMenu.querySelectorAll<HTMLButtonElement>('.format-opt'),
|
|
965
|
+
)) {
|
|
966
|
+
btn.setAttribute(
|
|
967
|
+
'data-active',
|
|
968
|
+
(btn.getAttribute('data-value') === state.export.format).toString(),
|
|
969
|
+
);
|
|
970
|
+
}
|
|
658
971
|
}
|
|
659
972
|
|
|
973
|
+
// ===== Copy / export actions =====
|
|
974
|
+
on(els.copyAction, 'click', () => {
|
|
975
|
+
closeAllMenus();
|
|
976
|
+
void handleExportAction(`copy-${state.export.format}`);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
on(els.exportAction, 'click', () => {
|
|
980
|
+
closeAllMenus();
|
|
981
|
+
void handleExportAction(`save-${state.export.format}`);
|
|
982
|
+
});
|
|
983
|
+
|
|
660
984
|
async function handleExportAction(action: string): Promise<void> {
|
|
661
985
|
if (!state.svgString) return;
|
|
662
986
|
try {
|
|
@@ -735,14 +1059,299 @@ export function mountPreview(
|
|
|
735
1059
|
});
|
|
736
1060
|
}
|
|
737
1061
|
|
|
738
|
-
// =====
|
|
1062
|
+
// ===== Theme dropdown =====
|
|
1063
|
+
function buildThemeMenu(themes: string[]): void {
|
|
1064
|
+
els.themeMenu.innerHTML = '';
|
|
1065
|
+
|
|
1066
|
+
const autoLi = doc.createElement('li');
|
|
1067
|
+
const autoBtn = doc.createElement('button');
|
|
1068
|
+
autoBtn.className = 'btn theme-opt';
|
|
1069
|
+
autoBtn.setAttribute('data-value', 'auto');
|
|
1070
|
+
autoBtn.textContent = 'Auto';
|
|
1071
|
+
autoLi.appendChild(autoBtn);
|
|
1072
|
+
els.themeMenu.appendChild(autoLi);
|
|
1073
|
+
|
|
1074
|
+
for (const theme of themes) {
|
|
1075
|
+
const li = doc.createElement('li');
|
|
1076
|
+
const btn = doc.createElement('button');
|
|
1077
|
+
btn.className = 'btn theme-opt';
|
|
1078
|
+
btn.setAttribute('data-value', theme);
|
|
1079
|
+
const chip = doc.createElement('code');
|
|
1080
|
+
chip.className = 'code-chip';
|
|
1081
|
+
chip.textContent = theme;
|
|
1082
|
+
btn.appendChild(chip);
|
|
1083
|
+
li.appendChild(btn);
|
|
1084
|
+
els.themeMenu.appendChild(li);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
refreshThemeToggle();
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
on(els.themeToggle, 'click', (e: MouseEvent) => {
|
|
1091
|
+
e.stopPropagation();
|
|
1092
|
+
const opening = els.themeMenu.hidden;
|
|
1093
|
+
closeSubMenus();
|
|
1094
|
+
if (opening) {
|
|
1095
|
+
els.themeMenu.hidden = false;
|
|
1096
|
+
placeFlyout(els.themeMenu, 'left');
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
on(els.themeMenu, 'click', (e: Event) => {
|
|
1101
|
+
e.stopPropagation();
|
|
1102
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.theme-opt');
|
|
1103
|
+
if (!btn) return;
|
|
1104
|
+
const value = btn.getAttribute('data-value');
|
|
1105
|
+
if (!value) return;
|
|
1106
|
+
state.view.theme = value as ThemeOverride;
|
|
1107
|
+
state.view.overridden.theme = true;
|
|
1108
|
+
refreshThemeToggle();
|
|
1109
|
+
els.themeMenu.hidden = true;
|
|
1110
|
+
postViewOverrides();
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
function refreshThemeToggle(): void {
|
|
1114
|
+
if (state.view.theme === 'auto') {
|
|
1115
|
+
els.themeToggle.textContent = `Auto \u25be`;
|
|
1116
|
+
} else {
|
|
1117
|
+
els.themeToggle.innerHTML = '';
|
|
1118
|
+
const chip = doc.createElement('code');
|
|
1119
|
+
chip.className = 'code-chip';
|
|
1120
|
+
chip.textContent = state.view.theme;
|
|
1121
|
+
els.themeToggle.appendChild(chip);
|
|
1122
|
+
els.themeToggle.appendChild(doc.createTextNode(` \u25be`));
|
|
1123
|
+
}
|
|
1124
|
+
for (const btn of Array.from(
|
|
1125
|
+
els.themeMenu.querySelectorAll<HTMLButtonElement>('.theme-opt'),
|
|
1126
|
+
)) {
|
|
1127
|
+
btn.setAttribute(
|
|
1128
|
+
'data-active',
|
|
1129
|
+
(btn.getAttribute('data-value') === state.view.theme).toString(),
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// ===== Now picker (calendar) =====
|
|
1135
|
+
let calendarYear = new Date().getFullYear();
|
|
1136
|
+
let calendarMonth = new Date().getMonth();
|
|
1137
|
+
|
|
1138
|
+
function buildCalendar(): void {
|
|
1139
|
+
const picker = els.nowPicker;
|
|
1140
|
+
picker.innerHTML = '';
|
|
1141
|
+
const year = calendarYear;
|
|
1142
|
+
const month = calendarMonth;
|
|
1143
|
+
|
|
1144
|
+
const nav = doc.createElement('div');
|
|
1145
|
+
nav.className = 'cal-nav';
|
|
1146
|
+
|
|
1147
|
+
const prevBtn = doc.createElement('button');
|
|
1148
|
+
prevBtn.className = 'btn cal-nav-btn';
|
|
1149
|
+
prevBtn.textContent = '\u2039';
|
|
1150
|
+
prevBtn.addEventListener('click', (e) => {
|
|
1151
|
+
e.stopPropagation();
|
|
1152
|
+
calendarMonth--;
|
|
1153
|
+
if (calendarMonth < 0) {
|
|
1154
|
+
calendarMonth = 11;
|
|
1155
|
+
calendarYear--;
|
|
1156
|
+
}
|
|
1157
|
+
buildCalendar();
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
const heading = doc.createElement('span');
|
|
1161
|
+
heading.className = 'cal-heading';
|
|
1162
|
+
heading.textContent = new Intl.DateTimeFormat(state.locale, {
|
|
1163
|
+
month: 'long',
|
|
1164
|
+
year: 'numeric',
|
|
1165
|
+
}).format(new Date(year, month, 1));
|
|
1166
|
+
|
|
1167
|
+
const nextBtn = doc.createElement('button');
|
|
1168
|
+
nextBtn.className = 'btn cal-nav-btn';
|
|
1169
|
+
nextBtn.textContent = '\u203a';
|
|
1170
|
+
nextBtn.addEventListener('click', (e) => {
|
|
1171
|
+
e.stopPropagation();
|
|
1172
|
+
calendarMonth++;
|
|
1173
|
+
if (calendarMonth > 11) {
|
|
1174
|
+
calendarMonth = 0;
|
|
1175
|
+
calendarYear++;
|
|
1176
|
+
}
|
|
1177
|
+
buildCalendar();
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
nav.appendChild(prevBtn);
|
|
1181
|
+
nav.appendChild(heading);
|
|
1182
|
+
nav.appendChild(nextBtn);
|
|
1183
|
+
picker.appendChild(nav);
|
|
1184
|
+
|
|
1185
|
+
const grid = doc.createElement('div');
|
|
1186
|
+
grid.className = 'cal-grid';
|
|
1187
|
+
|
|
1188
|
+
for (const d of ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']) {
|
|
1189
|
+
const cell = doc.createElement('span');
|
|
1190
|
+
cell.className = 'cal-dow';
|
|
1191
|
+
cell.textContent = d;
|
|
1192
|
+
grid.appendChild(cell);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const firstDow = new Date(year, month, 1).getDay();
|
|
1196
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
1197
|
+
|
|
1198
|
+
for (let i = 0; i < firstDow; i++) {
|
|
1199
|
+
const empty = doc.createElement('span');
|
|
1200
|
+
empty.className = 'cal-empty';
|
|
1201
|
+
grid.appendChild(empty);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const now = new Date();
|
|
1205
|
+
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
|
1206
|
+
|
|
1207
|
+
for (let d = 1; d <= daysInMonth; d++) {
|
|
1208
|
+
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
1209
|
+
const btn = doc.createElement('button');
|
|
1210
|
+
btn.className = 'btn cal-day';
|
|
1211
|
+
btn.textContent = String(d);
|
|
1212
|
+
if (dateStr === todayStr) btn.classList.add('is-today');
|
|
1213
|
+
if (state.view.now === dateStr) btn.classList.add('is-selected');
|
|
1214
|
+
btn.addEventListener('click', (e) => {
|
|
1215
|
+
e.stopPropagation();
|
|
1216
|
+
applyNow(dateStr);
|
|
1217
|
+
});
|
|
1218
|
+
grid.appendChild(btn);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
picker.appendChild(grid);
|
|
1222
|
+
|
|
1223
|
+
const footer = doc.createElement('div');
|
|
1224
|
+
footer.className = 'cal-footer';
|
|
1225
|
+
|
|
1226
|
+
const todayBtnEl = doc.createElement('button');
|
|
1227
|
+
todayBtnEl.className = 'btn cal-footer-btn';
|
|
1228
|
+
todayBtnEl.textContent = 'Today';
|
|
1229
|
+
todayBtnEl.addEventListener('click', (e) => {
|
|
1230
|
+
e.stopPropagation();
|
|
1231
|
+
applyNow('today');
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
const noneBtnEl = doc.createElement('button');
|
|
1235
|
+
noneBtnEl.className = 'btn cal-footer-btn';
|
|
1236
|
+
noneBtnEl.textContent = 'None';
|
|
1237
|
+
noneBtnEl.addEventListener('click', (e) => {
|
|
1238
|
+
e.stopPropagation();
|
|
1239
|
+
applyNow('hide');
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
footer.appendChild(todayBtnEl);
|
|
1243
|
+
footer.appendChild(noneBtnEl);
|
|
1244
|
+
picker.appendChild(footer);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function applyNow(value: NowOverride): void {
|
|
1248
|
+
state.view.now = value;
|
|
1249
|
+
state.view.overridden.now = true;
|
|
1250
|
+
refreshNowToggle();
|
|
1251
|
+
els.nowPicker.hidden = true;
|
|
1252
|
+
postViewOverrides();
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
on(els.nowToggle, 'click', (e: MouseEvent) => {
|
|
1256
|
+
e.stopPropagation();
|
|
1257
|
+
const opening = els.nowPicker.hidden;
|
|
1258
|
+
closeSubMenus();
|
|
1259
|
+
if (opening) {
|
|
1260
|
+
buildCalendar();
|
|
1261
|
+
els.nowPicker.hidden = false;
|
|
1262
|
+
placeFlyout(els.nowPicker, 'left');
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
function refreshNowToggle(): void {
|
|
1267
|
+
let label: string;
|
|
1268
|
+
if (state.view.now === 'today') {
|
|
1269
|
+
label = 'Today';
|
|
1270
|
+
} else if (state.view.now === 'hide') {
|
|
1271
|
+
label = 'None';
|
|
1272
|
+
} else {
|
|
1273
|
+
try {
|
|
1274
|
+
label = new Intl.DateTimeFormat(state.locale, { dateStyle: 'medium' }).format(
|
|
1275
|
+
new Date(`${state.view.now}T00:00:00`),
|
|
1276
|
+
);
|
|
1277
|
+
} catch {
|
|
1278
|
+
label = state.view.now;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
els.nowLabel.textContent = label;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// ===== Show-links dropdown =====
|
|
1285
|
+
on(els.linksToggle, 'click', (e: MouseEvent) => {
|
|
1286
|
+
e.stopPropagation();
|
|
1287
|
+
const opening = els.linksMenu.hidden;
|
|
1288
|
+
closeSubMenus();
|
|
1289
|
+
if (opening) {
|
|
1290
|
+
els.linksMenu.hidden = false;
|
|
1291
|
+
placeFlyout(els.linksMenu, 'left');
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
on(els.linksMenu, 'click', (e: Event) => {
|
|
1296
|
+
e.stopPropagation();
|
|
1297
|
+
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.links-opt');
|
|
1298
|
+
if (!btn) return;
|
|
1299
|
+
const value = btn.getAttribute('data-value');
|
|
1300
|
+
if (value === null) return;
|
|
1301
|
+
state.view.showLinks = value === 'true';
|
|
1302
|
+
state.view.overridden.showLinks = true;
|
|
1303
|
+
refreshLinksToggle();
|
|
1304
|
+
els.linksMenu.hidden = true;
|
|
1305
|
+
postViewOverrides();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
function refreshLinksToggle(): void {
|
|
1309
|
+
els.linksToggle.textContent = `${state.view.showLinks ? 'Yes' : 'No'} \u25be`;
|
|
1310
|
+
for (const btn of Array.from(
|
|
1311
|
+
els.linksMenu.querySelectorAll<HTMLButtonElement>('.links-opt'),
|
|
1312
|
+
)) {
|
|
1313
|
+
btn.setAttribute(
|
|
1314
|
+
'data-active',
|
|
1315
|
+
(
|
|
1316
|
+
btn.getAttribute('data-value') === (state.view.showLinks ? 'true' : 'false')
|
|
1317
|
+
).toString(),
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// ===== View-options helpers =====
|
|
1323
|
+
function postViewOverrides(): void {
|
|
1324
|
+
const overrides: ViewOptionsOverrides = {};
|
|
1325
|
+
if (state.view.overridden.theme) overrides.theme = state.view.theme;
|
|
1326
|
+
if (state.view.overridden.now) overrides.now = state.view.now;
|
|
1327
|
+
if (state.view.overridden.showLinks) overrides.showLinks = state.view.showLinks;
|
|
1328
|
+
options.onViewOptions?.(overrides);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function refreshAll(): void {
|
|
1332
|
+
refreshThemeToggle();
|
|
1333
|
+
refreshNowToggle();
|
|
1334
|
+
refreshLinksToggle();
|
|
1335
|
+
refreshFormatToggle();
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// ===== Theme control visibility =====
|
|
1339
|
+
if (options.themeControl === 'hide') {
|
|
1340
|
+
const themeRow = els.themeMenu.closest<HTMLElement>('.theme-control-row');
|
|
1341
|
+
if (themeRow) themeRow.style.display = 'none';
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// ===== Apply initial theme menu + baselines =====
|
|
1345
|
+
buildThemeMenu(options.availableThemes ?? ['light', 'dark', 'grayscale']);
|
|
1346
|
+
|
|
739
1347
|
if (options.viewBaseline) {
|
|
740
1348
|
applyBaseline(state, options.viewBaseline, true);
|
|
741
|
-
refreshViewMenu();
|
|
742
|
-
} else {
|
|
743
|
-
refreshViewMenu();
|
|
744
1349
|
}
|
|
1350
|
+
refreshAll();
|
|
745
1351
|
updateMinimapVisibility();
|
|
1352
|
+
// Anchor the toolbar explicitly (default: upper-right) so resize
|
|
1353
|
+
// shifts it left rather than the CSS `right` anchor squishing it.
|
|
1354
|
+
repositionChrome();
|
|
746
1355
|
|
|
747
1356
|
// ===== Imperative API =====
|
|
748
1357
|
return {
|
|
@@ -751,16 +1360,26 @@ export function mountPreview(
|
|
|
751
1360
|
setFatal: showFatal,
|
|
752
1361
|
setViewBaseline(baseline, resetOverrides = false) {
|
|
753
1362
|
applyBaseline(state, baseline, resetOverrides);
|
|
754
|
-
|
|
1363
|
+
refreshAll();
|
|
755
1364
|
},
|
|
756
1365
|
setDefaultFit(fit) {
|
|
757
1366
|
state.defaultFit = fit;
|
|
758
1367
|
},
|
|
759
1368
|
setShowMinimap(show) {
|
|
760
1369
|
state.showMinimap = show;
|
|
761
|
-
state.minimapDismissedThisSession = false;
|
|
762
1370
|
updateMinimapVisibility();
|
|
763
1371
|
},
|
|
1372
|
+
setMode(mode) {
|
|
1373
|
+
const resolved = mode === 'system' ? resolveSystemMode() : mode;
|
|
1374
|
+
applyMode(resolved);
|
|
1375
|
+
},
|
|
1376
|
+
setAvailableThemes(themes) {
|
|
1377
|
+
buildThemeMenu(themes);
|
|
1378
|
+
},
|
|
1379
|
+
setLocale(locale) {
|
|
1380
|
+
state.locale = locale;
|
|
1381
|
+
refreshNowToggle();
|
|
1382
|
+
},
|
|
764
1383
|
fitPage,
|
|
765
1384
|
fitWidth,
|
|
766
1385
|
actualSize,
|
|
@@ -769,13 +1388,16 @@ export function mountPreview(
|
|
|
769
1388
|
},
|
|
770
1389
|
setZoom(scale) {
|
|
771
1390
|
state.activeFit = 'manual';
|
|
1391
|
+
state.isDirty = true;
|
|
772
1392
|
setScale(scale);
|
|
773
1393
|
},
|
|
774
1394
|
dispose() {
|
|
775
1395
|
if (fadeTimer) clearTimeout(fadeTimer);
|
|
1396
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
776
1397
|
for (const c of cleanups) c();
|
|
777
1398
|
cleanups.length = 0;
|
|
778
1399
|
rootEl.classList.remove('nl-preview-root');
|
|
1400
|
+
rootEl.removeAttribute('data-nl-mode');
|
|
779
1401
|
rootEl.innerHTML = '';
|
|
780
1402
|
},
|
|
781
1403
|
};
|
|
@@ -790,10 +1412,13 @@ function applyBaseline(
|
|
|
790
1412
|
state.view.theme = baseline.theme;
|
|
791
1413
|
}
|
|
792
1414
|
if (baseline.now !== undefined && !state.view.overridden.now) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1415
|
+
if (baseline.now === 'none' || baseline.now === 'hide') {
|
|
1416
|
+
state.view.now = 'hide';
|
|
1417
|
+
} else if (typeof baseline.now === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(baseline.now)) {
|
|
1418
|
+
state.view.now = baseline.now;
|
|
1419
|
+
} else {
|
|
1420
|
+
state.view.now = 'today';
|
|
1421
|
+
}
|
|
797
1422
|
}
|
|
798
1423
|
if (baseline.showLinks !== undefined && !state.view.overridden.showLinks) {
|
|
799
1424
|
state.view.showLinks = baseline.showLinks !== false;
|