@quario/viewer 0.4.0 → 0.6.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 +52 -0
- package/README.md +28 -17
- 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 +9 -8
- package/lib/index.js +25 -30
- package/lib/menu.js +17 -30
- package/lib/panel.js +2 -1
- package/lib/stage.js +257 -93
- package/lib/toolbar.js +17 -36
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Numbers presented through `format` now show a fixed two fraction digits**,
|
|
15
|
+
the same as every other target: `1,000.00` where the preview showed `1,000`,
|
|
16
|
+
`21.00%` where it showed `21%`, and a currency's own minor units in place of
|
|
17
|
+
a universal two. The digits come from the engine, so a page on screen and
|
|
18
|
+
the PDF of it agree. A formatted cell is also up to three characters wider,
|
|
19
|
+
so a line that just fitted can wrap and move a page break.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **A page the browser cannot fully paint no longer fails the render.** An
|
|
24
|
+
image whose pixel data is corrupt past the size in its header left that page
|
|
25
|
+
blank _and_ rejected `renderComplete`, so a host awaiting it saw an unhandled
|
|
26
|
+
rejection instead of an answer, and every page after it on screen went
|
|
27
|
+
unpainted too. Such an image is now drawn as nothing and the page is drawn
|
|
28
|
+
around it, marking and all; `renderComplete` resolves `true`, `rendered`
|
|
29
|
+
fires, and no `error` event is raised. It never rejects for this reason
|
|
30
|
+
again: a page that cannot be drawn is blank, not a failed render.
|
|
31
|
+
|
|
32
|
+
## [0.5.0] - 2026-09-05
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- **Previewing with `fonts` now needs `fontkit`, not `@pdf-lib/fontkit`.**
|
|
37
|
+
Install `fontkit` instead. The viewer measures host TrueType faces through
|
|
38
|
+
`@quario/layout`, whose optional parser this is; the old package crashed on
|
|
39
|
+
any OpenType face needing a shaping state machine, so those faces now
|
|
40
|
+
preview where they used to throw.
|
|
41
|
+
|
|
42
|
+
- **The bar's controls are drawn icons.** The zoom trigger, the menu's check
|
|
43
|
+
and the error panel's dismiss are drawn from Lucide's set rather than by
|
|
44
|
+
hand, at one weight and one grid, and the dismiss no longer relies on the
|
|
45
|
+
host's font to draw a multiplication sign. They paint in `currentColor`, so
|
|
46
|
+
`--qv-icon` and `--qv-icon-active` still recolour them.
|
|
47
|
+
|
|
48
|
+
- **An export button names its format in words.** Each one is a download icon
|
|
49
|
+
with `PDF`, `XLSX` or `CSV` beside it, in place of the sheet drawing that
|
|
50
|
+
lettered the format inside itself. The buttons are wider; the accessible
|
|
51
|
+
name is unchanged.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **A zoom no longer leaves a page part-drawn.** Changing the zoom while a
|
|
56
|
+
page was still painting could leave that page carrying content at the old
|
|
57
|
+
scale on a canvas sized for the new one — the rest of it blank — and nothing
|
|
58
|
+
repainted it afterwards: scrolling away and back left it as it was. A page
|
|
59
|
+
whose paint is still in flight is now replaced rather than reused, so the
|
|
60
|
+
superseded paint has nowhere to land.
|
|
61
|
+
|
|
10
62
|
## [0.4.0] - 2026-09-03
|
|
11
63
|
|
|
12
64
|
### 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,23 +87,30 @@ 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
|
|
97
|
-
on screen
|
|
98
|
-
arrive on the `error` event. `rendered` fires at that same moment. Like every
|
|
99
|
-
answers for the newest render only. A superseded render's failure is reported to no
|
|
99
|
+
`renderComplete` awaits the newest render settling: `true` when it landed on the sheet with the pages
|
|
100
|
+
on screen finished trying to paint, `false` when it failed or there was nothing to render. It never
|
|
101
|
+
rejects; failures arrive on the `error` event. `rendered` fires at that same moment. Like every
|
|
102
|
+
outcome here, it answers for the newest render only. A superseded render's failure is reported to no
|
|
103
|
+
one.
|
|
100
104
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
An image the browser cannot decode — pixel data corrupt past the size in its header, which is all
|
|
106
|
+
the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
|
|
107
|
+
failure: `renderComplete` answers `true`, `rendered` fires, and no `error` event is raised. A face in
|
|
108
|
+
`fonts` is a different story: it is parsed while the report is measured, so bytes that will not parse
|
|
109
|
+
are a render error with a panel.
|
|
110
|
+
|
|
111
|
+
Your own mistakes surface on the same channel: a report that is not compiled, or a malformed
|
|
112
|
+
option property, becomes a `TypeError` naming the property, on the `error` event and the
|
|
113
|
+
[error panel](#errors). No particular target is required — the sheet is the layout's own.
|
|
104
114
|
|
|
105
115
|
## Lifecycle
|
|
106
116
|
|
|
@@ -247,7 +257,8 @@ export marks every page: it rides on the layout, so what you see is what the doc
|
|
|
247
257
|
licensed render carries none.
|
|
248
258
|
|
|
249
259
|
While a render is in flight a thin indeterminate bar sits on the toolbar's bottom edge, and the
|
|
250
|
-
viewer
|
|
260
|
+
viewer's inner container — the `.qv-viewer` div in its shadow root, not the element itself —
|
|
261
|
+
reads `aria-busy="true"`. It reports that the viewer is working, not how far along. The
|
|
251
262
|
engine streams events and cannot know how many are still coming.
|
|
252
263
|
|
|
253
264
|
There is no Print button, because the sheet is the wrong thing to print. Your page's stylesheets do
|
|
@@ -258,9 +269,9 @@ document, the same bytes the PDF export hands over. A host that wants its own Pr
|
|
|
258
269
|
two lines:
|
|
259
270
|
|
|
260
271
|
```js
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
const bytes = await
|
|
272
|
+
// The same compiled report and data the element holds, through the pdf()
|
|
273
|
+
// target from the quick start — so what prints is exactly the PDF export.
|
|
274
|
+
const bytes = await view.report.render(pdf(), view.data);
|
|
264
275
|
window.open(URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })));
|
|
265
276
|
```
|
|
266
277
|
|
|
@@ -270,8 +281,8 @@ the export is, watermark and all.
|
|
|
270
281
|
## Errors
|
|
271
282
|
|
|
272
283
|
When a render or an export fails, the viewer says so on the **error panel**, a strip across the top
|
|
273
|
-
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
|
|
284
|
+
of the sheet carrying a short label — "Could not render the report", "Could not update the report — showing the previous version", or "Could not export PDF" — and the error's own message. It replaces rather than stacks, is
|
|
285
|
+
dismissed by its own button, and is cleared by the next render that lands on the sheet. A successful
|
|
275
286
|
export leaves it up, because the panel describes what you are looking at and a download says
|
|
276
287
|
nothing about that.
|
|
277
288
|
|
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,11 +96,12 @@ export class QuarioViewer extends LitElement {
|
|
|
96
96
|
*/
|
|
97
97
|
colorScheme: "light" | "dark" | "auto" | undefined;
|
|
98
98
|
/**
|
|
99
|
-
* The newest render settling: `true` when it
|
|
100
|
-
* pages on screen
|
|
101
|
-
* render.
|
|
102
|
-
*
|
|
103
|
-
*
|
|
99
|
+
* The newest render settling: `true` when it landed on the sheet with the
|
|
100
|
+
* pages on screen finished trying to paint, `false` when it failed or there
|
|
101
|
+
* was nothing to render. A page the browser could not draw is blank and the
|
|
102
|
+
* render still landed. Never rejects — failures are the
|
|
103
|
+
* `error` event's — and like every outcome here it answers for the newest
|
|
104
|
+
* render only. `rendered` fires at that same moment.
|
|
104
105
|
*/
|
|
105
106
|
get renderComplete(): Promise<boolean>;
|
|
106
107
|
|
|
@@ -128,7 +129,7 @@ export class QuarioViewer extends LitElement {
|
|
|
128
129
|
|
|
129
130
|
/** The element's events. Both are non-bubbling, like `<img>`'s. */
|
|
130
131
|
export interface QuarioViewerEventMap {
|
|
131
|
-
/** A render
|
|
132
|
+
/** A render landed on the sheet — the mount and every update. */
|
|
132
133
|
rendered: CustomEvent<undefined>;
|
|
133
134
|
/** A failure the host should know about; `detail.kind` says which. */
|
|
134
135
|
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;
|
|
@@ -112,7 +112,13 @@ export class QuarioViewer extends LitElement {
|
|
|
112
112
|
#exporting = new Set();
|
|
113
113
|
/** @type {(() => void) | undefined} */
|
|
114
114
|
#unwatch;
|
|
115
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* The newest swap's paint, which `renderComplete` waits behind. Bare, with
|
|
117
|
+
* no catch of its own: the stage settles a page it cannot draw rather than
|
|
118
|
+
* rejecting, so the chain below is reached whatever the pixels did.
|
|
119
|
+
*
|
|
120
|
+
* @type {Promise<void>}
|
|
121
|
+
*/
|
|
116
122
|
#painting = Promise.resolve();
|
|
117
123
|
|
|
118
124
|
// The whole async pipeline: keyed on the render properties, re-run when one
|
|
@@ -135,7 +141,7 @@ export class QuarioViewer extends LitElement {
|
|
|
135
141
|
// The engine takes no signal, so abandonment is the guards around this
|
|
136
142
|
// body; the check only spares the swap when the answer arrives after a
|
|
137
143
|
// disconnect mid-render.
|
|
138
|
-
if (this
|
|
144
|
+
if (signal.aborted && !this.isConnected) return this.#abandon();
|
|
139
145
|
return { list, fonts };
|
|
140
146
|
},
|
|
141
147
|
onComplete: (result) => {
|
|
@@ -145,7 +151,7 @@ export class QuarioViewer extends LitElement {
|
|
|
145
151
|
this.#painting = this.#stage.swap(result.list, result.fonts).then(() => {
|
|
146
152
|
this.#landed = true;
|
|
147
153
|
this.#stale = false;
|
|
148
|
-
// A render that
|
|
154
|
+
// A render that landed on the sheet takes the panel down: the panel
|
|
149
155
|
// says what is wrong with what the reader is looking at, and this is
|
|
150
156
|
// the moment that stops being true. A successful export is not that
|
|
151
157
|
// moment.
|
|
@@ -186,10 +192,13 @@ export class QuarioViewer extends LitElement {
|
|
|
186
192
|
}
|
|
187
193
|
|
|
188
194
|
/**
|
|
189
|
-
* The newest render settling: `true` when it
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
195
|
+
* The newest render settling: `true` when it landed on the sheet — after
|
|
196
|
+
* its pages have finished trying to paint — `false` when it failed or there
|
|
197
|
+
* was nothing to render. A page the browser could not draw is blank and the
|
|
198
|
+
* render still landed, so `true` promises the sheet is done changing, not
|
|
199
|
+
* that every page carries pixels. It never rejects — a failed
|
|
200
|
+
* render is handled, on the panel and through the error event — and like
|
|
201
|
+
* every outcome here it answers for the newest render only.
|
|
193
202
|
*
|
|
194
203
|
* @returns {Promise<boolean>}
|
|
195
204
|
*/
|
|
@@ -238,7 +247,7 @@ export class QuarioViewer extends LitElement {
|
|
|
238
247
|
})}
|
|
239
248
|
</div>
|
|
240
249
|
<div class="qv-body">
|
|
241
|
-
${this.#panel()}
|
|
250
|
+
${this.#failure && !this.#dismissed ? panel(this.#failure, () => this.#dismiss()) : ""}
|
|
242
251
|
${this.#stage.element}
|
|
243
252
|
</div>
|
|
244
253
|
</div>
|
|
@@ -259,7 +268,9 @@ export class QuarioViewer extends LitElement {
|
|
|
259
268
|
super.connectedCallback();
|
|
260
269
|
// A fitted sheet follows the host's box; the observer is released on
|
|
261
270
|
// disconnect, so reconnection takes it again.
|
|
262
|
-
this.#
|
|
271
|
+
this.#unwatch ??= this.#stage.watch(() => {
|
|
272
|
+
if (this.#mode === "fit" && this.#wanted() !== this.#stage.percent()) this.#apply();
|
|
273
|
+
});
|
|
263
274
|
// Reconnect re-renders from the current properties — but only when the
|
|
264
275
|
// sheet stopped matching them: a render was abandoned mid-flight, or a
|
|
265
276
|
// property write landed while disconnected. The task's arguments did not
|
|
@@ -295,11 +306,6 @@ export class QuarioViewer extends LitElement {
|
|
|
295
306
|
return true;
|
|
296
307
|
}
|
|
297
308
|
|
|
298
|
-
/** @param {AbortSignal} signal */
|
|
299
|
-
#drop(signal) {
|
|
300
|
-
return signal.aborted && !this.isConnected;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
309
|
#abandon() {
|
|
304
310
|
this.#stale = true;
|
|
305
311
|
return null;
|
|
@@ -361,18 +367,6 @@ export class QuarioViewer extends LitElement {
|
|
|
361
367
|
if (said(was) !== said(this.#invalid)) this.#epoch++;
|
|
362
368
|
}
|
|
363
369
|
|
|
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
370
|
#wake() {
|
|
377
371
|
if (this.#stale || this.#task.status === TaskStatus.PENDING) {
|
|
378
372
|
this.#epoch++;
|
|
@@ -442,12 +436,13 @@ export class QuarioViewer extends LitElement {
|
|
|
442
436
|
* @param {any} target
|
|
443
437
|
*/
|
|
444
438
|
async #export(target) {
|
|
445
|
-
let
|
|
439
|
+
let type = EXPORTS[target.name];
|
|
440
|
+
let format = target.name.toUpperCase();
|
|
446
441
|
this.#exporting.add(target.name);
|
|
447
442
|
this.requestUpdate();
|
|
448
443
|
try {
|
|
449
444
|
let body = await /** @type {any} */ (this.report).render(target, this.data);
|
|
450
|
-
this.#ifConnected(() =>
|
|
445
|
+
this.#ifConnected(() => saveAs(body, this.#name + "." + target.name, type));
|
|
451
446
|
} catch (error) {
|
|
452
447
|
this.#ifConnected(() => this.#announce(error, "export", format));
|
|
453
448
|
} 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,41 @@
|
|
|
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 page the browser will not paint is blank, not a failure.** `start`
|
|
25
|
+
* swallows and nothing here rejects: the report laid out and the list reached
|
|
26
|
+
* the sheet, which is all a render promised, so the surfaces have no channel
|
|
27
|
+
* for it and deliberately grow none.
|
|
28
|
+
*
|
|
29
|
+
* Little reaches it. An image that will not decode is the layout's own to
|
|
30
|
+
* absorb — `paint` draws it as nothing and draws the page around it — and a
|
|
31
|
+
* face is parsed with fontkit while the report is measured, so bytes that will
|
|
32
|
+
* not parse are a render error long before the sheet. What is left is the face
|
|
33
|
+
* fontkit accepted and the browser refuses, which the browser suite drives by
|
|
34
|
+
* making `FontFace.prototype.load` reject under a record handed straight to
|
|
35
|
+
* `swap`.
|
|
36
|
+
*
|
|
37
|
+
* **A canvas on the sheet is never re-pointed at a second paint.** `paint()`
|
|
38
|
+
* awaits before it draws, so a canvas whose paint is still in flight is
|
|
39
|
+
* *retired* rather than painted over or emptied: it comes off the sheet, its
|
|
40
|
+
* pixels go back, and a fresh one stands in its place, so the superseded
|
|
41
|
+
* paint draws into an element nobody is looking at. A canvas whose paint has
|
|
42
|
+
* settled has nothing that could land late and is re-sized or emptied in
|
|
43
|
+
* place as before. Without this a zoom landing mid-paint could leave a page
|
|
44
|
+
* carrying old-scale content on a new-scale canvas, with the memo below
|
|
45
|
+
* calling it painted so that nothing repainted it again (ADR 0046).
|
|
46
|
+
*
|
|
47
|
+
* Retirement is lazy, and only the paint is guarded. A scale change sizes
|
|
48
|
+
* every page's CSS box at once but retires a page only when the repaint loop
|
|
49
|
+
* reaches it, so a page further down the reach shows its old pixels stretched
|
|
50
|
+
* into the new box until its turn comes — the ordinary look of a zoom in
|
|
51
|
+
* progress, not the artefact above. The cost is that a retired page is a
|
|
52
|
+
* replaced `role="img"` node: assistive technology reading that page sees it
|
|
53
|
+
* swapped under them. Judged acceptable because retirement only happens while
|
|
54
|
+
* that very page is mid-repaint and about to change what it shows anyway, and
|
|
55
|
+
* because the alternative — a stable wrapper element per page to announce
|
|
56
|
+
* from — is a second element per page on a sheet ADR 0043 exists to keep
|
|
57
|
+
* cheap.
|
|
58
|
+
*
|
|
24
59
|
* What the stage does not decide is which percentage to show: `fit()`
|
|
25
60
|
* measures what would make one page span the width available, and `zoom.js`
|
|
26
61
|
* owns the policy over that answer. What is on screen, though, is the
|
|
@@ -79,7 +114,7 @@ export let SURFACE = css`
|
|
|
79
114
|
}
|
|
80
115
|
|
|
81
116
|
/* One page. The painter fills it white; the background here is what a page
|
|
82
|
-
outside the
|
|
117
|
+
outside the reach shows, and what one inside it shows between being sized
|
|
83
118
|
and being painted. */
|
|
84
119
|
.qv-page {
|
|
85
120
|
display: block;
|
|
@@ -131,7 +166,7 @@ export let stage = () => {
|
|
|
131
166
|
|
|
132
167
|
/**
|
|
133
168
|
* What each backed page is carrying: the scale it was painted at, so a page
|
|
134
|
-
* re-entering the
|
|
169
|
+
* re-entering the reach at that scale is not repainted and a scale change
|
|
135
170
|
* invalidates it, and the paint itself, so whoever asks second waits behind
|
|
136
171
|
* the paint the first caller started rather than being told it is done.
|
|
137
172
|
* Keyed by the canvas, so a swap's new pages start with nothing remembered
|
|
@@ -143,84 +178,224 @@ export let stage = () => {
|
|
|
143
178
|
let backed = new WeakMap();
|
|
144
179
|
|
|
145
180
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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.
|
|
150
191
|
*
|
|
151
|
-
* @type {
|
|
192
|
+
* @type {WeakSet<HTMLCanvasElement>}
|
|
152
193
|
*/
|
|
153
|
-
let
|
|
194
|
+
let settled = new WeakSet();
|
|
195
|
+
|
|
196
|
+
/** CSS pixels per point at the applied percentage. */
|
|
197
|
+
let ratio = () => (PX_PER_POINT * applied) / 100;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The canvas standing for page `i` right now. Read through here and never
|
|
201
|
+
* held: a retired page is a different element, so a canvas taken before an
|
|
202
|
+
* await may be off the sheet by the time it is used.
|
|
203
|
+
*
|
|
204
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
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);
|
|
154
283
|
if (!backed.has(canvas)) return;
|
|
284
|
+
if (!settled.has(canvas)) return void retire(i);
|
|
155
285
|
backed.delete(canvas);
|
|
156
286
|
canvas.width = 0;
|
|
157
287
|
canvas.height = 0;
|
|
158
288
|
};
|
|
159
289
|
|
|
160
|
-
/**
|
|
161
|
-
*
|
|
162
|
-
let
|
|
163
|
-
|
|
164
|
-
/** CSS pixels per point at the applied percentage. */
|
|
165
|
-
let ratio = () => (PX_PER_POINT * applied) / 100;
|
|
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);
|
|
166
293
|
|
|
167
294
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* pass over the
|
|
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`.
|
|
174
302
|
*
|
|
175
|
-
*
|
|
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>}
|
|
176
318
|
*/
|
|
177
|
-
let
|
|
178
|
-
let
|
|
179
|
-
|
|
180
|
-
|
|
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();
|
|
181
324
|
let each = list.pages[i];
|
|
182
325
|
canvas.width = Math.round(each.width * scale);
|
|
183
326
|
canvas.height = Math.round(each.height * scale);
|
|
184
327
|
let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
|
|
185
|
-
let painted = paint(ctx, each, { scale, fonts }).
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
328
|
+
let painted = paint(ctx, each, { scale, fonts }).then(
|
|
329
|
+
() => {
|
|
330
|
+
settled.add(canvas);
|
|
331
|
+
},
|
|
332
|
+
() => {
|
|
333
|
+
backed.delete(canvas);
|
|
334
|
+
},
|
|
335
|
+
);
|
|
189
336
|
backed.set(canvas, { scale, painted });
|
|
190
337
|
return painted;
|
|
191
338
|
};
|
|
192
339
|
|
|
193
|
-
/**
|
|
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
|
+
*/
|
|
194
373
|
let epoch = 0;
|
|
195
374
|
|
|
196
375
|
/**
|
|
197
376
|
* Give every page its CSS size. Callers run this before writing the scroll
|
|
198
377
|
* offsets back: the extent those offsets are clamped against is this one,
|
|
199
|
-
* and the
|
|
378
|
+
* and the reach below is read from the offsets once they are in.
|
|
200
379
|
*/
|
|
201
380
|
let sizeAll = () => {
|
|
202
381
|
if (!list) return;
|
|
203
382
|
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
|
-
}
|
|
383
|
+
for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
|
|
209
384
|
};
|
|
210
385
|
|
|
211
386
|
/**
|
|
212
|
-
* The indices of the pages in the
|
|
387
|
+
* The indices of the pages in the reach — the ones the viewport shows, and
|
|
213
388
|
* everything within one viewport height above or below. Walked over the
|
|
214
389
|
* list's own geometry (the gutter, each page's height at the applied
|
|
215
390
|
* scale, the gap), never a layout read, so it costs nothing to ask
|
|
216
391
|
* mid-scroll — the whole list is walked because a multiply and an add per
|
|
217
392
|
* page is beneath measuring, and stopping early would be a second rule
|
|
218
|
-
* about where the
|
|
219
|
-
* pane that changed size changes the
|
|
393
|
+
* about where the reach ends. `clientHeight` is read afresh each time, so a
|
|
394
|
+
* pane that changed size changes the reach with it.
|
|
220
395
|
*
|
|
221
396
|
* @returns {number[]}
|
|
222
397
|
*/
|
|
223
|
-
let
|
|
398
|
+
let inReach = () => {
|
|
224
399
|
let view = scroll.clientHeight;
|
|
225
400
|
let top = scroll.scrollTop - view;
|
|
226
401
|
let bottom = scroll.scrollTop + 2 * view;
|
|
@@ -236,73 +411,58 @@ export let stage = () => {
|
|
|
236
411
|
};
|
|
237
412
|
|
|
238
413
|
/**
|
|
239
|
-
* Release every page the
|
|
240
|
-
*
|
|
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
|
|
241
416
|
* definition of which pages carry pixels: the repaint below and the scroll
|
|
242
417
|
* pass both go through here, and neither holds a page the other released.
|
|
243
418
|
*
|
|
244
|
-
*
|
|
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[]}
|
|
245
423
|
*/
|
|
246
424
|
let keep = () => {
|
|
247
|
-
let wanted = new Set(
|
|
425
|
+
let wanted = new Set(inReach());
|
|
248
426
|
let kept = [];
|
|
249
|
-
for
|
|
250
|
-
|
|
251
|
-
if (wanted.has(i)) kept.push(
|
|
252
|
-
else release(
|
|
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);
|
|
253
431
|
}
|
|
254
432
|
return kept;
|
|
255
433
|
};
|
|
256
434
|
|
|
257
|
-
// Paint the
|
|
435
|
+
// Paint the reach in order, yielding between pages and giving way to any
|
|
258
436
|
// repaint that started since. Sizing is the caller's, and comes first.
|
|
259
437
|
let repaint = async () => {
|
|
260
438
|
if (!list) return;
|
|
261
439
|
let mine = ++epoch;
|
|
262
|
-
for (let
|
|
440
|
+
for (let i of keep()) {
|
|
263
441
|
if (mine !== epoch) return;
|
|
264
|
-
await paintPage(
|
|
442
|
+
await paintPage(i);
|
|
265
443
|
}
|
|
266
444
|
};
|
|
267
445
|
|
|
268
446
|
/**
|
|
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
|
-
*
|
|
274
|
-
*
|
|
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.
|
|
275
453
|
*/
|
|
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
454
|
let pending = false;
|
|
283
455
|
let follow = () => {
|
|
284
456
|
if (pending) return;
|
|
285
457
|
pending = true;
|
|
286
458
|
requestAnimationFrame(() => {
|
|
287
459
|
pending = false;
|
|
288
|
-
|
|
460
|
+
if (!list) return;
|
|
461
|
+
for (let i of keep()) void paintPage(i);
|
|
289
462
|
});
|
|
290
463
|
};
|
|
291
464
|
scroll.addEventListener("scroll", follow);
|
|
292
465
|
|
|
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
466
|
return {
|
|
307
467
|
element: scroll,
|
|
308
468
|
|
|
@@ -314,7 +474,8 @@ export let stage = () => {
|
|
|
314
474
|
*/
|
|
315
475
|
fit: () => {
|
|
316
476
|
let usable = scroll.clientWidth - 2 * GUTTER;
|
|
317
|
-
|
|
477
|
+
// The list's page width, or the geometry's before one arrives.
|
|
478
|
+
let width = (list ? list.width : fallback) * PX_PER_POINT;
|
|
318
479
|
if (usable <= 0 || !width) return null;
|
|
319
480
|
return (usable / width) * 100;
|
|
320
481
|
},
|
|
@@ -333,7 +494,7 @@ export let stage = () => {
|
|
|
333
494
|
* seen. The browser clamps whatever it cannot honour. An empty sheet has
|
|
334
495
|
* no view to hold, which is what mounting at an authored zoom takes.
|
|
335
496
|
*
|
|
336
|
-
* Only the
|
|
497
|
+
* Only the reach is repainted, and only where the scale actually changed,
|
|
337
498
|
* so a zoom step costs a handful of pages however long the report is.
|
|
338
499
|
*/
|
|
339
500
|
scale: (percent) => {
|
|
@@ -367,26 +528,29 @@ export let stage = () => {
|
|
|
367
528
|
|
|
368
529
|
/**
|
|
369
530
|
* 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
|
-
*
|
|
376
|
-
*
|
|
531
|
+
* were: one canvas per page, the reach among them painted at the applied
|
|
532
|
+
* scale. The order is the point of the method: the pages come off
|
|
533
|
+
* `pageOf` already at their size, so the browser clamps the offsets going
|
|
534
|
+
* back against the extent the sheet will have rather than the one it had
|
|
535
|
+
* — and the reach is read from those offsets, so it is chosen after they
|
|
536
|
+
* are in. What the
|
|
537
|
+
* returned promise settles behind is the reach, which is what the caller's
|
|
538
|
+
* `renderComplete` means by "the pages on screen have finished trying to
|
|
539
|
+
* paint" — and **it never rejects**, because `start` swallows a page the
|
|
540
|
+
* browser will not draw and the reach walks on past it.
|
|
377
541
|
*/
|
|
378
542
|
swap: async (next, faces) => {
|
|
379
543
|
// Reading the offsets flushes layout, so only an actual reswap pays for
|
|
380
544
|
// it: on an empty sheet there is nothing scrolled to preserve.
|
|
381
|
-
let held = sheet.firstChild && {
|
|
545
|
+
let held = sheet.firstChild && {
|
|
546
|
+
top: scroll.scrollTop,
|
|
547
|
+
left: scroll.scrollLeft,
|
|
548
|
+
};
|
|
382
549
|
list = next;
|
|
383
550
|
fonts = faces;
|
|
384
551
|
sheet.replaceChildren(
|
|
385
|
-
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) =>
|
|
386
|
-
pageOf(i, next.pages.length),
|
|
387
|
-
),
|
|
552
|
+
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
|
|
388
553
|
);
|
|
389
|
-
sizeAll();
|
|
390
554
|
if (held) {
|
|
391
555
|
scroll.scrollTop = held.top;
|
|
392
556
|
scroll.scrollLeft = held.left;
|
|
@@ -403,7 +567,7 @@ export let stage = () => {
|
|
|
403
567
|
watch: (changed) => {
|
|
404
568
|
let observer = new ResizeObserver(() => {
|
|
405
569
|
// A resize that changes the fit repaints through `changed()`; one
|
|
406
|
-
// that does not still moved the viewport the
|
|
570
|
+
// that does not still moved the viewport the reach is measured in.
|
|
407
571
|
follow();
|
|
408
572
|
changed();
|
|
409
573
|
});
|
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.6.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.3.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.6.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.6.0"
|
|
62
62
|
},
|
|
63
63
|
"size-limit": [
|
|
64
64
|
{
|