@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/lib/stage.js CHANGED
@@ -1,23 +1,63 @@
1
1
  /**
2
- * The stage: the surface a report is scaled on. It owns the scroll container
3
- * the reader looks through, the wrapper that carries whatever extent the
4
- * current scale needs, and the white sheet itself their elements, their CSS,
5
- * and every number relating the three.
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
- * Scaling is `transform: scale()` from the sheet's top-left corner, with the
8
- * wrapper sized to the scaled result: a transformed child grows no scroll
9
- * extent of its own, so without the wrapper a zoomed-in report would simply be
10
- * clipped.
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
- * What the stage does not decide is which percentage to show: `fit()` measures
13
- * what would make the sheet span the width available, and `zoom.js` owns the
14
- * policy over that answer. What is on screen, though, is the stage's own —
15
- * `percent()` reports it, so no caller keeps a second copy.
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 { geometry } from "./check.js";
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: scaling sets the wrapper's height, the height is what decides
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 margin and the scroll extent: a margin on a scaled
47
- child scales with it, and a transformed child grows no extent at all. Only
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
- /* Sheet is the containing block for on-sheet watermark stamps. */
96
+ /* The sheet is the stack of pages. */
55
97
  .qv-sheet {
56
- position: relative;
57
- box-sizing: border-box;
58
- transform-origin: 0 0;
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
- /* Stamps live under the sheet's transform so zoom/fit scales them. Absolute
71
- layer clips rotated glyphs so they never widen scroll extent or feed \`fit()\`
72
- see docs/adr/0017-the-viewer-watermarks-the-sheet.md. */
73
- .qv-marks {
74
- position: absolute;
75
- inset: 0;
76
- overflow: hidden;
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: (fragment: string) => void,
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
- * @param {Face} face
155
- * @param {number} y
156
- * @returns {HTMLElement}
157
- */
158
- let stampAt = (face, y) => {
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 scroll extent; "the stage" in
216
- // the glossary is this whole surface, the way `.qv-viewer` is one element
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
- let page = geometry(undefined);
226
- /** @type {Face | null} */
227
- let face = null;
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
- * Show the marking this fragment carries, or none. Wording is the engine's
231
- * on the html target's badge; stamps are presentation on the sheet (ADR 0017).
232
- * `fresh` remeasures the face (swap / page change); height-only updates reuse it.
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
- * @param {boolean} [fresh]
202
+ * @type {(canvas: HTMLElement, i: number, px?: number) => void}
235
203
  */
236
- let marking = (fresh = true) => {
237
- clearMarks(sheet);
238
- if (fresh) face = null;
239
- face ??= faceOf(sheet, page);
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
- /** The percentage currently on screen. */
244
- let applied = 100;
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
- // Write the scale and size the wrapper against it. Both reads are layout
247
- // metrics, unaffected by the transform, so the wrapper is sized from the
248
- // sheet's own untransformed box and both come before any write, so a
249
- // paint costs one layout pass rather than one per measurement.
250
- let paint = () => {
251
- let factor = applied / 100;
252
- let width = sheet.offsetWidth;
253
- let height = sheet.offsetHeight;
254
- sheet.style.transform = factor === 1 ? "" : "scale(" + factor + ")";
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 the sheet spans the width available to it, or
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
- let width = sheet.offsetWidth;
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 ratio = percent / applied;
482
+ let ratioOf = percent / applied;
297
483
  applied = percent;
298
- paint();
299
- if (!held) return;
300
- let middle = held.top + held.height / 2 - GUTTER;
301
- scroll.scrollTop = GUTTER + middle * ratio - held.height / 2;
302
- scroll.scrollLeft = (held.left + held.width / 2) * ratio - held.width / 2;
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 sheet its point geometry. Page points are CSS points, so the
307
- * host's values transfer unit-for-unit; the wrapper re-sizes against the
308
- * result so the scroll extent keeps describing what is there.
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
- page = next;
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 rendered fragment on the sheet, keeping the reader where they
329
- * were. The order is the point of the method: the wrapper takes the new
330
- * content's height before the offsets go back, so the browser clamps them
331
- * against the extent they will have rather than the one they had.
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: (fragment) => {
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 && { top: scroll.scrollTop, left: scroll.scrollLeft };
337
- // The viewer's one markup edge. What goes in is the html target's
338
- // documented output, injected the documented way. Watermark stamps are
339
- // presentation synthesized after from the badge (ADR 0017) — not report
340
- // content (hard constraint 4).
341
- sheet.innerHTML = fragment;
342
- face = null;
343
- marking(true);
344
- // A resize, not a zoom — the scale has not changed, so nothing
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 sheet can follow its host. Also watches sheet height so stamp
357
- * density tracks layout. Returns the disposer; what rules out a fit
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
- let height = sheet.offsetHeight;
364
- if (height !== last) {
365
- last = height;
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
  };