@quario/viewer 0.2.0 → 0.4.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,41 @@
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
+ * [band](../../../CONTEXT.md#band) 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
+ * What the stage does not decide is which percentage to show: `fit()`
25
+ * measures what would make one page span the width available, and `zoom.js`
26
+ * owns the policy over that answer. What is on screen, though, is the
27
+ * stage's own — `percent()` reports it, so no caller keeps a second copy.
28
+ *
29
+ * The editor builds its own stack of pages next door, and deliberately: what
30
+ * the two surfaces share is the display list and the size of a point, not the
31
+ * sheet. Each is a canvas element, a device-pixel size and a `paint()` call
32
+ * around policy neither could lend the other — this one repaints under zoom
33
+ * and guards a superseded repaint, that one builds once per swap and lays box
34
+ * elements over the result.
16
35
  */
17
36
 
18
37
  import { css } from "lit";
19
- import { geometry } from "./check.js";
20
- import { FACE, angle, centres, size, span } from "./mark.js";
38
+ import { PX_PER_POINT, paint } from "@quario/layout";
21
39
 
22
40
  /**
23
41
  * Space around the sheet, in px. Read from here and nowhere else: the stage's
@@ -26,10 +44,13 @@ import { FACE, angle, centres, size, span } from "./mark.js";
26
44
  */
27
45
  let GUTTER = 28;
28
46
 
47
+ /** The gap between two pages, in px. */
48
+ let GAP = 16;
49
+
29
50
  export let SURFACE = css`
30
51
  /* Where scrollbars take width, the gutter is held whether one is showing or
31
52
  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
53
+ starts: painting sets the pages' height, the height is what decides
33
54
  whether this element needs a vertical scrollbar, and a scrollbar that came
34
55
  and went would change the width \`fit()\` measures. Reserving it permanently
35
56
  means the width fit reads cannot move in response to the height fit writes.
@@ -43,168 +64,45 @@ export let SURFACE = css`
43
64
  scrollbar-gutter: stable;
44
65
  }
45
66
 
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. */
67
+ /* The wrapper carries the gutter. Only the vertical margin is the gutter as
68
+ such the horizontal ones centre whatever slack the fit left over. */
50
69
  .qv-stage {
70
+ width: fit-content;
51
71
  margin: ${GUTTER}px auto;
52
72
  }
53
73
 
54
- /* Sheet is the containing block for on-sheet watermark stamps. */
74
+ /* The sheet is the stack of pages. */
55
75
  .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);
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: ${GAP}px;
68
79
  }
69
80
 
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;
81
+ /* One page. The painter fills it white; the background here is what a page
82
+ outside the band shows, and what one inside it shows between being sized
83
+ and being painted. */
84
+ .qv-page {
85
+ display: block;
86
+ background: #fff;
87
+ box-shadow: var(--_sheet-shadow);
96
88
  }
