@quario/viewer 0.3.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 +73 -0
- package/README.md +84 -75
- package/lib/button.js +11 -4
- package/lib/check.js +23 -63
- package/lib/chrome.js +22 -4
- package/lib/icons.js +25 -0
- package/lib/index.d.ts +37 -29
- package/lib/index.js +68 -63
- package/lib/menu.js +20 -33
- package/lib/panel.js +2 -1
- package/lib/stage.js +410 -235
- package/lib/toolbar.js +17 -36
- package/package.json +6 -4
- package/lib/mark.js +0 -40
- package/lib/style.js +0 -123
package/lib/stage.js
CHANGED
|
@@ -1,23 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The stage: the surface
|
|
3
|
-
* the reader looks through, the wrapper that carries
|
|
4
|
-
*
|
|
5
|
-
* and every number
|
|
2
|
+
* The stage: the surface the pages are painted on. It owns the scroll
|
|
3
|
+
* container the reader looks through, the wrapper that carries the gutter,
|
|
4
|
+
* and the sheet — one canvas per page of the layout list, painted by
|
|
5
|
+
* `@quario/layout`'s `paint` — their elements, their CSS, and every number
|
|
6
|
+
* relating them.
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Zoom is a repaint, not a transform: each page canvas is sized to the
|
|
9
|
+
* percentage in CSS pixels and painted at that scale times the device pixel
|
|
10
|
+
* ratio, so text stays crisp at 200% and a screenshot at 50% is what the
|
|
11
|
+
* reader saw. The list never changes under a zoom — the preview scales and
|
|
12
|
+
* never reflows (zoom.js says why).
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Every page keeps its CSS size, but only the
|
|
15
|
+
* [reach](../../../CONTEXT.md#reach) carries pixels — the pages on screen and
|
|
16
|
+
* one viewport height either side. The extent the reader scrolls through is
|
|
17
|
+
* whole and synchronous whatever is painted; what comes and goes is the
|
|
18
|
+
* backing store, released by sizing a canvas to 0 × 0. A scroll or a resize
|
|
19
|
+
* runs the same arithmetic over the list's own geometry, coalesced to one
|
|
20
|
+
* pass a frame. ADR 0043 says what that is worth: painting the whole sheet
|
|
21
|
+
* asked a thousand-page report for gigabytes of backing store, and past what
|
|
22
|
+
* the browser would grant the pages simply came up blank.
|
|
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
|
+
*
|
|
46
|
+
* What the stage does not decide is which percentage to show: `fit()`
|
|
47
|
+
* measures what would make one page span the width available, and `zoom.js`
|
|
48
|
+
* owns the policy over that answer. What is on screen, though, is the
|
|
49
|
+
* stage's own — `percent()` reports it, so no caller keeps a second copy.
|
|
50
|
+
*
|
|
51
|
+
* The editor builds its own stack of pages next door, and deliberately: what
|
|
52
|
+
* the two surfaces share is the display list and the size of a point, not the
|
|
53
|
+
* sheet. Each is a canvas element, a device-pixel size and a `paint()` call
|
|
54
|
+
* around policy neither could lend the other — this one repaints under zoom
|
|
55
|
+
* and guards a superseded repaint, that one builds once per swap and lays box
|
|
56
|
+
* elements over the result.
|
|
16
57
|
*/
|
|
17
58
|
|
|
18
59
|
import { css } from "lit";
|
|
19
|
-
import {
|
|
20
|
-
import { FACE, angle, centres, size, span } from "./mark.js";
|
|
60
|
+
import { PX_PER_POINT, paint } from "@quario/layout";
|
|
21
61
|
|
|
22
62
|
/**
|
|
23
63
|
* Space around the sheet, in px. Read from here and nowhere else: the stage's
|
|
@@ -26,10 +66,13 @@ import { FACE, angle, centres, size, span } from "./mark.js";
|
|
|
26
66
|
*/
|
|
27
67
|
let GUTTER = 28;
|
|
28
68
|
|
|
69
|
+
/** The gap between two pages, in px. */
|
|
70
|
+
let GAP = 16;
|
|
71
|
+
|
|
29
72
|
export let SURFACE = css`
|
|
30
73
|
/* Where scrollbars take width, the gutter is held whether one is showing or
|
|
31
74
|
not, which is what rules out a feedback loop in the observer \`watch()\`
|
|
32
|
-
starts:
|
|
75
|
+
starts: painting sets the pages' height, the height is what decides
|
|
33
76
|
whether this element needs a vertical scrollbar, and a scrollbar that came
|
|
34
77
|
and went would change the width \`fit()\` measures. Reserving it permanently
|
|
35
78
|
means the width fit reads cannot move in response to the height fit writes.
|
|
@@ -43,168 +86,45 @@ export let SURFACE = css`
|
|
|
43
86
|
scrollbar-gutter: stable;
|
|
44
87
|
}
|
|
45
88
|
|
|
46
|
-
/* The wrapper carries the
|
|
47
|
-
|
|
48
|
-
the vertical margin is the gutter as such — the horizontal ones centre
|
|
49
|
-
whatever slack the fit left over. */
|
|
89
|
+
/* The wrapper carries the gutter. Only the vertical margin is the gutter as
|
|
90
|
+
such — the horizontal ones centre whatever slack the fit left over. */
|
|
50
91
|
.qv-stage {
|
|
92
|
+
width: fit-content;
|
|
51
93
|
margin: ${GUTTER}px auto;
|
|
52
94
|
}
|
|
53
95
|
|
|
54
|
-
/*
|
|
96
|
+
/* The sheet is the stack of pages. */
|
|
55
97
|
.qv-sheet {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
background: #fff;
|
|
60
|
-
color: #000;
|
|
61
|
-
/* Sheet geometry only. The report text's own family comes from the shared
|
|
62
|
-
report stylesheet, which the html target ships byte-identically -- a
|
|
63
|
-
preview and an export that disagree on typeface is the bug this splits
|
|
64
|
-
apart (docs/adr/0014). */
|
|
65
|
-
font-size: 10pt;
|
|
66
|
-
line-height: 1.4;
|
|
67
|
-
box-shadow: var(--_sheet-shadow);
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: ${GAP}px;
|
|
68
101
|
}
|
|
69
102
|
|
|
70
|
-
/*
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.qv-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
pointer-events: none;
|
|
78
|
-
user-select: none;
|
|
79
|
-
z-index: 1;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
.qv-mark {
|
|
83
|
-
position: absolute;
|
|
84
|
-
left: 50%;
|
|
85
|
-
height: 0;
|
|
86
|
-
width: 0;
|
|
87
|
-
overflow: visible;
|
|
88
|
-
text-align: center;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.qv-mark span {
|
|
92
|
-
display: inline-block;
|
|
93
|
-
white-space: nowrap;
|
|
94
|
-
color: var(--_mark);
|
|
95
|
-
opacity: 0.15;
|
|
103
|
+
/* One page. The painter fills it white; the background here is what a page
|
|
104
|
+
outside the reach shows, and what one inside it shows between being sized
|
|
105
|
+
and being painted. */
|
|
106
|
+
.qv-page {
|
|
107
|
+
display: block;
|
|
108
|
+
background: #fff;
|
|
109
|
+
box-shadow: var(--_sheet-shadow);
|
|
96
110
|
}
|
|
97
111
|
`;
|
|
98
112
|
|
|
99
113
|
/**
|
|
100
114
|
* @typedef {{ width: number, height: number, margin: number }} PageBox
|
|
101
|
-
* @typedef {{ text: string, turn: number, fontSize: number }} Face
|
|
102
115
|
* @typedef {{ element: HTMLElement, fit: () => number | null,
|
|
103
116
|
* percent: () => number, scale: (percent: number) => void,
|
|
104
117
|
* resize: (geometry: PageBox) => void,
|
|
105
|
-
* swap: (
|
|
118
|
+
* swap: (list: any, fonts: any) => Promise<void>,
|
|
106
119
|
* watch: (changed: () => void) => () => void }} Stage
|
|
107
120
|
*/
|
|
108
121
|
|
|
109
|
-
/** @type {(sheet: HTMLElement, text: string, pxPerPt: number) => number} */
|
|
110
|
-
let measure = (sheet, text, pxPerPt) => {
|
|
111
|
-
let probe = document.createElement("span");
|
|
112
|
-
probe.style.cssText =
|
|
113
|
-
"position:absolute;visibility:hidden;white-space:nowrap;font:" +
|
|
114
|
-
FACE +
|
|
115
|
-
// The face the marking is actually drawn in, or the measurement sizes
|
|
116
|
-
// wording that will be laid out in something else.
|
|
117
|
-
"pt sans-serif";
|
|
118
|
-
probe.textContent = text;
|
|
119
|
-
sheet.append(probe);
|
|
120
|
-
let width = probe.offsetWidth / pxPerPt;
|
|
121
|
-
probe.remove();
|
|
122
|
-
return width;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/** @type {(sheet: HTMLElement, page: PageBox) => number} */
|
|
126
|
-
let rateOf = (sheet, page) => (page.width ? sheet.offsetWidth / page.width : 0);
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* @param {HTMLElement} sheet
|
|
130
|
-
* @param {PageBox} page
|
|
131
|
-
* @param {string} text
|
|
132
|
-
* @param {number} pxPerPt
|
|
133
|
-
* @returns {Face}
|
|
134
|
-
*/
|
|
135
|
-
let faceAt = (sheet, page, text, pxPerPt) => ({
|
|
136
|
-
text,
|
|
137
|
-
turn: angle(page.width, page.height),
|
|
138
|
-
fontSize: size(span(page.width, page.height), measure(sheet, text, pxPerPt)),
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* @param {HTMLElement} sheet
|
|
143
|
-
* @param {PageBox} page
|
|
144
|
-
* @returns {Face | null}
|
|
145
|
-
*/
|
|
146
|
-
let faceOf = (sheet, page) => {
|
|
147
|
-
let badge = sheet.querySelector(".q-unlicensed");
|
|
148
|
-
let pxPerPt = rateOf(sheet, page);
|
|
149
|
-
if (!badge || !(pxPerPt > 0)) return null;
|
|
150
|
-
return faceAt(sheet, page, badge.textContent ?? "", pxPerPt);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
122
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
let mark = document.createElement("div");
|
|
160
|
-
mark.className = "qv-mark";
|
|
161
|
-
mark.style.top = y + "pt";
|
|
162
|
-
let wording = document.createElement("span");
|
|
163
|
-
wording.textContent = face.text;
|
|
164
|
-
// The same face the PDF target draws the marking in (`families.sans[0]`,
|
|
165
|
-
// Helvetica): quario's own wording, so the two must not disagree either.
|
|
166
|
-
wording.style.font = face.fontSize + "pt sans-serif";
|
|
167
|
-
wording.style.transform = "translate(-50%, -50%) rotate(" + face.turn + "deg)";
|
|
168
|
-
mark.append(wording);
|
|
169
|
-
return mark;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* @param {HTMLElement} sheet
|
|
174
|
-
* @param {Face} face
|
|
175
|
-
* @param {number} sheetPt
|
|
176
|
-
* @param {number} pageHeight
|
|
177
|
-
*/
|
|
178
|
-
let paintMarks = (sheet, face, sheetPt, pageHeight) => {
|
|
179
|
-
let layer = document.createElement("div");
|
|
180
|
-
layer.className = "qv-marks";
|
|
181
|
-
layer.setAttribute("aria-hidden", "true");
|
|
182
|
-
for (let y of centres(sheetPt, pageHeight)) layer.append(stampAt(face, y));
|
|
183
|
-
sheet.append(layer);
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
/** @type {(sheet: HTMLElement) => void} */
|
|
187
|
-
let clearMarks = (sheet) => {
|
|
188
|
-
for (let node of sheet.querySelectorAll(".qv-marks")) node.remove();
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* @param {HTMLElement} sheet
|
|
193
|
-
* @param {Face} face
|
|
194
|
-
* @param {PageBox} page
|
|
195
|
-
*/
|
|
196
|
-
let placeMarks = (sheet, face, page) => {
|
|
197
|
-
let pxPerPt = rateOf(sheet, page);
|
|
198
|
-
if (!(pxPerPt > 0)) return;
|
|
199
|
-
paintMarks(sheet, face, sheet.offsetHeight / pxPerPt, page.height);
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Build the stage and take ownership of the sheet's scale. The element
|
|
204
|
-
* renders `element` into its template; everything else about the three
|
|
205
|
-
* elements stays in here. Geometry arrives through `resize` rather than
|
|
206
|
-
* construction, because the `page` property can change over the element's
|
|
207
|
-
* lifetime.
|
|
123
|
+
* Build the stage and take ownership of the pages' scale. The element
|
|
124
|
+
* renders `element` into its template; everything else about the elements
|
|
125
|
+
* stays in here. Geometry arrives through `resize` rather than construction,
|
|
126
|
+
* because the `page` property can change over the element's lifetime — it
|
|
127
|
+
* sizes what `fit()` measures against before a list has arrived.
|
|
208
128
|
*
|
|
209
129
|
* @returns {Stage}
|
|
210
130
|
*/
|
|
@@ -212,8 +132,8 @@ export let stage = () => {
|
|
|
212
132
|
let scroll = document.createElement("div");
|
|
213
133
|
scroll.className = "qv-scroll";
|
|
214
134
|
let wrapper = document.createElement("div");
|
|
215
|
-
// The class names the part that carries the
|
|
216
|
-
//
|
|
135
|
+
// The class names the part that carries the gutter; "the stage" in the
|
|
136
|
+
// glossary is this whole surface, the way `.qv-viewer` is one element
|
|
217
137
|
// inside the viewer. The `qv-*` names are documented as stable, so the
|
|
218
138
|
// narrower one keeps its name.
|
|
219
139
|
wrapper.className = "qv-stage";
|
|
@@ -222,52 +142,315 @@ export let stage = () => {
|
|
|
222
142
|
wrapper.append(sheet);
|
|
223
143
|
scroll.append(wrapper);
|
|
224
144
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
145
|
+
/** The page width to fit against before a list arrives, in points. */
|
|
146
|
+
let fallback = 0;
|
|
147
|
+
/** @type {any} */
|
|
148
|
+
let list = null;
|
|
149
|
+
/** @type {any} */
|
|
150
|
+
let fonts;
|
|
151
|
+
/** The percentage currently on screen. */
|
|
152
|
+
let applied = 100;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* What each backed page is carrying: the scale it was painted at, so a page
|
|
156
|
+
* re-entering the reach at that scale is not repainted and a scale change
|
|
157
|
+
* invalidates it, and the paint itself, so whoever asks second waits behind
|
|
158
|
+
* the paint the first caller started rather than being told it is done.
|
|
159
|
+
* Keyed by the canvas, so a swap's new pages start with nothing remembered
|
|
160
|
+
* and the pages it replaced take their entries with them — which is why the
|
|
161
|
+
* map is weak: nothing has to remember to forget them.
|
|
162
|
+
*
|
|
163
|
+
* @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
|
|
164
|
+
*/
|
|
165
|
+
let backed = new WeakMap();
|
|
166
|
+
|
|
167
|
+
/**
|
|
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]);
|
|
228
194
|
|
|
229
195
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* `
|
|
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.
|
|
233
201
|
*
|
|
234
|
-
* @
|
|
202
|
+
* @type {(canvas: HTMLElement, i: number, px?: number) => void}
|
|
235
203
|
*/
|
|
236
|
-
let
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (face) placeMarks(sheet, face, page);
|
|
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";
|
|
241
208
|
};
|
|
242
209
|
|
|
243
|
-
/**
|
|
244
|
-
|
|
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.
|
|
215
|
+
*
|
|
216
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
217
|
+
*/
|
|
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);
|
|
270
|
+
if (!backed.has(canvas)) return;
|
|
271
|
+
if (!settled.has(canvas)) return void retire(i);
|
|
272
|
+
backed.delete(canvas);
|
|
273
|
+
canvas.width = 0;
|
|
274
|
+
canvas.height = 0;
|
|
275
|
+
};
|
|
276
|
+
|
|
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);
|
|
280
|
+
|
|
281
|
+
/**
|
|
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`.
|
|
289
|
+
*
|
|
290
|
+
* @type {(i: number) => Promise<void>}
|
|
291
|
+
*/
|
|
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();
|
|
297
|
+
let each = list.pages[i];
|
|
298
|
+
canvas.width = Math.round(each.width * scale);
|
|
299
|
+
canvas.height = Math.round(each.height * scale);
|
|
300
|
+
let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
|
|
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
|
+
);
|
|
310
|
+
backed.set(canvas, { scale, painted });
|
|
311
|
+
return painted;
|
|
312
|
+
};
|
|
313
|
+
|
|
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
|
+
*/
|
|
347
|
+
let epoch = 0;
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Give every page its CSS size. Callers run this before writing the scroll
|
|
351
|
+
* offsets back: the extent those offsets are clamped against is this one,
|
|
352
|
+
* and the reach below is read from the offsets once they are in.
|
|
353
|
+
*/
|
|
354
|
+
let sizeAll = () => {
|
|
355
|
+
if (!list) return;
|
|
356
|
+
let px = ratio();
|
|
357
|
+
for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The indices of the pages in the reach — the ones the viewport shows, and
|
|
362
|
+
* everything within one viewport height above or below. Walked over the
|
|
363
|
+
* list's own geometry (the gutter, each page's height at the applied
|
|
364
|
+
* scale, the gap), never a layout read, so it costs nothing to ask
|
|
365
|
+
* mid-scroll — the whole list is walked because a multiply and an add per
|
|
366
|
+
* page is beneath measuring, and stopping early would be a second rule
|
|
367
|
+
* about where the reach ends. `clientHeight` is read afresh each time, so a
|
|
368
|
+
* pane that changed size changes the reach with it.
|
|
369
|
+
*
|
|
370
|
+
* @returns {number[]}
|
|
371
|
+
*/
|
|
372
|
+
let inReach = () => {
|
|
373
|
+
let view = scroll.clientHeight;
|
|
374
|
+
let top = scroll.scrollTop - view;
|
|
375
|
+
let bottom = scroll.scrollTop + 2 * view;
|
|
376
|
+
let px = ratio();
|
|
377
|
+
let y = GUTTER;
|
|
378
|
+
let found = [];
|
|
379
|
+
for (let [i, each] of list.pages.entries()) {
|
|
380
|
+
let height = each.height * px;
|
|
381
|
+
if (y + height >= top && y <= bottom) found.push(i);
|
|
382
|
+
y += height + GAP;
|
|
383
|
+
}
|
|
384
|
+
return found;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/**
|
|
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
|
|
390
|
+
* definition of which pages carry pixels: the repaint below and the scroll
|
|
391
|
+
* pass both go through here, and neither holds a page the other released.
|
|
392
|
+
*
|
|
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[]}
|
|
397
|
+
*/
|
|
398
|
+
let keep = () => {
|
|
399
|
+
let wanted = new Set(inReach());
|
|
400
|
+
let kept = [];
|
|
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);
|
|
405
|
+
}
|
|
406
|
+
return kept;
|
|
407
|
+
};
|
|
245
408
|
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
wrapper.style.width = width * factor + "px";
|
|
256
|
-
wrapper.style.height = height * factor + "px";
|
|
409
|
+
// Paint the reach in order, yielding between pages and giving way to any
|
|
410
|
+
// repaint that started since. Sizing is the caller's, and comes first.
|
|
411
|
+
let repaint = async () => {
|
|
412
|
+
if (!list) return;
|
|
413
|
+
let mine = ++epoch;
|
|
414
|
+
for (let i of keep()) {
|
|
415
|
+
if (mine !== epoch) return;
|
|
416
|
+
await paintPage(i);
|
|
417
|
+
}
|
|
257
418
|
};
|
|
258
419
|
|
|
420
|
+
/**
|
|
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.
|
|
428
|
+
*/
|
|
429
|
+
let pending = false;
|
|
430
|
+
let follow = () => {
|
|
431
|
+
if (pending) return;
|
|
432
|
+
pending = true;
|
|
433
|
+
requestAnimationFrame(() => {
|
|
434
|
+
pending = false;
|
|
435
|
+
if (!list) return;
|
|
436
|
+
for (let i of keep()) paintPage(i).catch(() => {});
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
scroll.addEventListener("scroll", follow);
|
|
440
|
+
|
|
259
441
|
return {
|
|
260
442
|
element: scroll,
|
|
261
443
|
|
|
262
444
|
/**
|
|
263
|
-
* The percentage at which
|
|
445
|
+
* The percentage at which one page spans the width available to it, or
|
|
264
446
|
* `null` when there is nothing to measure against — a `display: none`
|
|
265
447
|
* host, or a pane narrower than its own gutters. A caller with nothing to
|
|
266
448
|
* compute from keeps what is on screen rather than scaling to a guess.
|
|
267
449
|
*/
|
|
268
450
|
fit: () => {
|
|
269
451
|
let usable = scroll.clientWidth - 2 * GUTTER;
|
|
270
|
-
|
|
452
|
+
// The list's page width, or the geometry's before one arrives.
|
|
453
|
+
let width = (list ? list.width : fallback) * PX_PER_POINT;
|
|
271
454
|
if (usable <= 0 || !width) return null;
|
|
272
455
|
return (usable / width) * 100;
|
|
273
456
|
},
|
|
@@ -285,6 +468,9 @@ export let stage = () => {
|
|
|
285
468
|
* are 0 once it overflows, so the plain ratio holds wherever it can be
|
|
286
469
|
* seen. The browser clamps whatever it cannot honour. An empty sheet has
|
|
287
470
|
* no view to hold, which is what mounting at an authored zoom takes.
|
|
471
|
+
*
|
|
472
|
+
* Only the reach is repainted, and only where the scale actually changed,
|
|
473
|
+
* so a zoom step costs a handful of pages however long the report is.
|
|
288
474
|
*/
|
|
289
475
|
scale: (percent) => {
|
|
290
476
|
let held = sheet.firstChild && {
|
|
@@ -293,83 +479,72 @@ export let stage = () => {
|
|
|
293
479
|
height: scroll.clientHeight,
|
|
294
480
|
width: scroll.clientWidth,
|
|
295
481
|
};
|
|
296
|
-
let
|
|
482
|
+
let ratioOf = percent / applied;
|
|
297
483
|
applied = percent;
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
484
|
+
sizeAll();
|
|
485
|
+
if (held) {
|
|
486
|
+
let middle = held.top + held.height / 2 - GUTTER;
|
|
487
|
+
scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
|
|
488
|
+
scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
|
|
489
|
+
}
|
|
490
|
+
void repaint();
|
|
303
491
|
},
|
|
304
492
|
|
|
305
493
|
/**
|
|
306
|
-
* Give the
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* The sheet is the page's shape: its width, and at least one page tall,
|
|
311
|
-
* the same rule the editor's sheet follows. A minimum rather than a
|
|
312
|
-
* height, because the sheet stays one continuous surface -- so the height
|
|
313
|
-
* goes on serving as the watermark stamp period along it too
|
|
314
|
-
* (docs/adr/0035).
|
|
494
|
+
* Give the stage its page width, for the fit an empty sheet measures
|
|
495
|
+
* against. A list that has arrived carries its own; the geometry it was
|
|
496
|
+
* laid out on is the geometry the next render uses.
|
|
315
497
|
*
|
|
316
498
|
* @param {PageBox} next
|
|
317
499
|
*/
|
|
318
500
|
resize: (next) => {
|
|
319
|
-
|
|
320
|
-
sheet.style.width = next.width + "pt";
|
|
321
|
-
sheet.style.minHeight = next.height + "pt";
|
|
322
|
-
sheet.style.padding = next.margin + "pt";
|
|
323
|
-
marking(true);
|
|
324
|
-
paint();
|
|
501
|
+
fallback = next.width;
|
|
325
502
|
},
|
|
326
503
|
|
|
327
504
|
/**
|
|
328
|
-
* Put a
|
|
329
|
-
* were
|
|
330
|
-
*
|
|
331
|
-
*
|
|
505
|
+
* Put a laid-out report on the sheet, keeping the reader where they
|
|
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
|
|
513
|
+
* `renderComplete` means by "the pages on screen are painted".
|
|
332
514
|
*/
|
|
333
|
-
swap: (
|
|
515
|
+
swap: async (next, faces) => {
|
|
334
516
|
// Reading the offsets flushes layout, so only an actual reswap pays for
|
|
335
517
|
// it: on an empty sheet there is nothing scrolled to preserve.
|
|
336
|
-
let held = sheet.firstChild && {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
// recentres here or it would fight the restore below.
|
|
346
|
-
paint();
|
|
518
|
+
let held = sheet.firstChild && {
|
|
519
|
+
top: scroll.scrollTop,
|
|
520
|
+
left: scroll.scrollLeft,
|
|
521
|
+
};
|
|
522
|
+
list = next;
|
|
523
|
+
fonts = faces;
|
|
524
|
+
sheet.replaceChildren(
|
|
525
|
+
...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i)),
|
|
526
|
+
);
|
|
347
527
|
if (held) {
|
|
348
528
|
scroll.scrollTop = held.top;
|
|
349
529
|
scroll.scrollLeft = held.left;
|
|
350
530
|
}
|
|
531
|
+
await repaint();
|
|
351
532
|
},
|
|
352
533
|
|
|
353
534
|
/**
|
|
354
535
|
* Watch the box for changes the viewer learns of no other way — a
|
|
355
536
|
* collapsed side panel, a resized window, a flex sibling appearing — so a
|
|
356
|
-
* fitted
|
|
357
|
-
*
|
|
358
|
-
* feedback loop is the CSS above.
|
|
537
|
+
* fitted page can follow its host. Returns the disposer; what rules out a
|
|
538
|
+
* fit feedback loop is the CSS above.
|
|
359
539
|
*/
|
|
360
540
|
watch: (changed) => {
|
|
361
|
-
let last = sheet.offsetHeight;
|
|
362
541
|
let observer = new ResizeObserver(() => {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
marking(false);
|
|
367
|
-
paint();
|
|
368
|
-
}
|
|
542
|
+
// A resize that changes the fit repaints through `changed()`; one
|
|
543
|
+
// that does not still moved the viewport the reach is measured in.
|
|
544
|
+
follow();
|
|
369
545
|
changed();
|
|
370
546
|
});
|
|
371
547
|
observer.observe(scroll);
|
|
372
|
-
observer.observe(sheet);
|
|
373
548
|
return () => observer.disconnect();
|
|
374
549
|
},
|
|
375
550
|
};
|