@quario/viewer 0.3.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/CHANGELOG.md +43 -0
- package/README.md +76 -70
- package/lib/check.js +18 -59
- package/lib/chrome.js +3 -4
- package/lib/index.d.ts +34 -26
- package/lib/index.js +57 -38
- package/lib/menu.js +3 -3
- package/lib/stage.js +272 -234
- package/package.json +5 -3
- package/lib/mark.js +0 -40
- package/lib/style.js +0 -123
package/lib/stage.js
CHANGED
|
@@ -1,23 +1,41 @@
|
|
|
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
|
+
* [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 {
|
|
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:
|
|
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
|
|
47
|
-
|
|
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
|
-
/*
|
|
74
|
+
/* The sheet is the stack of pages. */
|
|
55
75
|
.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);
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
gap: ${GAP}px;
|
|
68
79
|
}
|
|
69
80
|
|
|
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;
|
|
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: (
|
|
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
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
|
216
|
-
//
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
-
* @
|
|
151
|
+
* @type {(canvas: HTMLCanvasElement) => void}
|
|
235
152
|
*/
|
|
236
|
-
let
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
244
|
-
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
let
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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 =
|
|
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
|
|
346
|
+
let ratioOf = percent / applied;
|
|
297
347
|
applied = percent;
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
|
329
|
-
* were
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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: (
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
357
|
-
*
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quario/viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The embeddable report viewer shell for quario — in the makings, not yet released",
|
|
5
5
|
"homepage": "https://getquario.com",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@lit/task": "^1.0.3",
|
|
47
|
+
"@quario/layout": "^0.1.0",
|
|
47
48
|
"lit": "^3.3.3"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
@@ -52,18 +53,19 @@
|
|
|
52
53
|
"esbuild": "^0.28.2",
|
|
53
54
|
"exceljs": "^4.4.0",
|
|
54
55
|
"pdf-lib": "^1.17.1",
|
|
55
|
-
"quario": "^0.
|
|
56
|
+
"quario": "^0.4.0",
|
|
56
57
|
"size-limit": "^13.0.3",
|
|
57
58
|
"typescript": "^7.0.2"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
|
-
"quario": "^0.
|
|
61
|
+
"quario": "^0.4.0"
|
|
61
62
|
},
|
|
62
63
|
"size-limit": [
|
|
63
64
|
{
|
|
64
65
|
"path": "lib/index.js",
|
|
65
66
|
"ignore": [
|
|
66
67
|
"quario",
|
|
68
|
+
"@quario/layout",
|
|
67
69
|
"lit",
|
|
68
70
|
"@lit/task"
|
|
69
71
|
],
|