@quario/viewer 0.6.0 → 0.7.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/CHANGELOG.md +26 -0
- package/lib/check.js +17 -1
- package/lib/index.d.ts +5 -4
- package/lib/index.js +79 -86
- package/lib/panel.js +11 -4
- package/lib/stage.js +21 -326
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.7.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`fonts` is validated as a host property.** It was the one the viewer never
|
|
15
|
+
checked, so a malformed record reached the target inside the render and came
|
|
16
|
+
back as a render failure about a report that was never at fault. Its shape is
|
|
17
|
+
now checked with the other host properties and named on `fonts` rather than
|
|
18
|
+
on the target's `options.fonts`. A face that will not parse, or a missing
|
|
19
|
+
parser, is still found while the report is measured and remains a render
|
|
20
|
+
failure there.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **A rejected host property reports as `host-option`.** `ViewerErrorKind`
|
|
25
|
+
gains a fourth member, so a host switching exhaustively over the kind needs
|
|
26
|
+
a case for it. A `page`, `zoom`, `filename`, `colorScheme` or `fonts` the
|
|
27
|
+
viewer rejected was reported as `mount-render` or `update-render` — a render
|
|
28
|
+
that was never attempted — and the panel said "Could not render the report",
|
|
29
|
+
which reads as the report being at fault. It now says the viewer is
|
|
30
|
+
misconfigured and that the report was not the problem.
|
|
31
|
+
|
|
32
|
+
A malformed `report` or `targets` still reports as a render failure: those
|
|
33
|
+
are refused where the render begins rather than when the property is written,
|
|
34
|
+
so nothing yet tells them apart from a render that failed.
|
|
35
|
+
|
|
10
36
|
## [0.6.0] - 2026-09-07
|
|
11
37
|
|
|
12
38
|
### Changed
|
package/lib/check.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Pure policy, no DOM: this module is what the Node suite pins.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { pageBox } from "@quario/layout";
|
|
12
|
+
import { checkFonts, pageBox } from "@quario/layout";
|
|
13
13
|
import { STEPS } from "./zoom.js";
|
|
14
14
|
|
|
15
15
|
/** @type {(message: string) => never} */
|
|
@@ -55,6 +55,22 @@ export let geometry = (page) => {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The font mapping from the `fonts` property. Only the shape reaches here: a
|
|
60
|
+
* face that will not parse, or a missing parser, is found while the report is
|
|
61
|
+
* measured and is a render failure there. The layout names the mistake; this
|
|
62
|
+
* names the property, the way `geometry` does.
|
|
63
|
+
*
|
|
64
|
+
* @param {any} fonts
|
|
65
|
+
*/
|
|
66
|
+
export let faces = (fonts) => {
|
|
67
|
+
try {
|
|
68
|
+
checkFonts(fonts, "fonts");
|
|
69
|
+
} catch (error) {
|
|
70
|
+
fail(/** @type {Error} */ (error).message);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
58
74
|
/** @type {(mode: any, floor: number, ceiling: number) => boolean} */
|
|
59
75
|
let inRange = (mode, floor, ceiling) =>
|
|
60
76
|
[Number.isFinite(mode), mode >= floor, mode <= ceiling].every(Boolean);
|
package/lib/index.d.ts
CHANGED
|
@@ -24,16 +24,17 @@ export type ViewerFonts = LayoutFonts;
|
|
|
24
24
|
export type ViewableReport = CompiledReport;
|
|
25
25
|
|
|
26
26
|
/** Which failure an `error` event names. */
|
|
27
|
-
export type ViewerErrorKind = "mount-render" | "update-render" | "export";
|
|
27
|
+
export type ViewerErrorKind = "mount-render" | "update-render" | "export" | "host-option";
|
|
28
28
|
|
|
29
29
|
/** The `error` event's payload. */
|
|
30
30
|
export interface ViewerErrorDetail {
|
|
31
31
|
/** The caught value, exactly as thrown. */
|
|
32
32
|
error: unknown;
|
|
33
33
|
/**
|
|
34
|
-
* Which failure occurred: `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* Which failure occurred: `host-option` for a property the viewer rejected,
|
|
35
|
+
* which reached no render at all; otherwise `mount-render` until a render
|
|
36
|
+
* has ever landed on the sheet, `update-render` after, and `export` for a
|
|
37
|
+
* download that could not be produced.
|
|
37
38
|
*/
|
|
38
39
|
kind: ViewerErrorKind;
|
|
39
40
|
}
|
package/lib/index.js
CHANGED
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { Task, TaskStatus } from "@lit/task";
|
|
28
28
|
import { LitElement, html } from "lit";
|
|
29
|
+
import { Landing, failures, options } from "@quario/landing";
|
|
29
30
|
import { layout } from "@quario/layout";
|
|
30
31
|
import { BUTTON } from "./button.js";
|
|
31
32
|
import { CHROME, progress } from "./chrome.js";
|
|
32
|
-
import { exports, geometry, level, name, scheme } from "./check.js";
|
|
33
|
+
import { exports, faces, geometry, level, name, scheme } from "./check.js";
|
|
33
34
|
import { MENU, zoomMenu } from "./menu.js";
|
|
34
35
|
import { PANEL, label, panel } from "./panel.js";
|
|
35
36
|
import { SURFACE, stage } from "./stage.js";
|
|
@@ -39,12 +40,6 @@ import { wanted } from "./zoom.js";
|
|
|
39
40
|
/** @type {(report: unknown, targets: unknown) => boolean} */
|
|
40
41
|
let vacant = (report, targets) => report === undefined && targets === undefined;
|
|
41
42
|
|
|
42
|
-
/** @type {(error: unknown) => unknown} */
|
|
43
|
-
let said = (error) => {
|
|
44
|
-
// oxlint-disable-next-line typescript/no-base-to-string
|
|
45
|
-
return error && String(error);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
43
|
// The properties are described once, in the hand-written public declarations,
|
|
49
44
|
// and read back here — a second copy in JSDoc is a copy that drifts.
|
|
50
45
|
/** @import { ViewableReport, ViewerFonts, ViewerPage } from './index.d.ts' */
|
|
@@ -89,23 +84,23 @@ export class QuarioViewer extends LitElement {
|
|
|
89
84
|
// fails and a disconnect that does not. The template interpolates its
|
|
90
85
|
// element as a node, which lit-html leaves untouched across re-renders.
|
|
91
86
|
#stage = stage();
|
|
92
|
-
/** Whether a render has ever landed on the sheet — the mount/update boundary. */
|
|
93
|
-
#landed = false;
|
|
94
|
-
/** Whether the sheet stopped matching the properties while disconnected. */
|
|
95
|
-
#stale = false;
|
|
96
87
|
/** @type {"fit" | number} The live zoom mode; the controls move it. */
|
|
97
88
|
#mode = "fit";
|
|
98
89
|
/** @type {{ width: number, height: number, margin: number }} */
|
|
99
90
|
#box = geometry(undefined);
|
|
100
91
|
#name = "report";
|
|
101
|
-
/**
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
|
|
92
|
+
/** The option check across one update cycle: first-failure-wins, the
|
|
93
|
+
* re-validate-everything rule, and the commit gate that keeps a `filename`
|
|
94
|
+
* write from clobbering the mode a reader clicked to. */
|
|
95
|
+
#options = options();
|
|
96
|
+
/** The render boundary — landed, stale, and the epoch that re-runs the task
|
|
97
|
+
* when its real arguments did not change. */
|
|
98
|
+
#landing = new Landing(this, { abort: () => this.#task.abort() });
|
|
99
|
+
/** The panel's model, with this element's own announce policy: every
|
|
100
|
+
* failure is drawn, because the viewer only re-runs when a host property or
|
|
101
|
+
* the epoch moves, so its rate is already bounded. The panel is a state and
|
|
102
|
+
* the error event is the log. */
|
|
103
|
+
#failures = failures();
|
|
109
104
|
/** Re-applies geometry and scale after the update that changed them. */
|
|
110
105
|
#reapply = false;
|
|
111
106
|
/** @type {Set<string>} The exports in flight; their buttons disable. */
|
|
@@ -130,7 +125,7 @@ export class QuarioViewer extends LitElement {
|
|
|
130
125
|
// show" result: unlike the task primitive's own initial-state symbol it
|
|
131
126
|
// settles `taskComplete`, which is what lets `renderComplete` always answer.
|
|
132
127
|
#task = new Task(this, {
|
|
133
|
-
args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#epoch],
|
|
128
|
+
args: () => [this.report, this.targets, this.data, this.page, this.fonts, this.#landing.epoch],
|
|
134
129
|
task: async ([report, targets, data, page, fonts], { signal }) => {
|
|
135
130
|
if (!this.#begin(report, targets)) return null;
|
|
136
131
|
// The sheet's own target: the layout list, on the host's page and
|
|
@@ -141,7 +136,7 @@ export class QuarioViewer extends LitElement {
|
|
|
141
136
|
// The engine takes no signal, so abandonment is the guards around this
|
|
142
137
|
// body; the check only spares the swap when the answer arrives after a
|
|
143
138
|
// disconnect mid-render.
|
|
144
|
-
if (signal
|
|
139
|
+
if (this.#landing.dropped(signal, this.isConnected)) return this.#landing.abandon();
|
|
145
140
|
return { list, fonts };
|
|
146
141
|
},
|
|
147
142
|
onComplete: (result) => {
|
|
@@ -149,14 +144,12 @@ export class QuarioViewer extends LitElement {
|
|
|
149
144
|
// The swap sizes every page before it settles, so the reader's place
|
|
150
145
|
// is held; the paint it awaits is what `rendered` waits for.
|
|
151
146
|
this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
|
|
152
|
-
this.#
|
|
153
|
-
this.#stale = false;
|
|
147
|
+
this.#landing.land();
|
|
154
148
|
// A render that landed on the sheet takes the panel down: the panel
|
|
155
149
|
// says what is wrong with what the reader is looking at, and this is
|
|
156
150
|
// the moment that stops being true. A successful export is not that
|
|
157
151
|
// moment.
|
|
158
|
-
this.#
|
|
159
|
-
this.#dismissed = false;
|
|
152
|
+
this.#failures.clear();
|
|
160
153
|
// After the paint, so the update the task queued has already run.
|
|
161
154
|
this.requestUpdate();
|
|
162
155
|
this.dispatchEvent(new CustomEvent("rendered"));
|
|
@@ -164,7 +157,7 @@ export class QuarioViewer extends LitElement {
|
|
|
164
157
|
},
|
|
165
158
|
onError: (error) => {
|
|
166
159
|
if (!this.isConnected) return;
|
|
167
|
-
this.#announce(error, this.#
|
|
160
|
+
this.#announce(error, this.#kindOf(error));
|
|
168
161
|
},
|
|
169
162
|
});
|
|
170
163
|
|
|
@@ -205,9 +198,9 @@ export class QuarioViewer extends LitElement {
|
|
|
205
198
|
get renderComplete() {
|
|
206
199
|
return this.updateComplete.then(() =>
|
|
207
200
|
this.#task.status === TaskStatus.INITIAL
|
|
208
|
-
? this.#landed
|
|
201
|
+
? this.#landing.landed
|
|
209
202
|
: this.#task.taskComplete.then(
|
|
210
|
-
() => this.#painting.then(() => this.#landed),
|
|
203
|
+
() => this.#painting.then(() => this.#landing.landed),
|
|
211
204
|
() => false,
|
|
212
205
|
),
|
|
213
206
|
);
|
|
@@ -227,7 +220,7 @@ export class QuarioViewer extends LitElement {
|
|
|
227
220
|
this.#checkOptions(changed);
|
|
228
221
|
// A new document is a new subject: dismissing the last failure said
|
|
229
222
|
// nothing about this one.
|
|
230
|
-
if (["data", "report"].some((key) => changed.has(key))) this.#
|
|
223
|
+
if (["data", "report"].some((key) => changed.has(key))) this.#failures.reopen();
|
|
231
224
|
}
|
|
232
225
|
|
|
233
226
|
render() {
|
|
@@ -247,7 +240,7 @@ export class QuarioViewer extends LitElement {
|
|
|
247
240
|
})}
|
|
248
241
|
</div>
|
|
249
242
|
<div class="qv-body">
|
|
250
|
-
${this.#
|
|
243
|
+
${this.#failures.showing ? panel(this.#failures.showing, () => this.#dismiss()) : ""}
|
|
251
244
|
${this.#stage.element}
|
|
252
245
|
</div>
|
|
253
246
|
</div>
|
|
@@ -276,14 +269,12 @@ export class QuarioViewer extends LitElement {
|
|
|
276
269
|
// property write landed while disconnected. The task's arguments did not
|
|
277
270
|
// change, so the epoch is what re-runs it; left alone, reparenting a
|
|
278
271
|
// settled viewer costs nothing.
|
|
279
|
-
this.#wake();
|
|
280
272
|
}
|
|
281
273
|
|
|
282
274
|
disconnectedCallback() {
|
|
283
275
|
super.disconnectedCallback();
|
|
284
276
|
// Abandon in-flight work and release the observer. The properties and the
|
|
285
277
|
// sheet persist: removal is destruction only in the collector's sense.
|
|
286
|
-
this.#task.abort();
|
|
287
278
|
this.#unwatch?.();
|
|
288
279
|
this.#unwatch = undefined;
|
|
289
280
|
}
|
|
@@ -299,27 +290,13 @@ export class QuarioViewer extends LitElement {
|
|
|
299
290
|
* @param {unknown} targets
|
|
300
291
|
*/
|
|
301
292
|
#begin(report, targets) {
|
|
302
|
-
if (this.#invalid) throw this.#invalid;
|
|
293
|
+
if (this.#options.invalid) throw this.#options.invalid;
|
|
303
294
|
if (vacant(report, targets)) return null;
|
|
304
|
-
if (!this.isConnected) return this.#abandon();
|
|
295
|
+
if (!this.isConnected) return this.#landing.abandon();
|
|
305
296
|
exports(report, targets);
|
|
306
297
|
return true;
|
|
307
298
|
}
|
|
308
299
|
|
|
309
|
-
#abandon() {
|
|
310
|
-
this.#stale = true;
|
|
311
|
-
return null;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/** @param {() => void} run */
|
|
315
|
-
#take(run) {
|
|
316
|
-
try {
|
|
317
|
-
run();
|
|
318
|
-
} catch (error) {
|
|
319
|
-
this.#invalid ??= error;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
300
|
/**
|
|
324
301
|
* Each property validates and commits on its own, stashing the first
|
|
325
302
|
* failure: one bad property must not block a good write to another. All
|
|
@@ -336,42 +313,59 @@ export class QuarioViewer extends LitElement {
|
|
|
336
313
|
* @param {Map<string, unknown>} changed
|
|
337
314
|
*/
|
|
338
315
|
#checkOptions(changed) {
|
|
339
|
-
let
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
316
|
+
let moved = this.#options.recheck(this.hasUpdated, changed, [
|
|
317
|
+
(gate) => {
|
|
318
|
+
let box = geometry(this.page);
|
|
319
|
+
if (gate("page")) {
|
|
320
|
+
this.#box = box;
|
|
321
|
+
this.#reapply = true;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
(gate) => {
|
|
325
|
+
// Committed only when the host wrote `zoom`, so re-validating on a
|
|
326
|
+
// filename change cannot clobber the mode the reader clicked to.
|
|
327
|
+
let mode = level(this.zoom);
|
|
328
|
+
if (gate("zoom")) {
|
|
329
|
+
this.#mode = mode;
|
|
330
|
+
this.#reapply = true;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
() => void (this.#name = name(this.filename)),
|
|
334
|
+
// Nothing to commit: the shape is the whole answer, and the faces
|
|
335
|
+
// themselves are loaded inside the render where a bad one is a render
|
|
336
|
+
// failure. Checked here so a malformed record is a mistake named on the
|
|
337
|
+
// property rather than one the target names on `options.fonts`.
|
|
338
|
+
() => faces(this.fonts),
|
|
339
|
+
(gate) => {
|
|
340
|
+
// Same gate as zoom/page: re-validate always, write the CSSOM pin only
|
|
341
|
+
// when this property changed, so a filename write does not dirty
|
|
342
|
+
// inherited color-scheme before layout.
|
|
343
|
+
let used = scheme(this.colorScheme);
|
|
344
|
+
if (gate("colorScheme")) this.style.colorScheme = used;
|
|
345
|
+
},
|
|
346
|
+
]);
|
|
347
|
+
if (moved) this.#landing.epoch++;
|
|
368
348
|
}
|
|
369
349
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Which failure a throw from the task is. Identity, not a tag: `#begin`
|
|
352
|
+
* throws the very value `#checkOptions` stashed, so the one it stashed is
|
|
353
|
+
* the one host mistake this can name for certain — and a property the
|
|
354
|
+
* viewer rejected reached no render at all, so it carries no
|
|
355
|
+
* mount-versus-update distinction.
|
|
356
|
+
*
|
|
357
|
+
* `exports()`'s `report` and `targets` are host mistakes too and still
|
|
358
|
+
* report as a render: they are thrown from `#begin` rather than stashed, so
|
|
359
|
+
* nothing here tells them from a render that failed. Closing that means the
|
|
360
|
+
* task returning failures instead of throwing them, which is the channel
|
|
361
|
+
* quario-70jg.27 reworks — not worth doing twice.
|
|
362
|
+
*
|
|
363
|
+
* @param {unknown} error
|
|
364
|
+
* @returns {import('./index.d.ts').ViewerErrorKind}
|
|
365
|
+
*/
|
|
366
|
+
#kindOf(error) {
|
|
367
|
+
if (error !== undefined && error === this.#options.invalid) return "host-option";
|
|
368
|
+
return this.#landing.landed ? "update-render" : "mount-render";
|
|
375
369
|
}
|
|
376
370
|
|
|
377
371
|
/** @param {() => void} run */
|
|
@@ -408,7 +402,7 @@ export class QuarioViewer extends LitElement {
|
|
|
408
402
|
}
|
|
409
403
|
|
|
410
404
|
#dismiss() {
|
|
411
|
-
this.#
|
|
405
|
+
this.#failures.dismiss();
|
|
412
406
|
this.requestUpdate();
|
|
413
407
|
}
|
|
414
408
|
|
|
@@ -418,12 +412,11 @@ export class QuarioViewer extends LitElement {
|
|
|
418
412
|
* or anything after a disconnect — never come through here.
|
|
419
413
|
*
|
|
420
414
|
* @param {unknown} error The caught value.
|
|
421
|
-
* @param {
|
|
415
|
+
* @param {import('./index.d.ts').ViewerErrorKind} kind
|
|
422
416
|
* @param {string} [format] The export format's name, for that kind alone.
|
|
423
417
|
*/
|
|
424
418
|
#announce(error, kind, format) {
|
|
425
|
-
this.#
|
|
426
|
-
this.#dismissed = false;
|
|
419
|
+
this.#failures.announce(label(kind, format), error);
|
|
427
420
|
this.requestUpdate();
|
|
428
421
|
this.dispatchEvent(new CustomEvent("error", { detail: { error, kind } }));
|
|
429
422
|
}
|
package/lib/panel.js
CHANGED
|
@@ -80,16 +80,23 @@ export let PANEL = css`
|
|
|
80
80
|
* its length: that failure leaves the previous report on the sheet, and
|
|
81
81
|
* silently stale content is the thing the panel exists to prevent.
|
|
82
82
|
*
|
|
83
|
-
* @param {
|
|
83
|
+
* @param {import('./index.d.ts').ViewerErrorKind} kind
|
|
84
84
|
* @param {string} [format] The export format's name, for that kind alone.
|
|
85
85
|
* @returns {string}
|
|
86
86
|
*/
|
|
87
87
|
export let label = (kind, format) =>
|
|
88
88
|
kind === "export"
|
|
89
89
|
? "Could not export " + format
|
|
90
|
-
: kind === "
|
|
91
|
-
?
|
|
92
|
-
|
|
90
|
+
: kind === "host-option"
|
|
91
|
+
? // Named apart because the panel speaks to the reader, and where that
|
|
92
|
+
// reader is a playground's visitor the panel is the product rather
|
|
93
|
+
// than chrome (CONTEXT.md, "Error panel"). Told the report could not
|
|
94
|
+
// be rendered, they read it as their own document failing. A property
|
|
95
|
+
// they cannot see and cannot fix is where that reading is wrong.
|
|
96
|
+
"This viewer is misconfigured — the report itself was not the problem."
|
|
97
|
+
: kind === "update-render"
|
|
98
|
+
? "Could not update the report — showing the previous version"
|
|
99
|
+
: "Could not render the report";
|
|
93
100
|
|
|
94
101
|
/**
|
|
95
102
|
* What an error says. Not every throw is an `Error` — a host's registry
|
package/lib/stage.js
CHANGED
|
@@ -70,17 +70,12 @@
|
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
72
|
import { css } from "lit";
|
|
73
|
-
import {
|
|
73
|
+
import { GAP, GUTTER, sheet } from "@quario/landing";
|
|
74
|
+
import { PX_PER_POINT } from "@quario/layout";
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* `scale()` converts a scroll position through.
|
|
79
|
-
*/
|
|
80
|
-
let GUTTER = 28;
|
|
81
|
-
|
|
82
|
-
/** The gap between two pages, in px. */
|
|
83
|
-
let GAP = 16;
|
|
76
|
+
// `GUTTER` and `GAP` come from `@quario/landing`, which walks the reach over
|
|
77
|
+
// them: the CSS below and the sheet's own arithmetic have to agree about where
|
|
78
|
+
// a page sits, and two copies of that is a reach that paints the wrong pages.
|
|
84
79
|
|
|
85
80
|
export let SURFACE = css`
|
|
86
81
|
/* Where scrollbars take width, the gutter is held whether one is showing or
|
|
@@ -150,319 +145,24 @@ export let stage = () => {
|
|
|
150
145
|
// inside the viewer. The `qv-*` names are documented as stable, so the
|
|
151
146
|
// narrower one keeps its name.
|
|
152
147
|
wrapper.className = "qv-stage";
|
|
153
|
-
let
|
|
154
|
-
|
|
155
|
-
wrapper.append(
|
|
148
|
+
let sheetEl = document.createElement("div");
|
|
149
|
+
sheetEl.className = "qv-sheet";
|
|
150
|
+
wrapper.append(sheetEl);
|
|
156
151
|
scroll.append(wrapper);
|
|
157
152
|
|
|
158
153
|
/** The page width to fit against before a list arrives, in points. */
|
|
159
154
|
let fallback = 0;
|
|
160
|
-
/** @type {any} */
|
|
161
|
-
let list = null;
|
|
162
|
-
/** @type {any} */
|
|
163
|
-
let fonts;
|
|
164
155
|
/** The percentage currently on screen. */
|
|
165
156
|
let applied = 100;
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* What each backed page is carrying: the scale it was painted at, so a page
|
|
169
|
-
* re-entering the reach at that scale is not repainted and a scale change
|
|
170
|
-
* invalidates it, and the paint itself, so whoever asks second waits behind
|
|
171
|
-
* the paint the first caller started rather than being told it is done.
|
|
172
|
-
* Keyed by the canvas, so a swap's new pages start with nothing remembered
|
|
173
|
-
* and the pages it replaced take their entries with them — which is why the
|
|
174
|
-
* map is weak: nothing has to remember to forget them.
|
|
175
|
-
*
|
|
176
|
-
* @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
|
|
177
|
-
*/
|
|
178
|
-
let backed = new WeakMap();
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* The canvases whose paint has finished. A promise's settled state is not
|
|
182
|
-
* synchronously observable and this is the one question the rule below
|
|
183
|
-
* turns on: a canvas in here has nothing left that could draw into it, so
|
|
184
|
-
* it can be emptied or re-sized in place; one that is backed but absent
|
|
185
|
-
* here is still being painted, and must be retired instead. Weak on the
|
|
186
|
-
* same key as `backed`, so a retired canvas takes its membership with it.
|
|
187
|
-
*
|
|
188
|
-
* Membership is per paint, not per canvas — `start` takes a canvas out
|
|
189
|
-
* before painting it again, or a page settled at one scale would count as
|
|
190
|
-
* settled the moment it began painting at the next.
|
|
191
|
-
*
|
|
192
|
-
* @type {WeakSet<HTMLCanvasElement>}
|
|
193
|
-
*/
|
|
194
|
-
let settled = new WeakSet();
|
|
195
|
-
|
|
196
157
|
/** CSS pixels per point at the applied percentage. */
|
|
197
158
|
let ratio = () => (PX_PER_POINT * applied) / 100;
|
|
198
159
|
|
|
199
|
-
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
|
|
205
|
-
*/
|
|
206
|
-
let pageAt = (i) => /** @type {HTMLCanvasElement} */ (sheet.children[i]);
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Give a page's canvas its size on screen. The one answer to how big page
|
|
210
|
-
* `i` is at the applied percentage: `pageOf` builds a canvas with it and
|
|
211
|
-
* `sizeAll` writes it over the sheet after a scale change, so a page
|
|
212
|
-
* retired between the two cannot arrive sizeless and move the extent the
|
|
213
|
-
* reader is scrolling through.
|
|
214
|
-
*
|
|
215
|
-
* @type {(canvas: HTMLElement, i: number, px?: number) => void}
|
|
216
|
-
*/
|
|
217
|
-
let sizePage = (canvas, i, px = ratio()) => {
|
|
218
|
-
let each = list.pages[i];
|
|
219
|
-
canvas.style.width = each.width * px + "px";
|
|
220
|
-
canvas.style.height = each.height * px + "px";
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* One page of the list as a canvas: sized on screen, carrying no pixels
|
|
225
|
-
* yet, and named for a screen reader. Every canvas on the sheet is built
|
|
226
|
-
* here — a swap's and a retirement's alike — so a replacement is the same
|
|
227
|
-
* element in every respect but identity.
|
|
228
|
-
*
|
|
229
|
-
* @type {(i: number) => HTMLCanvasElement}
|
|
230
|
-
*/
|
|
231
|
-
let pageOf = (i) => {
|
|
232
|
-
let canvas = document.createElement("canvas");
|
|
233
|
-
canvas.className = "qv-page";
|
|
234
|
-
// A canvas is 300 x 150 until told otherwise, and that store counts. A
|
|
235
|
-
// page starts with none and takes one once it is inside the reach.
|
|
236
|
-
canvas.width = 0;
|
|
237
|
-
canvas.height = 0;
|
|
238
|
-
canvas.setAttribute("role", "img");
|
|
239
|
-
canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
|
|
240
|
-
sizePage(canvas, i);
|
|
241
|
-
return canvas;
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Take page `i`'s canvas off the sheet and stand a fresh one in its place,
|
|
246
|
-
* answering with the replacement. A paint still in flight holds the old
|
|
247
|
-
* canvas's context and draws into something nobody is looking at, which is
|
|
248
|
-
* how a superseded paint is stopped here — by construction, rather than by
|
|
249
|
-
* a check the painter would have to make above its own draw (ADR 0046).
|
|
250
|
-
* The retired canvas takes its entries in `backed` and `settled` with it.
|
|
251
|
-
*
|
|
252
|
-
* Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
|
|
253
|
-
* store is what ADR 0043 rations, and leaving it to be collected whenever
|
|
254
|
-
* the superseded paint lets go of the context is the timing that ADR
|
|
255
|
-
* refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
|
|
256
|
-
* so emptying the canvas between them unbalances nothing — it just leaves
|
|
257
|
-
* every op clipped to nothing, which spares the raster work too.
|
|
258
|
-
*
|
|
259
|
-
* @type {(i: number) => HTMLCanvasElement}
|
|
260
|
-
*/
|
|
261
|
-
let retire = (i) => {
|
|
262
|
-
let old = pageAt(i);
|
|
263
|
-
let fresh = pageOf(i);
|
|
264
|
-
old.replaceWith(fresh);
|
|
265
|
-
old.width = 0;
|
|
266
|
-
old.height = 0;
|
|
267
|
-
return fresh;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Give page `i`'s pixels back. A page whose paint has settled is emptied in
|
|
272
|
-
* place: sizing the canvas to 0 × 0 is the one idiom that frees the store
|
|
273
|
-
* synchronously in every engine the viewer runs in, and the CSS size is
|
|
274
|
-
* untouched, so the page keeps its place in the extent and shows the
|
|
275
|
-
* sheet's white. A page still painting is retired instead — emptying it
|
|
276
|
-
* would leave that paint pointed at a canvas the next pass over the reach
|
|
277
|
-
* re-sizes and re-paints.
|
|
278
|
-
*
|
|
279
|
-
* @type {(i: number) => void}
|
|
280
|
-
*/
|
|
281
|
-
let release = (i) => {
|
|
282
|
-
let canvas = pageAt(i);
|
|
283
|
-
if (!backed.has(canvas)) return;
|
|
284
|
-
if (!settled.has(canvas)) return void retire(i);
|
|
285
|
-
backed.delete(canvas);
|
|
286
|
-
canvas.width = 0;
|
|
287
|
-
canvas.height = 0;
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
/** Device pixels per point at the applied percentage: what a page is
|
|
291
|
-
* painted at, and what its backing store is sized in. */
|
|
292
|
-
let deviceScale = () => ratio() * (globalThis.devicePixelRatio || 1);
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Start painting page `i`, and answer with that paint. **The one place a
|
|
296
|
-
* paint begins, and the one place the rule is enforced**: a canvas whose
|
|
297
|
-
* paint is still in flight is retired here before a second one is pointed
|
|
298
|
-
* at it, so no caller can reach a live canvas with a second paint by
|
|
299
|
-
* forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
|
|
300
|
-
* the next pass over the reach tries again instead of counting the page as
|
|
301
|
-
* painted; one that settles joins `settled`.
|
|
302
|
-
*
|
|
303
|
-
* **The paint this answers with never rejects**, which is the whole of why
|
|
304
|
-
* `swap` does not either. A page that could not be drawn is not a render
|
|
305
|
-
* that failed: the report laid out and the list reached the sheet, so there
|
|
306
|
-
* is nothing for the error panel to say and nothing to report through the
|
|
307
|
-
* error event. Swallowed here, the one place a paint begins, so no caller
|
|
308
|
-
* has to remember to; one bad page then costs its own pixels rather than
|
|
309
|
-
* every page after it in the reach, which this loop awaits one at a time.
|
|
310
|
-
*
|
|
311
|
-
* See the header for how little can still reach it — the layout absorbs a
|
|
312
|
-
* bad image and refuses a bad face while measuring — and why it stays
|
|
313
|
-
* anyway. A paint that failed is forgotten so the next pass over the reach
|
|
314
|
-
* tries again, which is what makes a failure for a passing reason
|
|
315
|
-
* recoverable.
|
|
316
|
-
*
|
|
317
|
-
* @type {(i: number) => Promise<void>}
|
|
318
|
-
*/
|
|
319
|
-
let start = (i) => {
|
|
320
|
-
let canvas = pageAt(i);
|
|
321
|
-
if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
|
|
322
|
-
settled.delete(canvas);
|
|
323
|
-
let scale = deviceScale();
|
|
324
|
-
let each = list.pages[i];
|
|
325
|
-
canvas.width = Math.round(each.width * scale);
|
|
326
|
-
canvas.height = Math.round(each.height * scale);
|
|
327
|
-
let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
|
|
328
|
-
let painted = paint(ctx, each, { scale, fonts }).then(
|
|
329
|
-
() => {
|
|
330
|
-
settled.add(canvas);
|
|
331
|
-
},
|
|
332
|
-
() => {
|
|
333
|
-
backed.delete(canvas);
|
|
334
|
-
},
|
|
335
|
-
);
|
|
336
|
-
backed.set(canvas, { scale, painted });
|
|
337
|
-
return painted;
|
|
338
|
-
};
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Paint one page and answer with that paint — or with the paint already
|
|
342
|
-
* under way at this scale, which is what makes scrolling back over ground
|
|
343
|
-
* already covered free. Answering with the paint rather than with a
|
|
344
|
-
* resolved promise is what lets two callers share one page's paint and both
|
|
345
|
-
* settle behind its pixels.
|
|
346
|
-
*
|
|
347
|
-
* Takes the index, not the canvas: the page it paints may be retired out
|
|
348
|
-
* from under a caller, so a caller that handed one in would be left holding
|
|
349
|
-
* an element that is no longer on the sheet.
|
|
350
|
-
*
|
|
351
|
-
* @type {(i: number) => Promise<void>}
|
|
352
|
-
*/
|
|
353
|
-
let paintPage = (i) => {
|
|
354
|
-
let carrying = backed.get(pageAt(i));
|
|
355
|
-
if (carrying && carrying.scale === deviceScale()) return carrying.painted;
|
|
356
|
-
return start(i);
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Bumped per repaint, so a repaint overtaken by the next stops walking.
|
|
361
|
-
* What that guards is the backing store, not the pixels: a superseded loop
|
|
362
|
-
* would paint at the *current* scale — `paintPage` reads it afresh — but
|
|
363
|
-
* onto pages the newer reach has since dropped, re-backing pages that
|
|
364
|
-
* should be blank (ADR 0043). Stale pixels are `retire`'s business, not
|
|
365
|
-
* this one, so neither guard stands in for the other.
|
|
366
|
-
*
|
|
367
|
-
* It guards repaint against repaint, and nothing else. A repaint chooses
|
|
368
|
-
* its pages once and holds that list across its awaits, so a scroll pass
|
|
369
|
-
* releasing a page mid-repaint is one this loop will paint anyway; the
|
|
370
|
-
* store that leaves behind is bounded by the reach and goes back on the
|
|
371
|
-
* next pass over it.
|
|
372
|
-
*/
|
|
373
|
-
let epoch = 0;
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Give every page its CSS size. Callers run this before writing the scroll
|
|
377
|
-
* offsets back: the extent those offsets are clamped against is this one,
|
|
378
|
-
* and the reach below is read from the offsets once they are in.
|
|
379
|
-
*/
|
|
380
|
-
let sizeAll = () => {
|
|
381
|
-
if (!list) return;
|
|
382
|
-
let px = ratio();
|
|
383
|
-
for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* The indices of the pages in the reach — the ones the viewport shows, and
|
|
388
|
-
* everything within one viewport height above or below. Walked over the
|
|
389
|
-
* list's own geometry (the gutter, each page's height at the applied
|
|
390
|
-
* scale, the gap), never a layout read, so it costs nothing to ask
|
|
391
|
-
* mid-scroll — the whole list is walked because a multiply and an add per
|
|
392
|
-
* page is beneath measuring, and stopping early would be a second rule
|
|
393
|
-
* about where the reach ends. `clientHeight` is read afresh each time, so a
|
|
394
|
-
* pane that changed size changes the reach with it.
|
|
395
|
-
*
|
|
396
|
-
* @returns {number[]}
|
|
397
|
-
*/
|
|
398
|
-
let inReach = () => {
|
|
399
|
-
let view = scroll.clientHeight;
|
|
400
|
-
let top = scroll.scrollTop - view;
|
|
401
|
-
let bottom = scroll.scrollTop + 2 * view;
|
|
402
|
-
let px = ratio();
|
|
403
|
-
let y = GUTTER;
|
|
404
|
-
let found = [];
|
|
405
|
-
for (let [i, each] of list.pages.entries()) {
|
|
406
|
-
let height = each.height * px;
|
|
407
|
-
if (y + height >= top && y <= bottom) found.push(i);
|
|
408
|
-
y += height + GAP;
|
|
409
|
-
}
|
|
410
|
-
return found;
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* Release every page the reach has left behind, and answer with the indices
|
|
415
|
-
* of the ones to keep, in the order they are painted in. The single
|
|
416
|
-
* definition of which pages carry pixels: the repaint below and the scroll
|
|
417
|
-
* pass both go through here, and neither holds a page the other released.
|
|
418
|
-
*
|
|
419
|
-
* Indices rather than elements, for the reason `paintPage` takes one: a
|
|
420
|
-
* page can be retired between this pass and the paint that follows it.
|
|
421
|
-
*
|
|
422
|
-
* @type {() => number[]}
|
|
423
|
-
*/
|
|
424
|
-
let keep = () => {
|
|
425
|
-
let wanted = new Set(inReach());
|
|
426
|
-
let kept = [];
|
|
427
|
-
// `retire` replaces one for one, so the count is fixed across the walk.
|
|
428
|
-
for (let i = 0, total = sheet.children.length; i < total; i++) {
|
|
429
|
-
if (wanted.has(i)) kept.push(i);
|
|
430
|
-
else release(i);
|
|
431
|
-
}
|
|
432
|
-
return kept;
|
|
433
|
-
};
|
|
434
|
-
|
|
435
|
-
// Paint the reach in order, yielding between pages and giving way to any
|
|
436
|
-
// repaint that started since. Sizing is the caller's, and comes first.
|
|
437
|
-
let repaint = async () => {
|
|
438
|
-
if (!list) return;
|
|
439
|
-
let mine = ++epoch;
|
|
440
|
-
for (let i of keep()) {
|
|
441
|
-
if (mine !== epoch) return;
|
|
442
|
-
await paintPage(i);
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
/**
|
|
447
|
-
* One reach pass a frame, however many scrolls and resizes ask for one. It
|
|
448
|
-
* keeps no epoch of its own, so a swap painting behind it is never cut off
|
|
449
|
-
* part-painted, and a page it starts is one a swap arriving at the same page
|
|
450
|
-
* waits behind rather than skips. Nothing awaits these paints here: a scroll
|
|
451
|
-
* is not a render, and a page that will not paint is blank whoever asked for
|
|
452
|
-
* it — `start` settles either way, so there is nothing here to catch.
|
|
453
|
-
*/
|
|
454
|
-
let pending = false;
|
|
455
|
-
let follow = () => {
|
|
456
|
-
if (pending) return;
|
|
457
|
-
pending = true;
|
|
458
|
-
requestAnimationFrame(() => {
|
|
459
|
-
pending = false;
|
|
460
|
-
if (!list) return;
|
|
461
|
-
for (let i of keep()) void paintPage(i);
|
|
462
|
-
});
|
|
463
|
-
};
|
|
464
|
-
scroll.addEventListener("scroll", follow);
|
|
465
|
-
|
|
160
|
+
/** The paged sheet: the page canvases, their backing-store lifetime, the
|
|
161
|
+
* reach and its painting, in `@quario/landing`. It asks `ratio()` afresh,
|
|
162
|
+
* so the percentage this element applies is the scale it paints at — and
|
|
163
|
+
* the elements above stay this file's, because the CSS is the viewer's own
|
|
164
|
+
* public surface. */
|
|
165
|
+
let paged = sheet(scroll, sheetEl, ratio);
|
|
466
166
|
return {
|
|
467
167
|
element: scroll,
|
|
468
168
|
|
|
@@ -475,7 +175,7 @@ export let stage = () => {
|
|
|
475
175
|
fit: () => {
|
|
476
176
|
let usable = scroll.clientWidth - 2 * GUTTER;
|
|
477
177
|
// The list's page width, or the geometry's before one arrives.
|
|
478
|
-
let width = (
|
|
178
|
+
let width = (paged.width() ?? fallback) * PX_PER_POINT;
|
|
479
179
|
if (usable <= 0 || !width) return null;
|
|
480
180
|
return (usable / width) * 100;
|
|
481
181
|
},
|
|
@@ -498,7 +198,7 @@ export let stage = () => {
|
|
|
498
198
|
* so a zoom step costs a handful of pages however long the report is.
|
|
499
199
|
*/
|
|
500
200
|
scale: (percent) => {
|
|
501
|
-
let held =
|
|
201
|
+
let held = sheetEl.firstChild && {
|
|
502
202
|
top: scroll.scrollTop,
|
|
503
203
|
left: scroll.scrollLeft,
|
|
504
204
|
height: scroll.clientHeight,
|
|
@@ -506,13 +206,13 @@ export let stage = () => {
|
|
|
506
206
|
};
|
|
507
207
|
let ratioOf = percent / applied;
|
|
508
208
|
applied = percent;
|
|
509
|
-
sizeAll();
|
|
209
|
+
paged.sizeAll();
|
|
510
210
|
if (held) {
|
|
511
211
|
let middle = held.top + held.height / 2 - GUTTER;
|
|
512
212
|
scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
|
|
513
213
|
scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
|
|
514
214
|
}
|
|
515
|
-
void repaint();
|
|
215
|
+
void paged.repaint();
|
|
516
216
|
},
|
|
517
217
|
|
|
518
218
|
/**
|
|
@@ -542,20 +242,15 @@ export let stage = () => {
|
|
|
542
242
|
swap: async (next, faces) => {
|
|
543
243
|
// Reading the offsets flushes layout, so only an actual reswap pays for
|
|
544
244
|
// it: on an empty sheet there is nothing scrolled to preserve.
|
|
545
|
-
let held =
|
|
245
|
+
let held = sheetEl.firstChild && {
|
|
546
246
|
top: scroll.scrollTop,
|
|
547
247
|
left: scroll.scrollLeft,
|
|
548
248
|
};
|
|
549
|
-
|
|
550
|
-
fonts = faces;
|
|
551
|
-
sheet.replaceChildren(
|
|
552
|
-
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
|
|
553
|
-
);
|
|
249
|
+
await paged.swap(next, faces);
|
|
554
250
|
if (held) {
|
|
555
251
|
scroll.scrollTop = held.top;
|
|
556
252
|
scroll.scrollLeft = held.left;
|
|
557
253
|
}
|
|
558
|
-
await repaint();
|
|
559
254
|
},
|
|
560
255
|
|
|
561
256
|
/**
|
|
@@ -568,7 +263,7 @@ export let stage = () => {
|
|
|
568
263
|
let observer = new ResizeObserver(() => {
|
|
569
264
|
// A resize that changes the fit repaints through `changed()`; one
|
|
570
265
|
// that does not still moved the viewport the reach is measured in.
|
|
571
|
-
follow();
|
|
266
|
+
paged.follow();
|
|
572
267
|
changed();
|
|
573
268
|
});
|
|
574
269
|
observer.observe(scroll);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The embeddable report viewer shell for quario — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@lit/task": "^1.0.3",
|
|
47
|
-
"@quario/
|
|
47
|
+
"@quario/landing": "^0.1.0",
|
|
48
|
+
"@quario/layout": "^0.4.0",
|
|
48
49
|
"lit": "^3.3.3"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
@@ -53,18 +54,19 @@
|
|
|
53
54
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
54
55
|
"esbuild": "^0.28.2",
|
|
55
56
|
"exceljs": "^4.4.0",
|
|
56
|
-
"quario": "^0.
|
|
57
|
+
"quario": "^0.7.0",
|
|
57
58
|
"size-limit": "^13.0.3",
|
|
58
59
|
"typescript": "^7.0.2"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
|
-
"quario": "^0.
|
|
62
|
+
"quario": "^0.7.0"
|
|
62
63
|
},
|
|
63
64
|
"size-limit": [
|
|
64
65
|
{
|
|
65
66
|
"path": "lib/index.js",
|
|
66
67
|
"ignore": [
|
|
67
68
|
"quario",
|
|
69
|
+
"@quario/landing",
|
|
68
70
|
"@quario/layout",
|
|
69
71
|
"lit",
|
|
70
72
|
"@lit/task"
|