@quario/viewer 0.4.0 → 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/CHANGELOG.md +30 -0
- package/README.md +9 -6
- package/lib/button.js +11 -4
- package/lib/check.js +5 -4
- package/lib/chrome.js +19 -0
- package/lib/icons.js +25 -0
- package/lib/index.d.ts +4 -4
- package/lib/index.js +12 -26
- package/lib/menu.js +17 -30
- package/lib/panel.js +2 -1
- package/lib/stage.js +229 -92
- package/lib/toolbar.js +17 -36
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
|
|
15
|
+
Install `fontkit` instead. The viewer measures host TrueType faces through
|
|
16
|
+
`@quario/layout`, whose optional parser this is; the old package crashed on
|
|
17
|
+
any OpenType face needing a shaping state machine, so those faces now
|
|
18
|
+
preview where they used to throw.
|
|
19
|
+
|
|
20
|
+
- **The bar's controls are drawn icons.** The zoom trigger, the menu's check
|
|
21
|
+
and the error panel's dismiss are drawn from Lucide's set rather than by
|
|
22
|
+
hand, at one weight and one grid, and the dismiss no longer relies on the
|
|
23
|
+
host's font to draw a multiplication sign. They paint in `currentColor`, so
|
|
24
|
+
`--qv-icon` and `--qv-icon-active` still recolour them.
|
|
25
|
+
|
|
26
|
+
- **An export button names its format in words.** Each one is a download icon
|
|
27
|
+
with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
|
|
28
|
+
lettered the format inside itself. The buttons are wider; the accessible
|
|
29
|
+
name is unchanged.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
|
|
34
|
+
page was still painting could leave that page carrying content at the old
|
|
35
|
+
scale on a canvas sized for the new one — the rest of it blank — and nothing
|
|
36
|
+
repainted it afterwards: scrolling away and back left it as it was. A page
|
|
37
|
+
whose paint is still in flight is now replaced rather than reused, so the
|
|
38
|
+
superseded paint has nowhere to land.
|
|
39
|
+
|
|
10
40
|
## [0.4.0] - 2026-09-03
|
|
11
41
|
|
|
12
42
|
### Added
|
package/README.md
CHANGED
|
@@ -41,8 +41,11 @@ view.filename = "sales";
|
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
with `<quario-viewer></quario-viewer>` in your markup, sized by your own CSS. The element is
|
|
44
|
-
`display: block
|
|
45
|
-
|
|
44
|
+
`display: block`, and its chrome is laid out to fit a width of **320px** — a phone-width embed is
|
|
45
|
+
the narrowest the bar is designed for. Narrower than that and the export buttons push the zoom
|
|
46
|
+
control off the left of the bar, quietly: the bar does not wrap or scroll. Importing
|
|
47
|
+
`@quario/viewer` defines nothing: the main entry exports the `QuarioViewer` class and is
|
|
48
|
+
side-effect-free, and `@quario/viewer/register` performs the one-line
|
|
46
49
|
`customElements.define("quario-viewer", QuarioViewer)`. A host that wants its own tag imports the
|
|
47
50
|
class and defines it itself. The repository ships this wiring as a runnable page at
|
|
48
51
|
`example/viewer.js`.
|
|
@@ -84,16 +87,16 @@ out.
|
|
|
84
87
|
```js
|
|
85
88
|
view.addEventListener("rendered", () => {});
|
|
86
89
|
view.addEventListener("error", ({ detail: { error, kind } }) => {});
|
|
87
|
-
await view.renderComplete; // true when the newest render
|
|
90
|
+
await view.renderComplete; // true when the newest render landed on the sheet
|
|
88
91
|
```
|
|
89
92
|
|
|
90
|
-
`rendered` fires each time a render
|
|
93
|
+
`rendered` fires each time a render lands on the sheet; `error` fires for every failure you should
|
|
91
94
|
know about, with `detail.kind` naming which: `"mount-render"` until a render has ever landed,
|
|
92
95
|
`"update-render"` after, `"export"` for a download that could not be produced. Both events are
|
|
93
96
|
non-bubbling, like `<img>`'s. Listen on the element. (Because the event is named `error`, an
|
|
94
97
|
inline `onerror` attribute on the element would fire too; `window.onerror` never sees it.)
|
|
95
98
|
|
|
96
|
-
`renderComplete` awaits the newest render settling: `true` when it
|
|
99
|
+
`renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
|
|
97
100
|
on screen painted, `false` when it failed or there was nothing to render. It never rejects; failures
|
|
98
101
|
arrive on the `error` event. `rendered` fires at that same moment. Like every outcome here, it
|
|
99
102
|
answers for the newest render only. A superseded render's failure is reported to no one.
|
|
@@ -271,7 +274,7 @@ the export is, watermark and all.
|
|
|
271
274
|
|
|
272
275
|
When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
|
|
273
276
|
of the sheet carrying a short label and the error's own message. It replaces rather than stacks, is
|
|
274
|
-
dismissed by its own button, and is cleared by the next render that
|
|
277
|
+
dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
|
|
275
278
|
export leaves it up, because the panel describes what you are looking at and a download says
|
|
276
279
|
nothing about that.
|
|
277
280
|
|
package/lib/button.js
CHANGED
|
@@ -18,6 +18,11 @@ export let BUTTON = css`
|
|
|
18
18
|
border-radius: 5px;
|
|
19
19
|
background: transparent;
|
|
20
20
|
color: var(--_icon);
|
|
21
|
+
/* A form control does not inherit \`font\`, so the chrome's own 13px/1.4
|
|
22
|
+
system-ui stops at every button unless it is restated here. The shorthand
|
|
23
|
+
rather than \`font-family\` alone: the UA sets the shorthand, so restating
|
|
24
|
+
one longhand leaves size and line-height at the UA's. */
|
|
25
|
+
font: inherit;
|
|
21
26
|
cursor: pointer;
|
|
22
27
|
}
|
|
23
28
|
|
|
@@ -63,9 +68,11 @@ let fallback = (value, otherwise) => value ?? otherwise;
|
|
|
63
68
|
* @param {{ title: string, label?: string, disabled?: boolean, name?: string,
|
|
64
69
|
* popover?: string, click?: () => void,
|
|
65
70
|
* content?: import('lit').TemplateResult }} control
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
71
|
+
* `content` is the icon a control draws and `label` the visible text it
|
|
72
|
+
* reads as; an export button carries both, the zoom trigger and the panel's
|
|
73
|
+
* dismiss the icon alone. Neither is ever the accessible name — `title` is,
|
|
74
|
+
* which is why the icons are `aria-hidden`. `name` marks an export button
|
|
75
|
+
* with the target it downloads, and
|
|
69
76
|
* `popover` names the menu this button opens — the platform then owns the
|
|
70
77
|
* opening and the invoker's own `aria-expanded`, which is why such a button
|
|
71
78
|
* needs no click of its own.
|
|
@@ -83,6 +90,6 @@ export let button = ({ title, label, disabled, name, popover, click, content })
|
|
|
83
90
|
?disabled=${disabled}
|
|
84
91
|
@click=${click}
|
|
85
92
|
>
|
|
86
|
-
${
|
|
93
|
+
${content ?? nothing}${label ?? nothing}
|
|
87
94
|
</button>
|
|
88
95
|
`;
|
package/lib/check.js
CHANGED
|
@@ -94,8 +94,9 @@ export let name = (filename) => {
|
|
|
94
94
|
return base;
|
|
95
95
|
};
|
|
96
96
|
|
|
97
|
-
/**
|
|
98
|
-
|
|
97
|
+
/** The schemes the property accepts. Two are their own CSS value; `auto` is
|
|
98
|
+
* the one that is not, so it is the only wording written here. */
|
|
99
|
+
let SCHEMES = ["light", "dark", "auto"];
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
102
|
* The used `color-scheme` from the `colorScheme` property. Light is the
|
|
@@ -108,6 +109,6 @@ let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
|
|
|
108
109
|
*/
|
|
109
110
|
export let scheme = (colorScheme) => {
|
|
110
111
|
let pin = colorScheme ?? "light";
|
|
111
|
-
if (!
|
|
112
|
-
return
|
|
112
|
+
if (!SCHEMES.includes(pin)) fail('colorScheme: expected "light", "dark", or "auto"');
|
|
113
|
+
return pin === "auto" ? "light dark" : pin;
|
|
113
114
|
};
|
package/lib/chrome.js
CHANGED
|
@@ -166,6 +166,25 @@ export let CHROME = css`
|
|
|
166
166
|
display: flex;
|
|
167
167
|
gap: 2px;
|
|
168
168
|
}
|
|
169
|
+
|
|
170
|
+
/* An export button reads as a drawn download and the format in words, so it
|
|
171
|
+
takes the width its text needs rather than the icon-only box. */
|
|
172
|
+
.qv-exports .qv-button {
|
|
173
|
+
gap: 5px;
|
|
174
|
+
width: auto;
|
|
175
|
+
padding: 0 9px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* The base icon size, once per package rather than once per control: an
|
|
179
|
+
inline <svg> carrying a viewBox and no width falls back to 300x150, and
|
|
180
|
+
nothing in the chrome wants that. 18px is 0.75 of Lucide's 24 grid, which
|
|
181
|
+
puts its stroke 2 at 1.5px beside 13px text. The narrower selectors that
|
|
182
|
+
drop to 14px live with the controls they belong to — the menu's tick slot
|
|
183
|
+
in menu.js (ADR 0044). */
|
|
184
|
+
.qv-viewer svg {
|
|
185
|
+
width: 18px;
|
|
186
|
+
height: 18px;
|
|
187
|
+
}
|
|
169
188
|
`;
|
|
170
189
|
|
|
171
190
|
/**
|
package/lib/icons.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The icons @quario/viewer draws, vendored from Lucide: one export per icon,
|
|
3
|
+
* finished templates that carry no size. Chrome CSS decides that — 18px by
|
|
4
|
+
* default, 14px in the gutter and the menu's tick slot — because `x` appears
|
|
5
|
+
* in two contexts at once (ADR 0044).
|
|
6
|
+
*
|
|
7
|
+
* Generated by `scripts/icons.mjs` from lucide 1.40.0. Do not edit:
|
|
8
|
+
* `npm run icons` rewrites it, and `npm run check` regenerates it into a
|
|
9
|
+
* temp directory and compares.
|
|
10
|
+
*
|
|
11
|
+
* Lucide is ISC licensed. Copyright (c) 2026 Lucide Icons and Contributors.
|
|
12
|
+
*/
|
|
13
|
+
import { html, svg } from "lit";
|
|
14
|
+
|
|
15
|
+
/** Upstream's grid and stroke, written once. */
|
|
16
|
+
/** @type {(paths: import('lit').SVGTemplateResult) => import('lit').TemplateResult} */
|
|
17
|
+
let icon = (paths) =>
|
|
18
|
+
html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
|
|
19
|
+
|
|
20
|
+
export let check = icon(svg`<path d="M20 6 9 17l-5-5"/>`);
|
|
21
|
+
export let download = icon(
|
|
22
|
+
svg`<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>`,
|
|
23
|
+
);
|
|
24
|
+
export let search = icon(svg`<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>`);
|
|
25
|
+
export let x = icon(svg`<path d="M18 6 6 18"/><path d="m6 6 12 12"/>`);
|
package/lib/index.d.ts
CHANGED
|
@@ -31,8 +31,8 @@ export interface ViewerErrorDetail {
|
|
|
31
31
|
/** The caught value, exactly as thrown. */
|
|
32
32
|
error: unknown;
|
|
33
33
|
/**
|
|
34
|
-
* Which failure occurred: `mount-render` until a render has ever
|
|
35
|
-
* the sheet, `update-render` after, `export` for a download that could not
|
|
34
|
+
* Which failure occurred: `mount-render` until a render has ever landed
|
|
35
|
+
* on the sheet, `update-render` after, `export` for a download that could not
|
|
36
36
|
* be produced.
|
|
37
37
|
*/
|
|
38
38
|
kind: ViewerErrorKind;
|
|
@@ -96,7 +96,7 @@ export class QuarioViewer extends LitElement {
|
|
|
96
96
|
*/
|
|
97
97
|
colorScheme: "light" | "dark" | "auto" | undefined;
|
|
98
98
|
/**
|
|
99
|
-
* The newest render settling: `true` when it
|
|
99
|
+
* The newest render settling: `true` when it landed on the sheet with the
|
|
100
100
|
* pages on screen painted, `false` when it failed or there was nothing to
|
|
101
101
|
* render. Never rejects — failures are the `error` event's — and like every
|
|
102
102
|
* outcome here it answers for the newest render only. `rendered` fires at
|
|
@@ -128,7 +128,7 @@ export class QuarioViewer extends LitElement {
|
|
|
128
128
|
|
|
129
129
|
/** The element's events. Both are non-bubbling, like `<img>`'s. */
|
|
130
130
|
export interface QuarioViewerEventMap {
|
|
131
|
-
/** A render
|
|
131
|
+
/** A render landed on the sheet — the mount and every update. */
|
|
132
132
|
rendered: CustomEvent<undefined>;
|
|
133
133
|
/** A failure the host should know about; `detail.kind` says which. */
|
|
134
134
|
error: CustomEvent<ViewerErrorDetail>;
|
package/lib/index.js
CHANGED
|
@@ -33,7 +33,7 @@ import { exports, geometry, level, name, scheme } from "./check.js";
|
|
|
33
33
|
import { MENU, zoomMenu } from "./menu.js";
|
|
34
34
|
import { PANEL, label, panel } from "./panel.js";
|
|
35
35
|
import { SURFACE, stage } from "./stage.js";
|
|
36
|
-
import { EXPORTS,
|
|
36
|
+
import { EXPORTS, exportGroup, saveAs } from "./toolbar.js";
|
|
37
37
|
import { wanted } from "./zoom.js";
|
|
38
38
|
|
|
39
39
|
/** @type {(report: unknown, targets: unknown) => boolean} */
|
|
@@ -89,7 +89,7 @@ export class QuarioViewer extends LitElement {
|
|
|
89
89
|
// fails and a disconnect that does not. The template interpolates its
|
|
90
90
|
// element as a node, which lit-html leaves untouched across re-renders.
|
|
91
91
|
#stage = stage();
|
|
92
|
-
/** Whether a render has ever
|
|
92
|
+
/** Whether a render has ever landed on the sheet — the mount/update boundary. */
|
|
93
93
|
#landed = false;
|
|
94
94
|
/** Whether the sheet stopped matching the properties while disconnected. */
|
|
95
95
|
#stale = false;
|
|
@@ -135,7 +135,7 @@ export class QuarioViewer extends LitElement {
|
|
|
135
135
|
// The engine takes no signal, so abandonment is the guards around this
|
|
136
136
|
// body; the check only spares the swap when the answer arrives after a
|
|
137
137
|
// disconnect mid-render.
|
|
138
|
-
if (this
|
|
138
|
+
if (signal.aborted && !this.isConnected) return this.#abandon();
|
|
139
139
|
return { list, fonts };
|
|
140
140
|
},
|
|
141
141
|
onComplete: (result) => {
|
|
@@ -145,7 +145,7 @@ export class QuarioViewer extends LitElement {
|
|
|
145
145
|
this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
|
|
146
146
|
this.#landed = true;
|
|
147
147
|
this.#stale = false;
|
|
148
|
-
// A render that
|
|
148
|
+
// A render that landed on the sheet takes the panel down: the panel
|
|
149
149
|
// says what is wrong with what the reader is looking at, and this is
|
|
150
150
|
// the moment that stops being true. A successful export is not that
|
|
151
151
|
// moment.
|
|
@@ -186,7 +186,7 @@ export class QuarioViewer extends LitElement {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
|
-
* The newest render settling: `true` when it
|
|
189
|
+
* The newest render settling: `true` when it landed on the sheet, `false`
|
|
190
190
|
* when it failed or there was nothing to render. It never rejects — a
|
|
191
191
|
* failed render is handled, on the panel and through the error event — and
|
|
192
192
|
* like every outcome here it answers for the newest render only.
|
|
@@ -238,7 +238,7 @@ export class QuarioViewer extends LitElement {
|
|
|
238
238
|
})}
|
|
239
239
|
</div>
|
|
240
240
|
<div class="qv-body">
|
|
241
|
-
${this.#panel()}
|
|
241
|
+
${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
|
|
242
242
|
${this.#stage.element}
|
|
243
243
|
</div>
|
|
244
244
|
</div>
|
|
@@ -259,7 +259,9 @@ export class QuarioViewer extends LitElement {
|
|
|
259
259
|
super.connectedCallback();
|
|
260
260
|
// A fitted sheet follows the host's box; the observer is released on
|
|
261
261
|
// disconnect, so reconnection takes it again.
|
|
262
|
-
this.#
|
|
262
|
+
this.#unwatch ??= this.#stage.watch(() => {
|
|
263
|
+
if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
|
|
264
|
+
});
|
|
263
265
|
// Reconnect re-renders from the current properties — but only when the
|
|
264
266
|
// sheet stopped matching them: a render was abandoned mid-flight, or a
|
|
265
267
|
// property write landed while disconnected. The task's arguments did not
|
|
@@ -295,11 +297,6 @@ export class QuarioViewer extends LitElement {
|
|
|
295
297
|
return true;
|
|
296
298
|
}
|
|
297
299
|
|
|
298
|
-
/** @param {AbortSignal} signal */
|
|
299
|
-
#drop(signal) {
|
|
300
|
-
return signal.aborted && !this.isConnected;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
300
|
#abandon() {
|
|
304
301
|
this.#stale = true;
|
|
305
302
|
return null;
|
|
@@ -361,18 +358,6 @@ export class QuarioViewer extends LitElement {
|
|
|
361
358
|
if (said(was) !== said(this.#invalid)) this.#epoch++;
|
|
362
359
|
}
|
|
363
360
|
|
|
364
|
-
#panel() {
|
|
365
|
-
return this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : "";
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
#onResize() {
|
|
369
|
-
if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
#watchStage() {
|
|
373
|
-
this.#unwatch ??= this.#stage.watch(() => this.#onResize());
|
|
374
|
-
}
|
|
375
|
-
|
|
376
361
|
#wake() {
|
|
377
362
|
if (this.#stale || this.#task.status === TaskStatus.PENDING) {
|
|
378
363
|
this.#epoch++;
|
|
@@ -442,12 +427,13 @@ export class QuarioViewer extends LitElement {
|
|
|
442
427
|
* @param {any} target
|
|
443
428
|
*/
|
|
444
429
|
async #export(target) {
|
|
445
|
-
let
|
|
430
|
+
let type = EXPORTS[target.name];
|
|
431
|
+
let format = target.name.toUpperCase();
|
|
446
432
|
this.#exporting.add(target.name);
|
|
447
433
|
this.requestUpdate();
|
|
448
434
|
try {
|
|
449
435
|
let body = await /** @type {any} */ (this.report).render(target, this.data);
|
|
450
|
-
this.#ifConnected(() =>
|
|
436
|
+
this.#ifConnected(() => saveAs(body, this.#name + "." + target.name, type));
|
|
451
437
|
} catch (error) {
|
|
452
438
|
this.#ifConnected(() => this.#announce(error, "export", format));
|
|
453
439
|
} finally {
|
package/lib/menu.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The zoom menu: the
|
|
2
|
+
* The zoom menu: the search trigger in the bar and the popover it opens —
|
|
3
3
|
* "Fit page" above a separator, then the percentages `zoom.js` offers. It is
|
|
4
4
|
* the whole zoom control; the live percentage is never drawn, it is what the
|
|
5
5
|
* trigger is named after ("Zoom, 62%"), so the bar carries one icon rather
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { css, html } from "lit";
|
|
31
31
|
import { button } from "./button.js";
|
|
32
|
+
import { check, search } from "./icons.js";
|
|
32
33
|
import { STEPS } from "./zoom.js";
|
|
33
34
|
|
|
34
35
|
/** The popover's id, scoped to the shadow root, so two viewers never collide. */
|
|
@@ -85,12 +86,22 @@ export let MENU = css`
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/* The check's slot is held whether or not a check is in it, so the labels
|
|
88
|
-
line up down the menu.
|
|
89
|
+
line up down the menu. 14px rather than 13: the small icon size serves this
|
|
90
|
+
slot and the editor's gutter alike, which is one size fewer than buying a
|
|
91
|
+
third for one glyph (ADR 0044). */
|
|
89
92
|
.qv-tick {
|
|
90
93
|
display: inline-flex;
|
|
91
94
|
flex: none;
|
|
92
|
-
width:
|
|
93
|
-
height:
|
|
95
|
+
width: 14px;
|
|
96
|
+
height: 14px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Narrower than the chrome's 18px base on specificity, not on where this
|
|
100
|
+
sheet sits in \`static styles\` — reordering that array must not silently
|
|
101
|
+
put the tick back to 18px. */
|
|
102
|
+
.qv-viewer .qv-tick svg {
|
|
103
|
+
width: 14px;
|
|
104
|
+
height: 14px;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
.qv-separator {
|
|
@@ -100,30 +111,6 @@ export let MENU = css`
|
|
|
100
111
|
}
|
|
101
112
|
`;
|
|
102
113
|
|
|
103
|
-
/** @type {import('lit').TemplateResult} */
|
|
104
|
-
let magnifier = html`
|
|
105
|
-
<svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
|
|
106
|
-
<g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
|
|
107
|
-
<circle cx="8.75" cy="8.75" r="5.25"></circle>
|
|
108
|
-
<path d="M12.6 12.6 16.4 16.4"></path>
|
|
109
|
-
</g>
|
|
110
|
-
</svg>
|
|
111
|
-
`;
|
|
112
|
-
|
|
113
|
-
/** @type {import('lit').TemplateResult} */
|
|
114
|
-
let tick = html`
|
|
115
|
-
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
|
|
116
|
-
<path
|
|
117
|
-
d="M3.4 8.4 6.4 11.4 12.6 4.8"
|
|
118
|
-
fill="none"
|
|
119
|
-
stroke="currentColor"
|
|
120
|
-
stroke-width="1.9"
|
|
121
|
-
stroke-linecap="round"
|
|
122
|
-
stroke-linejoin="round"
|
|
123
|
-
></path>
|
|
124
|
-
</svg>
|
|
125
|
-
`;
|
|
126
|
-
|
|
127
114
|
/**
|
|
128
115
|
* The button that opens this menu, found by the invoker relationship itself
|
|
129
116
|
* rather than by where it sits: a wrapper added for layout, or a second
|
|
@@ -276,7 +263,7 @@ let row = (label, value, checked, choose) => html`
|
|
|
276
263
|
data-zoom=${String(value)}
|
|
277
264
|
@click=${pick(value, choose)}
|
|
278
265
|
>
|
|
279
|
-
<span class="qv-tick">${checked ?
|
|
266
|
+
<span class="qv-tick">${checked ? check : ""}</span>
|
|
280
267
|
${label}
|
|
281
268
|
</button>
|
|
282
269
|
`;
|
|
@@ -298,7 +285,7 @@ export let zoomMenu = ({ mode, percent, choose }) => html`
|
|
|
298
285
|
${button({
|
|
299
286
|
title: "Zoom, " + percent + "%",
|
|
300
287
|
popover: ID,
|
|
301
|
-
content:
|
|
288
|
+
content: search,
|
|
302
289
|
})}
|
|
303
290
|
<div
|
|
304
291
|
id=${ID}
|
package/lib/panel.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { css, html } from "lit";
|
|
21
21
|
import { button } from "./button.js";
|
|
22
|
+
import { x } from "./icons.js";
|
|
22
23
|
|
|
23
24
|
export let PANEL = css`
|
|
24
25
|
/* Positioned rather than laid out, for the reason the progress strip is: a
|
|
@@ -121,6 +122,6 @@ export let panel = (failure, dismiss) => html`
|
|
|
121
122
|
<p class="qv-error-label">${failure.label}</p>
|
|
122
123
|
<p class="qv-error-message">${wording(failure.error)}</p>
|
|
123
124
|
</div>
|
|
124
|
-
${button({ title: "Dismiss",
|
|
125
|
+
${button({ title: "Dismiss", content: x, click: dismiss })}
|
|
125
126
|
</div>
|
|
126
127
|
`;
|
package/lib/stage.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* never reflows (zoom.js says why).
|
|
13
13
|
*
|
|
14
14
|
* Every page keeps its CSS size, but only the
|
|
15
|
-
* [
|
|
15
|
+
* [reach](../../../CONTEXT.md#reach) carries pixels — the pages on screen and
|
|
16
16
|
* one viewport height either side. The extent the reader scrolls through is
|
|
17
17
|
* whole and synchronous whatever is painted; what comes and goes is the
|
|
18
18
|
* backing store, released by sizing a canvas to 0 × 0. A scroll or a resize
|
|
@@ -21,6 +21,28 @@
|
|
|
21
21
|
* asked a thousand-page report for gigabytes of backing store, and past what
|
|
22
22
|
* the browser would grant the pages simply came up blank.
|
|
23
23
|
*
|
|
24
|
+
* **A canvas on the sheet is never re-pointed at a second paint.** `paint()`
|
|
25
|
+
* awaits before it draws, so a canvas whose paint is still in flight is
|
|
26
|
+
* *retired* rather than painted over or emptied: it comes off the sheet, its
|
|
27
|
+
* pixels go back, and a fresh one stands in its place, so the superseded
|
|
28
|
+
* paint draws into an element nobody is looking at. A canvas whose paint has
|
|
29
|
+
* settled has nothing that could land late and is re-sized or emptied in
|
|
30
|
+
* place as before. Without this a zoom landing mid-paint could leave a page
|
|
31
|
+
* carrying old-scale content on a new-scale canvas, with the memo below
|
|
32
|
+
* calling it painted so that nothing repainted it again (ADR 0046).
|
|
33
|
+
*
|
|
34
|
+
* Retirement is lazy, and only the paint is guarded. A scale change sizes
|
|
35
|
+
* every page's CSS box at once but retires a page only when the repaint loop
|
|
36
|
+
* reaches it, so a page further down the reach shows its old pixels stretched
|
|
37
|
+
* into the new box until its turn comes — the ordinary look of a zoom in
|
|
38
|
+
* progress, not the artefact above. The cost is that a retired page is a
|
|
39
|
+
* replaced `role="img"` node: assistive technology reading that page sees it
|
|
40
|
+
* swapped under them. Judged acceptable because retirement only happens while
|
|
41
|
+
* that very page is mid-repaint and about to change what it shows anyway, and
|
|
42
|
+
* because the alternative — a stable wrapper element per page to announce
|
|
43
|
+
* from — is a second element per page on a sheet ADR 0043 exists to keep
|
|
44
|
+
* cheap.
|
|
45
|
+
*
|
|
24
46
|
* What the stage does not decide is which percentage to show: `fit()`
|
|
25
47
|
* measures what would make one page span the width available, and `zoom.js`
|
|
26
48
|
* owns the policy over that answer. What is on screen, though, is the
|
|
@@ -79,7 +101,7 @@ export let SURFACE = css`
|
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
/* One page. The painter fills it white; the background here is what a page
|
|
82
|
-
outside the
|
|
104
|
+
outside the reach shows, and what one inside it shows between being sized
|
|
83
105
|
and being painted. */
|
|
84
106
|
.qv-page {
|
|
85
107
|
display: block;
|
|
@@ -131,7 +153,7 @@ export let stage = () => {
|
|
|
131
153
|
|
|
132
154
|
/**
|
|
133
155
|
* What each backed page is carrying: the scale it was painted at, so a page
|
|
134
|
-
* re-entering the
|
|
156
|
+
* re-entering the reach at that scale is not repainted and a scale change
|
|
135
157
|
* invalidates it, and the paint itself, so whoever asks second waits behind
|
|
136
158
|
* the paint the first caller started rather than being told it is done.
|
|
137
159
|
* Keyed by the canvas, so a swap's new pages start with nothing remembered
|
|
@@ -143,84 +165,211 @@ export let stage = () => {
|
|
|
143
165
|
let backed = new WeakMap();
|
|
144
166
|
|
|
145
167
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
168
|
+
* The canvases whose paint has finished. A promise's settled state is not
|
|
169
|
+
* synchronously observable and this is the one question the rule below
|
|
170
|
+
* turns on: a canvas in here has nothing left that could draw into it, so
|
|
171
|
+
* it can be emptied or re-sized in place; one that is backed but absent
|
|
172
|
+
* here is still being painted, and must be retired instead. Weak on the
|
|
173
|
+
* same key as `backed`, so a retired canvas takes its membership with it.
|
|
174
|
+
*
|
|
175
|
+
* Membership is per paint, not per canvas — `start` takes a canvas out
|
|
176
|
+
* before painting it again, or a page settled at one scale would count as
|
|
177
|
+
* settled the moment it began painting at the next.
|
|
178
|
+
*
|
|
179
|
+
* @type {WeakSet<HTMLCanvasElement>}
|
|
180
|
+
*/
|
|
181
|
+
let settled = new WeakSet();
|
|
182
|
+
|
|
183
|
+
/** CSS pixels per point at the applied percentage. */
|
|
184
|
+
let ratio = () => (PX_PER_POINT * applied) / 100;
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The canvas standing for page `i` right now. Read through here and never
|
|
188
|
+
* held: a retired page is a different element, so a canvas taken before an
|
|
189
|
+
* await may be off the sheet by the time it is used.
|
|
190
|
+
*
|
|
191
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
192
|
+
*/
|
|
193
|
+
let pageAt = (i) => /** @type {HTMLCanvasElement} */ (sheet.children[i]);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Give a page's canvas its size on screen. The one answer to how big page
|
|
197
|
+
* `i` is at the applied percentage: `pageOf` builds a canvas with it and
|
|
198
|
+
* `sizeAll` writes it over the sheet after a scale change, so a page
|
|
199
|
+
* retired between the two cannot arrive sizeless and move the extent the
|
|
200
|
+
* reader is scrolling through.
|
|
201
|
+
*
|
|
202
|
+
* @type {(canvas: HTMLElement, i: number, px?: number) => void}
|
|
203
|
+
*/
|
|
204
|
+
let sizePage = (canvas, i, px = ratio()) => {
|
|
205
|
+
let each = list.pages[i];
|
|
206
|
+
canvas.style.width = each.width * px + "px";
|
|
207
|
+
canvas.style.height = each.height * px + "px";
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One page of the list as a canvas: sized on screen, carrying no pixels
|
|
212
|
+
* yet, and named for a screen reader. Every canvas on the sheet is built
|
|
213
|
+
* here — a swap's and a retirement's alike — so a replacement is the same
|
|
214
|
+
* element in every respect but identity.
|
|
150
215
|
*
|
|
151
|
-
* @type {(
|
|
216
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
152
217
|
*/
|
|
153
|
-
let
|
|
218
|
+
let pageOf = (i) => {
|
|
219
|
+
let canvas = document.createElement("canvas");
|
|
220
|
+
canvas.className = "qv-page";
|
|
221
|
+
// A canvas is 300 x 150 until told otherwise, and that store counts. A
|
|
222
|
+
// page starts with none and takes one once it is inside the reach.
|
|
223
|
+
canvas.width = 0;
|
|
224
|
+
canvas.height = 0;
|
|
225
|
+
canvas.setAttribute("role", "img");
|
|
226
|
+
canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
|
|
227
|
+
sizePage(canvas, i);
|
|
228
|
+
return canvas;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Take page `i`'s canvas off the sheet and stand a fresh one in its place,
|
|
233
|
+
* answering with the replacement. A paint still in flight holds the old
|
|
234
|
+
* canvas's context and draws into something nobody is looking at, which is
|
|
235
|
+
* how a superseded paint is stopped here — by construction, rather than by
|
|
236
|
+
* a check the painter would have to make above its own draw (ADR 0046).
|
|
237
|
+
* The retired canvas takes its entries in `backed` and `settled` with it.
|
|
238
|
+
*
|
|
239
|
+
* Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
|
|
240
|
+
* store is what ADR 0043 rations, and leaving it to be collected whenever
|
|
241
|
+
* the superseded paint lets go of the context is the timing that ADR
|
|
242
|
+
* refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
|
|
243
|
+
* so emptying the canvas between them unbalances nothing — it just leaves
|
|
244
|
+
* every op clipped to nothing, which spares the raster work too.
|
|
245
|
+
*
|
|
246
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
247
|
+
*/
|
|
248
|
+
let retire = (i) => {
|
|
249
|
+
let old = pageAt(i);
|
|
250
|
+
let fresh = pageOf(i);
|
|
251
|
+
old.replaceWith(fresh);
|
|
252
|
+
old.width = 0;
|
|
253
|
+
old.height = 0;
|
|
254
|
+
return fresh;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Give page `i`'s pixels back. A page whose paint has settled is emptied in
|
|
259
|
+
* place: sizing the canvas to 0 × 0 is the one idiom that frees the store
|
|
260
|
+
* synchronously in every engine the viewer runs in, and the CSS size is
|
|
261
|
+
* untouched, so the page keeps its place in the extent and shows the
|
|
262
|
+
* sheet's white. A page still painting is retired instead — emptying it
|
|
263
|
+
* would leave that paint pointed at a canvas the next pass over the reach
|
|
264
|
+
* re-sizes and re-paints.
|
|
265
|
+
*
|
|
266
|
+
* @type {(i: number) => void}
|
|
267
|
+
*/
|
|
268
|
+
let release = (i) => {
|
|
269
|
+
let canvas = pageAt(i);
|
|
154
270
|
if (!backed.has(canvas)) return;
|
|
271
|
+
if (!settled.has(canvas)) return void retire(i);
|
|
155
272
|
backed.delete(canvas);
|
|
156
273
|
canvas.width = 0;
|
|
157
274
|
canvas.height = 0;
|
|
158
275
|
};
|
|
159
276
|
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
let
|
|
163
|
-
|
|
164
|
-
/** CSS pixels per point at the applied percentage. */
|
|
165
|
-
let ratio = () => (PX_PER_POINT * applied) / 100;
|
|
277
|
+
/** Device pixels per point at the applied percentage: what a page is
|
|
278
|
+
* painted at, and what its backing store is sized in. */
|
|
279
|
+
let deviceScale = () => ratio() * (globalThis.devicePixelRatio || 1);
|
|
166
280
|
|
|
167
281
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* pass over the
|
|
282
|
+
* Start painting page `i`, and answer with that paint. **The one place a
|
|
283
|
+
* paint begins, and the one place the rule is enforced**: a canvas whose
|
|
284
|
+
* paint is still in flight is retired here before a second one is pointed
|
|
285
|
+
* at it, so no caller can reach a live canvas with a second paint by
|
|
286
|
+
* forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
|
|
287
|
+
* the next pass over the reach tries again instead of counting the page as
|
|
288
|
+
* painted; one that settles joins `settled`.
|
|
174
289
|
*
|
|
175
|
-
* @type {(
|
|
290
|
+
* @type {(i: number) => Promise<void>}
|
|
176
291
|
*/
|
|
177
|
-
let
|
|
178
|
-
let
|
|
179
|
-
|
|
180
|
-
|
|
292
|
+
let start = (i) => {
|
|
293
|
+
let canvas = pageAt(i);
|
|
294
|
+
if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
|
|
295
|
+
settled.delete(canvas);
|
|
296
|
+
let scale = deviceScale();
|
|
181
297
|
let each = list.pages[i];
|
|
182
298
|
canvas.width = Math.round(each.width * scale);
|
|
183
299
|
canvas.height = Math.round(each.height * scale);
|
|
184
300
|
let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
|
|
185
|
-
let painted = paint(ctx, each, { scale, fonts }).
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
301
|
+
let painted = paint(ctx, each, { scale, fonts }).then(
|
|
302
|
+
() => {
|
|
303
|
+
settled.add(canvas);
|
|
304
|
+
},
|
|
305
|
+
(failure) => {
|
|
306
|
+
backed.delete(canvas);
|
|
307
|
+
throw failure;
|
|
308
|
+
},
|
|
309
|
+
);
|
|
189
310
|
backed.set(canvas, { scale, painted });
|
|
190
311
|
return painted;
|
|
191
312
|
};
|
|
192
313
|
|
|
193
|
-
/**
|
|
314
|
+
/**
|
|
315
|
+
* Paint one page and answer with that paint — or with the paint already
|
|
316
|
+
* under way at this scale, which is what makes scrolling back over ground
|
|
317
|
+
* already covered free. Answering with the paint rather than with a
|
|
318
|
+
* resolved promise is what lets two callers share one page's paint and both
|
|
319
|
+
* settle behind its pixels.
|
|
320
|
+
*
|
|
321
|
+
* Takes the index, not the canvas: the page it paints may be retired out
|
|
322
|
+
* from under a caller, so a caller that handed one in would be left holding
|
|
323
|
+
* an element that is no longer on the sheet.
|
|
324
|
+
*
|
|
325
|
+
* @type {(i: number) => Promise<void>}
|
|
326
|
+
*/
|
|
327
|
+
let paintPage = (i) => {
|
|
328
|
+
let carrying = backed.get(pageAt(i));
|
|
329
|
+
if (carrying && carrying.scale === deviceScale()) return carrying.painted;
|
|
330
|
+
return start(i);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Bumped per repaint, so a repaint overtaken by the next stops walking.
|
|
335
|
+
* What that guards is the backing store, not the pixels: a superseded loop
|
|
336
|
+
* would paint at the *current* scale — `paintPage` reads it afresh — but
|
|
337
|
+
* onto pages the newer reach has since dropped, re-backing pages that
|
|
338
|
+
* should be blank (ADR 0043). Stale pixels are `retire`'s business, not
|
|
339
|
+
* this one, so neither guard stands in for the other.
|
|
340
|
+
*
|
|
341
|
+
* It guards repaint against repaint, and nothing else. A repaint chooses
|
|
342
|
+
* its pages once and holds that list across its awaits, so a scroll pass
|
|
343
|
+
* releasing a page mid-repaint is one this loop will paint anyway; the
|
|
344
|
+
* store that leaves behind is bounded by the reach and goes back on the
|
|
345
|
+
* next pass over it.
|
|
346
|
+
*/
|
|
194
347
|
let epoch = 0;
|
|
195
348
|
|
|
196
349
|
/**
|
|
197
350
|
* Give every page its CSS size. Callers run this before writing the scroll
|
|
198
351
|
* offsets back: the extent those offsets are clamped against is this one,
|
|
199
|
-
* and the
|
|
352
|
+
* and the reach below is read from the offsets once they are in.
|
|
200
353
|
*/
|
|
201
354
|
let sizeAll = () => {
|
|
202
355
|
if (!list) return;
|
|
203
356
|
let px = ratio();
|
|
204
|
-
for (let
|
|
205
|
-
let canvas = /** @type {HTMLElement} */ (sheet.children[i]);
|
|
206
|
-
canvas.style.width = each.width * px + "px";
|
|
207
|
-
canvas.style.height = each.height * px + "px";
|
|
208
|
-
}
|
|
357
|
+
for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
|
|
209
358
|
};
|
|
210
359
|
|
|
211
360
|
/**
|
|
212
|
-
* The indices of the pages in the
|
|
361
|
+
* The indices of the pages in the reach — the ones the viewport shows, and
|
|
213
362
|
* everything within one viewport height above or below. Walked over the
|
|
214
363
|
* list's own geometry (the gutter, each page's height at the applied
|
|
215
364
|
* scale, the gap), never a layout read, so it costs nothing to ask
|
|
216
365
|
* mid-scroll — the whole list is walked because a multiply and an add per
|
|
217
366
|
* page is beneath measuring, and stopping early would be a second rule
|
|
218
|
-
* about where the
|
|
219
|
-
* pane that changed size changes the
|
|
367
|
+
* about where the reach ends. `clientHeight` is read afresh each time, so a
|
|
368
|
+
* pane that changed size changes the reach with it.
|
|
220
369
|
*
|
|
221
370
|
* @returns {number[]}
|
|
222
371
|
*/
|
|
223
|
-
let
|
|
372
|
+
let inReach = () => {
|
|
224
373
|
let view = scroll.clientHeight;
|
|
225
374
|
let top = scroll.scrollTop - view;
|
|
226
375
|
let bottom = scroll.scrollTop + 2 * view;
|
|
@@ -236,73 +385,59 @@ export let stage = () => {
|
|
|
236
385
|
};
|
|
237
386
|
|
|
238
387
|
/**
|
|
239
|
-
* Release every page the
|
|
240
|
-
*
|
|
388
|
+
* Release every page the reach has left behind, and answer with the indices
|
|
389
|
+
* of the ones to keep, in the order they are painted in. The single
|
|
241
390
|
* definition of which pages carry pixels: the repaint below and the scroll
|
|
242
391
|
* pass both go through here, and neither holds a page the other released.
|
|
243
392
|
*
|
|
244
|
-
*
|
|
393
|
+
* Indices rather than elements, for the reason `paintPage` takes one: a
|
|
394
|
+
* page can be retired between this pass and the paint that follows it.
|
|
395
|
+
*
|
|
396
|
+
* @type {() => number[]}
|
|
245
397
|
*/
|
|
246
398
|
let keep = () => {
|
|
247
|
-
let wanted = new Set(
|
|
399
|
+
let wanted = new Set(inReach());
|
|
248
400
|
let kept = [];
|
|
249
|
-
for
|
|
250
|
-
|
|
251
|
-
if (wanted.has(i)) kept.push(
|
|
252
|
-
else release(
|
|
401
|
+
// `retire` replaces one for one, so the count is fixed across the walk.
|
|
402
|
+
for (let i = 0, total = sheet.children.length; i < total; i++) {
|
|
403
|
+
if (wanted.has(i)) kept.push(i);
|
|
404
|
+
else release(i);
|
|
253
405
|
}
|
|
254
406
|
return kept;
|
|
255
407
|
};
|
|
256
408
|
|
|
257
|
-
// Paint the
|
|
409
|
+
// Paint the reach in order, yielding between pages and giving way to any
|
|
258
410
|
// repaint that started since. Sizing is the caller's, and comes first.
|
|
259
411
|
let repaint = async () => {
|
|
260
412
|
if (!list) return;
|
|
261
413
|
let mine = ++epoch;
|
|
262
|
-
for (let
|
|
414
|
+
for (let i of keep()) {
|
|
263
415
|
if (mine !== epoch) return;
|
|
264
|
-
await paintPage(
|
|
416
|
+
await paintPage(i);
|
|
265
417
|
}
|
|
266
418
|
};
|
|
267
419
|
|
|
268
420
|
/**
|
|
269
|
-
*
|
|
270
|
-
* a swap painting behind it is never cut off
|
|
271
|
-
* starts is one a swap
|
|
272
|
-
* skips. Nothing awaits these paints here: a scroll
|
|
273
|
-
* no failure channel of its own, and a paint that
|
|
274
|
-
* the next pass over the
|
|
421
|
+
* One reach pass a frame, however many scrolls and resizes ask for one. It
|
|
422
|
+
* keeps no epoch of its own, so a swap painting behind it is never cut off
|
|
423
|
+
* part-painted, and a page it starts is one a swap arriving at the same page
|
|
424
|
+
* waits behind rather than skips. Nothing awaits these paints here: a scroll
|
|
425
|
+
* is not a render and has no failure channel of its own, and a paint that
|
|
426
|
+
* failed is forgotten, so the next pass over the reach reports it the way
|
|
427
|
+
* any other paint does.
|
|
275
428
|
*/
|
|
276
|
-
let sync = () => {
|
|
277
|
-
if (!list) return;
|
|
278
|
-
for (let [i, canvas] of keep()) paintPage(canvas, i).catch(() => {});
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
/** One band pass a frame, however many scrolls and resizes ask for one. */
|
|
282
429
|
let pending = false;
|
|
283
430
|
let follow = () => {
|
|
284
431
|
if (pending) return;
|
|
285
432
|
pending = true;
|
|
286
433
|
requestAnimationFrame(() => {
|
|
287
434
|
pending = false;
|
|
288
|
-
|
|
435
|
+
if (!list) return;
|
|
436
|
+
for (let i of keep()) paintPage(i).catch(() => {});
|
|
289
437
|
});
|
|
290
438
|
};
|
|
291
439
|
scroll.addEventListener("scroll", follow);
|
|
292
440
|
|
|
293
|
-
/** @type {(i: number, total: number) => HTMLCanvasElement} */
|
|
294
|
-
let pageOf = (i, total) => {
|
|
295
|
-
let canvas = document.createElement("canvas");
|
|
296
|
-
canvas.className = "qv-page";
|
|
297
|
-
// A canvas is 300 x 150 until told otherwise, and that store counts. A
|
|
298
|
-
// page starts with none and takes one when the band reaches it.
|
|
299
|
-
canvas.width = 0;
|
|
300
|
-
canvas.height = 0;
|
|
301
|
-
canvas.setAttribute("role", "img");
|
|
302
|
-
canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + total);
|
|
303
|
-
return canvas;
|
|
304
|
-
};
|
|
305
|
-
|
|
306
441
|
return {
|
|
307
442
|
element: scroll,
|
|
308
443
|
|
|
@@ -314,7 +449,8 @@ export let stage = () => {
|
|
|
314
449
|
*/
|
|
315
450
|
fit: () => {
|
|
316
451
|
let usable = scroll.clientWidth - 2 * GUTTER;
|
|
317
|
-
|
|
452
|
+
// The list's page width, or the geometry's before one arrives.
|
|
453
|
+
let width = (list ? list.width : fallback) * PX_PER_POINT;
|
|
318
454
|
if (usable <= 0 || !width) return null;
|
|
319
455
|
return (usable / width) * 100;
|
|
320
456
|
},
|
|
@@ -333,7 +469,7 @@ export let stage = () => {
|
|
|
333
469
|
* seen. The browser clamps whatever it cannot honour. An empty sheet has
|
|
334
470
|
* no view to hold, which is what mounting at an authored zoom takes.
|
|
335
471
|
*
|
|
336
|
-
* Only the
|
|
472
|
+
* Only the reach is repainted, and only where the scale actually changed,
|
|
337
473
|
* so a zoom step costs a handful of pages however long the report is.
|
|
338
474
|
*/
|
|
339
475
|
scale: (percent) => {
|
|
@@ -367,26 +503,27 @@ export let stage = () => {
|
|
|
367
503
|
|
|
368
504
|
/**
|
|
369
505
|
* Put a laid-out report on the sheet, keeping the reader where they
|
|
370
|
-
* were: one canvas per page, the
|
|
371
|
-
* scale. The order is the point of the method: the pages
|
|
372
|
-
*
|
|
373
|
-
* extent
|
|
374
|
-
* read from those offsets, so it is chosen after they
|
|
375
|
-
*
|
|
506
|
+
* were: one canvas per page, the reach among them painted at the applied
|
|
507
|
+
* scale. The order is the point of the method: the pages come off
|
|
508
|
+
* `pageOf` already at their size, so the browser clamps the offsets going
|
|
509
|
+
* back against the extent the sheet will have rather than the one it had
|
|
510
|
+
* — and the reach is read from those offsets, so it is chosen after they
|
|
511
|
+
* are in. What the
|
|
512
|
+
* returned promise settles behind is the reach, which is what the caller's
|
|
376
513
|
* `renderComplete` means by "the pages on screen are painted".
|
|
377
514
|
*/
|
|
378
515
|
swap: async (next, faces) => {
|
|
379
516
|
// Reading the offsets flushes layout, so only an actual reswap pays for
|
|
380
517
|
// it: on an empty sheet there is nothing scrolled to preserve.
|
|
381
|
-
let held = sheet.firstChild && {
|
|
518
|
+
let held = sheet.firstChild && {
|
|
519
|
+
top: scroll.scrollTop,
|
|
520
|
+
left: scroll.scrollLeft,
|
|
521
|
+
};
|
|
382
522
|
list = next;
|
|
383
523
|
fonts = faces;
|
|
384
524
|
sheet.replaceChildren(
|
|
385
|
-
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) =>
|
|
386
|
-
pageOf(i, next.pages.length),
|
|
387
|
-
),
|
|
525
|
+
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
|
|
388
526
|
);
|
|
389
|
-
sizeAll();
|
|
390
527
|
if (held) {
|
|
391
528
|
scroll.scrollTop = held.top;
|
|
392
529
|
scroll.scrollLeft = held.left;
|
|
@@ -403,7 +540,7 @@ export let stage = () => {
|
|
|
403
540
|
watch: (changed) => {
|
|
404
541
|
let observer = new ResizeObserver(() => {
|
|
405
542
|
// A resize that changes the fit repaints through `changed()`; one
|
|
406
|
-
// that does not still moved the viewport the
|
|
543
|
+
// that does not still moved the viewport the reach is measured in.
|
|
407
544
|
follow();
|
|
408
545
|
changed();
|
|
409
546
|
});
|
package/lib/toolbar.js
CHANGED
|
@@ -3,54 +3,32 @@
|
|
|
3
3
|
* host passed, in the order they were given. It sits at the right of the
|
|
4
4
|
* viewer's bar, beside the zoom menu; no exportable target, no group.
|
|
5
5
|
* The element owns the click — rendering with its current data and
|
|
6
|
-
* auto-downloading through `
|
|
6
|
+
* auto-downloading through `saveAs` below — because only it knows
|
|
7
7
|
* whether it is still connected when the file arrives.
|
|
8
8
|
*
|
|
9
|
-
* The
|
|
9
|
+
* The format's name goes in as a template value, never as a string spliced
|
|
10
10
|
* into a document, so this module has no markup edge to escape at and never
|
|
11
11
|
* grows one (hard constraint 4).
|
|
12
12
|
*/
|
|
13
13
|
import { html, nothing } from "lit";
|
|
14
14
|
import { button } from "./button.js";
|
|
15
|
+
import { download } from "./icons.js";
|
|
15
16
|
|
|
16
|
-
/** The exportable targets
|
|
17
|
-
|
|
17
|
+
/** The exportable targets, filed under the extension each downloads as: the
|
|
18
|
+
* content type its blob carries. The wording on the button and in a failure
|
|
19
|
+
* is that same name upper-cased, never a second spelling of it. */
|
|
20
|
+
/** @type {Record<string, string>} */
|
|
18
21
|
export let EXPORTS = {
|
|
19
|
-
pdf:
|
|
20
|
-
xlsx:
|
|
21
|
-
|
|
22
|
-
label: "XLSX",
|
|
23
|
-
},
|
|
24
|
-
csv: { type: "text/csv", label: "CSV" },
|
|
22
|
+
pdf: "application/pdf",
|
|
23
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
24
|
+
csv: "text/csv",
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
// Sheet outline with a folded corner, over the format's name.
|
|
28
|
-
/** @type {(label: string) => import('lit').TemplateResult} */
|
|
29
|
-
let icon = (label) => html`
|
|
30
|
-
<svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
|
|
31
|
-
<g fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round">
|
|
32
|
-
<path d="M4.5 2.5h7l4 4v11h-11z"></path>
|
|
33
|
-
<path d="M11.5 2.5v4h4"></path>
|
|
34
|
-
</g>
|
|
35
|
-
<text
|
|
36
|
-
x="10"
|
|
37
|
-
y="14.5"
|
|
38
|
-
text-anchor="middle"
|
|
39
|
-
font-size="5"
|
|
40
|
-
font-weight="700"
|
|
41
|
-
letter-spacing="0.2"
|
|
42
|
-
fill="currentColor"
|
|
43
|
-
>
|
|
44
|
-
${label}
|
|
45
|
-
</text>
|
|
46
|
-
</svg>
|
|
47
|
-
`;
|
|
48
|
-
|
|
49
27
|
// The anchor joins the document for the click — detached-anchor downloads
|
|
50
28
|
// are unreliable outside Chromium — and the URL is revoked well after the
|
|
51
29
|
// browser has had time to start reading the blob.
|
|
52
30
|
/** @type {(body: string | Uint8Array<ArrayBuffer>, name: string, type: string) => void} */
|
|
53
|
-
export let
|
|
31
|
+
export let saveAs = (body, name, type) => {
|
|
54
32
|
let url = URL.createObjectURL(new Blob([body], { type }));
|
|
55
33
|
let anchor = document.createElement("a");
|
|
56
34
|
anchor.href = url;
|
|
@@ -80,14 +58,17 @@ export let exportGroup = (targets, context) => {
|
|
|
80
58
|
return html`
|
|
81
59
|
<div class="qv-exports">
|
|
82
60
|
${exportable.map((target) => {
|
|
83
|
-
let
|
|
61
|
+
let label = target.name.toUpperCase();
|
|
84
62
|
return button({
|
|
85
|
-
//
|
|
63
|
+
// One drawn verb and the format in words beside it: the download
|
|
64
|
+
// icon says what the click does, and the format is information the
|
|
65
|
+
// reader needs rather than a glyph to decode (ADR 0044).
|
|
86
66
|
title: "Download " + label,
|
|
67
|
+
label,
|
|
87
68
|
name: target.name,
|
|
88
69
|
disabled: context.busy.has(target.name),
|
|
89
70
|
click: () => context.click(target),
|
|
90
|
-
content:
|
|
71
|
+
content: download,
|
|
91
72
|
});
|
|
92
73
|
})}
|
|
93
74
|
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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,21 +44,21 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@lit/task": "^1.0.3",
|
|
47
|
-
"@quario/layout": "^0.
|
|
47
|
+
"@quario/layout": "^0.2.0",
|
|
48
48
|
"lit": "^3.3.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@arethetypeswrong/cli": "^0.18.3",
|
|
52
|
+
"@cantoo/pdf-lib": "^2.9.1",
|
|
52
53
|
"@size-limit/preset-small-lib": "^13.0.3",
|
|
53
54
|
"esbuild": "^0.28.2",
|
|
54
55
|
"exceljs": "^4.4.0",
|
|
55
|
-
"
|
|
56
|
-
"quario": "^0.4.0",
|
|
56
|
+
"quario": "^0.5.0",
|
|
57
57
|
"size-limit": "^13.0.3",
|
|
58
58
|
"typescript": "^7.0.2"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"quario": "^0.
|
|
61
|
+
"quario": "^0.5.0"
|
|
62
62
|
},
|
|
63
63
|
"size-limit": [
|
|
64
64
|
{
|