97
89
  `;
98
90
 
99
91
  /**
100
92
  * @typedef {{ width: number, height: number, margin: number }} PageBox
101
- * @typedef {{ text: string, turn: number, fontSize: number }} Face
102
93
  * @typedef {{ element: HTMLElement, fit: () => number | null,
103
94
  * percent: () => number, scale: (percent: number) => void,
104
95
  * resize: (geometry: PageBox) => void,
105
- * swap: (fragment: string) => void,
96
+ * swap: (list: any, fonts: any) => Promise<void>,
106
97
  * watch: (changed: () => void) => () => void }} Stage
107
98
  */
108
99
 
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
- /**
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
100
  /**
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.
101
+ * Build the stage and take ownership of the pages' scale. The element
102
+ * renders `element` into its template; everything else about the elements
103
+ * stays in here. Geometry arrives through `resize` rather than construction,
104
+ * because the `page` property can change over the element's lifetime — it
105
+ * sizes what `fit()` measures against before a list has arrived.
208
106
  *
209
107
  * @returns {Stage}
210
108
  */
@@ -212,8 +110,8 @@ export let stage = () => {
212
110
  let scroll = document.createElement("div");
213
111
  scroll.className = "qv-scroll";
214
112
  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
113
+ // The class names the part that carries the gutter; "the stage" in the
114
+ // glossary is this whole surface, the way `.qv-viewer` is one element
217
115
  // inside the viewer. The `qv-*` names are documented as stable, so the
218
116
  // narrower one keeps its name.
219
117
  wrapper.className = "qv-stage";
@@ -222,52 +120,201 @@ export let stage = () => {
222
120
  wrapper.append(sheet);
223
121
  scroll.append(wrapper);
224
122
 
225
- let page = geometry(undefined);
226
- /** @type {Face | null} */
227
- let face = null;
123
+ /** The page width to fit against before a list arrives, in points. */
124
+ let fallback = 0;
125
+ /** @type {any} */
126
+ let list = null;
127
+ /** @type {any} */
128
+ let fonts;
129
+ /** The percentage currently on screen. */
130
+ let applied = 100;
131
+
132
+ /**
133
+ * What each backed page is carrying: the scale it was painted at, so a page
134
+ * re-entering the band at that scale is not repainted and a scale change
135
+ * invalidates it, and the paint itself, so whoever asks second waits behind
136
+ * the paint the first caller started rather than being told it is done.
137
+ * Keyed by the canvas, so a swap's new pages start with nothing remembered
138
+ * and the pages it replaced take their entries with them — which is why the
139
+ * map is weak: nothing has to remember to forget them.
140
+ *
141
+ * @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
142
+ */
143
+ let backed = new WeakMap();
228
144
 
229
145
  /**
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.
146
+ * Give a page's pixels back. Sizing the canvas to 0 × 0 is the one idiom
147
+ * that frees the store synchronously in every engine the viewer runs in;
148
+ * the CSS size is untouched, so the page keeps its place in the extent and
149
+ * shows the sheet's white.
233
150
  *
234
- * @param {boolean} [fresh]
151
+ * @type {(canvas: HTMLCanvasElement) => void}
235
152
  */
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);
153
+ let release = (canvas) => {
154
+ if (!backed.has(canvas)) return;
155
+ backed.delete(canvas);
156
+ canvas.width = 0;
157
+ canvas.height = 0;
241
158
  };
242
159
 
243
- /** The percentage currently on screen. */
244
- let applied = 100;
160
+ /** The page width the fit measures against: the list's, or the geometry's
161
+ * before one arrives. */
162
+ let pageWidth = () => (list ? list.width : fallback);
163
+
164
+ /** CSS pixels per point at the applied percentage. */
165
+ let ratio = () => (PX_PER_POINT * applied) / 100;
166
+
167
+ /**
168
+ * Paint one page and answer with that paint — or with the paint already
169
+ * under way at this scale, which is what makes scrolling back over ground
170
+ * already covered free. Answering with the paint rather than with a
171
+ * resolved promise is what lets two callers reach the same page and both
172
+ * settle behind its pixels. A paint that failed is forgotten, so the next
173
+ * pass over the band tries again instead of counting the page as painted.
174
+ *
175
+ * @type {(canvas: HTMLCanvasElement, i: number) => Promise<void>}
176
+ */
177
+ let paintPage = (canvas, i) => {
178
+ let scale = ratio() * (globalThis.devicePixelRatio || 1);
179
+ let carrying = backed.get(canvas);
180
+ if (carrying && carrying.scale === scale) return carrying.painted;
181
+ let each = list.pages[i];
182
+ canvas.width = Math.round(each.width * scale);
183
+ canvas.height = Math.round(each.height * scale);
184
+ let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
185
+ let painted = paint(ctx, each, { scale, fonts }).catch((failure) => {
186
+ backed.delete(canvas);
187
+ throw failure;
188
+ });
189
+ backed.set(canvas, { scale, painted });
190
+ return painted;
191
+ };
192
+
193
+ /** Bumped per repaint, so a repaint overtaken by the next stops painting. */
194
+ let epoch = 0;
195
+
196
+ /**
197
+ * Give every page its CSS size. Callers run this before writing the scroll
198
+ * offsets back: the extent those offsets are clamped against is this one,
199
+ * and the band below is read from the offsets once they are in.
200
+ */
201
+ let sizeAll = () => {
202
+ if (!list) return;
203
+ let px = ratio();
204
+ for (let [i, each] of list.pages.entries()) {
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
+ }
209
+ };
210
+
211
+ /**
212
+ * The indices of the pages in the band — the ones the viewport shows, and
213
+ * everything within one viewport height above or below. Walked over the
214
+ * list's own geometry (the gutter, each page's height at the applied
215
+ * scale, the gap), never a layout read, so it costs nothing to ask
216
+ * mid-scroll — the whole list is walked because a multiply and an add per
217
+ * page is beneath measuring, and stopping early would be a second rule
218
+ * about where the band ends. `clientHeight` is read afresh each time, so a
219
+ * pane that changed size changes the band with it.
220
+ *
221
+ * @returns {number[]}
222
+ */
223
+ let inBand = () => {
224
+ let view = scroll.clientHeight;
225
+ let top = scroll.scrollTop - view;
226
+ let bottom = scroll.scrollTop + 2 * view;
227
+ let px = ratio();
228
+ let y = GUTTER;
229
+ let found = [];
230
+ for (let [i, each] of list.pages.entries()) {
231
+ let height = each.height * px;
232
+ if (y + height >= top && y <= bottom) found.push(i);
233
+ y += height + GAP;
234
+ }
235
+ return found;
236
+ };
237
+
238
+ /**
239
+ * Release every page the band has left behind, and answer with the ones to
240
+ * keep, each with its index — in the order they are painted in. The single
241
+ * definition of which pages carry pixels: the repaint below and the scroll
242
+ * pass both go through here, and neither holds a page the other released.
243
+ *
244
+ * @type {() => [number, HTMLCanvasElement][]}
245
+ */
246
+ let keep = () => {
247
+ let wanted = new Set(inBand());
248
+ let kept = [];
249
+ for (let i = 0; i < sheet.children.length; i++) {
250
+ let canvas = /** @type {HTMLCanvasElement} */ (sheet.children[i]);
251
+ if (wanted.has(i)) kept.push(/** @type {[number, HTMLCanvasElement]} */ ([i, canvas]));
252
+ else release(canvas);
253
+ }
254
+ return kept;
255
+ };
245
256
 
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";
257
+ // Paint the band in order, yielding between pages and giving way to any
258
+ // repaint that started since. Sizing is the caller's, and comes first.
259
+ let repaint = async () => {
260
+ if (!list) return;
261
+ let mine = ++epoch;
262
+ for (let [i, canvas] of keep()) {
263
+ if (mine !== epoch) return;
264
+ await paintPage(canvas, i);
265
+ }
266
+ };
267
+
268
+ /**
269
+ * The band pass a scroll or a resize runs. It keeps no epoch of its own, so
270
+ * a swap painting behind it is never cut off part-painted, and a page it
271
+ * starts is one a swap reaching the same page waits behind rather than
272
+ * skips. Nothing awaits these paints here: a scroll is not a render and has
273
+ * no failure channel of its own, and a paint that failed is forgotten, so
274
+ * the next pass over the band reports it the way any other paint does.
275
+ */
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
+ let pending = false;
283
+ let follow = () => {
284
+ if (pending) return;
285
+ pending = true;
286
+ requestAnimationFrame(() => {
287
+ pending = false;
288
+ sync();
289
+ });
290
+ };
291
+ scroll.addEventListener("scroll", follow);
292
+
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;
257
304
  };
258
305
 
259
306
  return {
260
307
  element: scroll,
261
308
 
262
309
  /**
263
- * The percentage at which the sheet spans the width available to it, or
310
+ * The percentage at which one page spans the width available to it, or
264
311
  * `null` when there is nothing to measure against — a `display: none`
265
312
  * host, or a pane narrower than its own gutters. A caller with nothing to
266
313
  * compute from keeps what is on screen rather than scaling to a guess.
267
314
  */
268
315
  fit: () => {
269
316
  let usable = scroll.clientWidth - 2 * GUTTER;
270
- let width = sheet.offsetWidth;
317
+ let width = pageWidth() * PX_PER_POINT;
271
318
  if (usable <= 0 || !width) return null;
272
319
  return (usable / width) * 100;
273
320
  },
@@ -285,6 +332,9 @@ export let stage = () => {
285
332
  * are 0 once it overflows, so the plain ratio holds wherever it can be
286
333
  * seen. The browser clamps whatever it cannot honour. An empty sheet has
287
334
  * no view to hold, which is what mounting at an authored zoom takes.
335
+ *
336
+ * Only the band is repainted, and only where the scale actually changed,
337
+ * so a zoom step costs a handful of pages however long the report is.
288
338
  */
289
339
  scale: (percent) => {
290
340
  let held = sheet.firstChild && {
@@ -293,83 +343,71 @@ export let stage = () => {
293
343
  height: scroll.clientHeight,
294
344
  width: scroll.clientWidth,
295
345
  };
296
- let ratio = percent / applied;
346
+ let ratioOf = percent / applied;
297
347
  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;
348
+ sizeAll();
349
+ if (held) {
350
+ let middle = held.top + held.height / 2 - GUTTER;
351
+ scroll.scrollTop = GUTTER + middle * ratioOf - held.height / 2;
352
+ scroll.scrollLeft = (held.left + held.width / 2) * ratioOf - held.width / 2;
353
+ }
354
+ void repaint();
303
355
  },
304
356
 
305
357
  /**
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).
358
+ * Give the stage its page width, for the fit an empty sheet measures
359
+ * against. A list that has arrived carries its own; the geometry it was
360
+ * laid out on is the geometry the next render uses.
315
361
  *
316
362
  * @param {PageBox} next
317
363
  */
318
364
  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();
365
+ fallback = next.width;
325
366
  },
326
367
 
327
368
  /**
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.
369
+ * Put a laid-out report on the sheet, keeping the reader where they
370
+ * were: one canvas per page, the band among them painted at the applied
371
+ * scale. The order is the point of the method: the pages take their size
372
+ * before the offsets go back, so the browser clamps them against the
373
+ * extent they will have rather than the one they had — and the band is
374
+ * read from those offsets, so it is chosen after they are in. What the
375
+ * returned promise settles behind is the band, which is what the caller's
376
+ * `renderComplete` means by "the pages on screen are painted".
332
377
  */
333
- swap: (fragment) => {
378
+ swap: async (next, faces) => {
334
379
  // Reading the offsets flushes layout, so only an actual reswap pays for
335
380
  // it: on an empty sheet there is nothing scrolled to preserve.
336
381
  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();
382
+ list = next;
383
+ fonts = faces;
384
+ sheet.replaceChildren(
385
+ ...next.pages.map((/** @type {any} */ _, /** @type {number} */ i) =>
386
+ pageOf(i, next.pages.length),
387
+ ),
388
+ );
389
+ sizeAll();
347
390
  if (held) {
348
391
  scroll.scrollTop = held.top;
349
392
  scroll.scrollLeft = held.left;
350
393
  }
394
+ await repaint();
351
395
  },
352
396
 
353
397
  /**
354
398
  * Watch the box for changes the viewer learns of no other way — a
355
399
  * 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.
400
+ * fitted page can follow its host. Returns the disposer; what rules out a
401
+ * fit feedback loop is the CSS above.
359
402
  */
360
403
  watch: (changed) => {
361
- let last = sheet.offsetHeight;
362
404
  let observer = new ResizeObserver(() => {
363
- let height = sheet.offsetHeight;
364
- if (height !== last) {
365
- last = height;
366
- marking(false);
367
- paint();
368
- }
405
+ // A resize that changes the fit repaints through `changed()`; one
406
+ // that does not still moved the viewport the band is measured in.
407
+ follow();
369
408
  changed();
370
409
  });
371
410
  observer.observe(scroll);
372
- observer.observe(sheet);
373
411
  return () => observer.disconnect();
374
412
  },
375
413
  };