@laminapdf/viewer 0.2.0-alpha.4 → 0.2.0-alpha.6
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/README.md +79 -10
- package/dist/client.d.ts +60 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +241 -22
- package/dist/client.js.map +1 -1
- package/dist/edit/session.d.ts +91 -0
- package/dist/edit/session.d.ts.map +1 -0
- package/dist/edit/session.js +179 -0
- package/dist/edit/session.js.map +1 -0
- package/dist/edit-log.d.ts +32 -0
- package/dist/edit-log.d.ts.map +1 -0
- package/dist/edit-log.js +74 -0
- package/dist/edit-log.js.map +1 -0
- package/dist/find/controller.d.ts +6 -0
- package/dist/find/controller.d.ts.map +1 -1
- package/dist/find/controller.js +69 -2
- package/dist/find/controller.js.map +1 -1
- package/dist/first-draw.d.ts +10 -0
- package/dist/first-draw.d.ts.map +1 -0
- package/dist/first-draw.js +33 -0
- package/dist/first-draw.js.map +1 -0
- package/dist/history/history.d.ts +74 -0
- package/dist/history/history.d.ts.map +1 -0
- package/dist/history/history.js +135 -0
- package/dist/history/history.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/layers/copy.d.ts +26 -0
- package/dist/layers/copy.d.ts.map +1 -0
- package/dist/layers/copy.js +150 -0
- package/dist/layers/copy.js.map +1 -0
- package/dist/layers/link-layer.d.ts +101 -0
- package/dist/layers/link-layer.d.ts.map +1 -0
- package/dist/layers/link-layer.js +527 -0
- package/dist/layers/link-layer.js.map +1 -0
- package/dist/layers/link-preview.d.ts +77 -0
- package/dist/layers/link-preview.d.ts.map +1 -0
- package/dist/layers/link-preview.js +202 -0
- package/dist/layers/link-preview.js.map +1 -0
- package/dist/layers/link-url.d.ts +19 -0
- package/dist/layers/link-url.d.ts.map +1 -0
- package/dist/layers/link-url.js +47 -0
- package/dist/layers/link-url.js.map +1 -0
- package/dist/layers/spans.d.ts +0 -6
- package/dist/layers/spans.d.ts.map +1 -1
- package/dist/layers/spans.js +2 -20
- package/dist/layers/spans.js.map +1 -1
- package/dist/layers/structure-layer.d.ts.map +1 -1
- package/dist/layers/structure-layer.js +3 -0
- package/dist/layers/structure-layer.js.map +1 -1
- package/dist/layers/text-layer.d.ts +27 -6
- package/dist/layers/text-layer.d.ts.map +1 -1
- package/dist/layers/text-layer.js +310 -28
- package/dist/layers/text-layer.js.map +1 -1
- package/dist/layout/page-layout.d.ts +7 -0
- package/dist/layout/page-layout.d.ts.map +1 -1
- package/dist/layout/page-layout.js +15 -0
- package/dist/layout/page-layout.js.map +1 -1
- package/dist/protocol.d.ts +197 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +33 -6
- package/dist/protocol.js.map +1 -1
- package/dist/source/opfs.d.ts +35 -0
- package/dist/source/opfs.d.ts.map +1 -0
- package/dist/source/opfs.js +178 -0
- package/dist/source/opfs.js.map +1 -0
- package/dist/thumbnails/strip.d.ts +1 -1
- package/dist/thumbnails/strip.d.ts.map +1 -1
- package/dist/thumbnails/strip.js +48 -4
- package/dist/thumbnails/strip.js.map +1 -1
- package/dist/view/previews.d.ts +47 -0
- package/dist/view/previews.d.ts.map +1 -0
- package/dist/view/previews.js +97 -0
- package/dist/view/previews.js.map +1 -0
- package/dist/view/user-select.d.ts +3 -0
- package/dist/view/user-select.d.ts.map +1 -0
- package/dist/view/user-select.js +10 -0
- package/dist/view/user-select.js.map +1 -0
- package/dist/view/viewer.d.ts +143 -5
- package/dist/view/viewer.d.ts.map +1 -1
- package/dist/view/viewer.js +590 -50
- package/dist/view/viewer.js.map +1 -1
- package/dist/worker/document.d.ts +66 -6
- package/dist/worker/document.d.ts.map +1 -1
- package/dist/worker/document.js +139 -23
- package/dist/worker/document.js.map +1 -1
- package/dist/worker/edit.d.ts +35 -0
- package/dist/worker/edit.d.ts.map +1 -0
- package/dist/worker/edit.js +305 -0
- package/dist/worker/edit.js.map +1 -0
- package/dist/worker/find.d.ts +2 -2
- package/dist/worker/find.d.ts.map +1 -1
- package/dist/worker/find.js +8 -21
- package/dist/worker/find.js.map +1 -1
- package/dist/worker/fingerprint.d.ts +10 -0
- package/dist/worker/fingerprint.d.ts.map +1 -0
- package/dist/worker/fingerprint.js +42 -0
- package/dist/worker/fingerprint.js.map +1 -0
- package/dist/worker/methods.d.ts.map +1 -1
- package/dist/worker/methods.js +42 -4
- package/dist/worker/methods.js.map +1 -1
- package/dist/worker/navigation.d.ts +5 -1
- package/dist/worker/navigation.d.ts.map +1 -1
- package/dist/worker/navigation.js +22 -4
- package/dist/worker/navigation.js.map +1 -1
- package/dist/worker/print.d.ts +14 -0
- package/dist/worker/print.d.ts.map +1 -0
- package/dist/worker/print.js +49 -0
- package/dist/worker/print.js.map +1 -0
- package/dist/worker/recent-pages.d.ts +16 -0
- package/dist/worker/recent-pages.d.ts.map +1 -0
- package/dist/worker/recent-pages.js +68 -0
- package/dist/worker/recent-pages.js.map +1 -0
- package/dist/worker/render.d.ts.map +1 -1
- package/dist/worker/render.js +5 -2
- package/dist/worker/render.js.map +1 -1
- package/dist/worker/session.d.ts +59 -0
- package/dist/worker/session.d.ts.map +1 -0
- package/dist/worker/session.js +152 -0
- package/dist/worker/session.js.map +1 -0
- package/dist/worker/text.d.ts +11 -3
- package/dist/worker/text.d.ts.map +1 -1
- package/dist/worker/text.js +33 -25
- package/dist/worker/text.js.map +1 -1
- package/package.json +9 -8
package/dist/view/viewer.js
CHANGED
|
@@ -11,17 +11,21 @@
|
|
|
11
11
|
import { pagePixels } from "@laminapdf/render";
|
|
12
12
|
import { BitmapCache } from "../canvas/cache.js";
|
|
13
13
|
import { CanvasPool } from "../canvas/pool.js";
|
|
14
|
+
import { EditSession } from "../edit/session.js";
|
|
14
15
|
import { FindController } from "../find/controller.js";
|
|
16
|
+
import { NavigationHistory, } from "../history/history.js";
|
|
15
17
|
import { HighlightLayer } from "../layers/highlight-layer.js";
|
|
18
|
+
import { LinkLayer } from "../layers/link-layer.js";
|
|
16
19
|
import { TextLayer } from "../layers/text-layer.js";
|
|
17
|
-
import { isRotation, PageLayout, rotated } from "../layout/page-layout.js";
|
|
20
|
+
import { isRotation, keptTop, PageLayout, rotated } from "../layout/page-layout.js";
|
|
18
21
|
import { range } from "../layout/range.js";
|
|
19
|
-
import {
|
|
22
|
+
import { appearanceField, appearanceKey, renderKeyFields, viewKey, } from "../protocol.js";
|
|
20
23
|
import { backdropRatio, bounds, contains, lowScale, sharpScale, tilesFor } from "../render/tiles.js";
|
|
21
24
|
import { destinationView } from "../zoom/destination.js";
|
|
22
25
|
import { contentPosition, percentOfScale, pointAt, scaleFor, scrollToPoint, } from "../zoom/zoom.js";
|
|
23
26
|
import { deviceHints, viewerBudgets } from "./budgets.js";
|
|
24
27
|
import { TouchGestures } from "./touch.js";
|
|
28
|
+
import { setUserSelect } from "./user-select.js";
|
|
25
29
|
/** The built-in layer slots, bottom to top, above the canvas. */
|
|
26
30
|
const SLOTS = ["text", "annot", "highlight"];
|
|
27
31
|
/** The tallest scroll space browsers handle (Firefox stops near 17.9 M CSS px). */
|
|
@@ -29,6 +33,19 @@ const MAX_HEIGHT = 16_000_000;
|
|
|
29
33
|
function isAbort(e) {
|
|
30
34
|
return e instanceof DOMException && e.name === "AbortError";
|
|
31
35
|
}
|
|
36
|
+
/** The state before a document opens. */
|
|
37
|
+
function emptyState(zoom) {
|
|
38
|
+
return {
|
|
39
|
+
pageCount: 0,
|
|
40
|
+
currentPage: 0,
|
|
41
|
+
zoom,
|
|
42
|
+
percent: 100,
|
|
43
|
+
rotation: 0,
|
|
44
|
+
appearance: "original",
|
|
45
|
+
scrollTop: 0,
|
|
46
|
+
visibleRange: { first: 0, last: -1 },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
32
49
|
/** What a painted canvas depends on: a zoom or a rotation makes it stale. */
|
|
33
50
|
function geometryOf(layout) {
|
|
34
51
|
return `${layout.scale}:${layout.rotation}`;
|
|
@@ -64,18 +81,48 @@ export class Viewer {
|
|
|
64
81
|
/** The text layer, unless `text: false`. */
|
|
65
82
|
#text;
|
|
66
83
|
#highlights;
|
|
84
|
+
/** The link layer, unless `links: false`. */
|
|
85
|
+
#links;
|
|
86
|
+
/** The link previews' pictures, loaded with their code on the first preview (not at startup). */
|
|
87
|
+
#previews;
|
|
88
|
+
#loadingPreviews;
|
|
67
89
|
/** Find in this viewer's document: query, options, matches, next and previous. */
|
|
68
90
|
find;
|
|
91
|
+
/**
|
|
92
|
+
* Back and forward through the places jumped from and to: links, `jumpToPage`,
|
|
93
|
+
* `jumpToDestination` and find's matches record a jump; scrolling doesn't.
|
|
94
|
+
*/
|
|
95
|
+
history;
|
|
96
|
+
/**
|
|
97
|
+
* The open document's edit session: its history, and edits, undo, redo and going to a step,
|
|
98
|
+
* each redrawing the pages it touched. One call runs at a time, in the order they were made.
|
|
99
|
+
*/
|
|
100
|
+
edits;
|
|
69
101
|
/** Pages kept mounted whatever the scroll (`keepMounted`): those a selection touches. */
|
|
70
102
|
#pinned = new Set();
|
|
103
|
+
#regions;
|
|
104
|
+
/** The page a jump landed on, focused once it is mounted. */
|
|
105
|
+
#focusTarget;
|
|
106
|
+
/** True while a jump that leaves focus where it is (a find match) runs. */
|
|
107
|
+
#keepFocus = false;
|
|
71
108
|
#listeners = new Set();
|
|
72
109
|
#events = new Map();
|
|
73
110
|
#cleanup = [];
|
|
74
|
-
|
|
111
|
+
/** The open document's ID (renders name it), or undefined before one opens. */
|
|
112
|
+
#documentId;
|
|
113
|
+
/** Counts `open`s: an edit answered for a document since replaced redraws nothing. */
|
|
114
|
+
#opens = 0;
|
|
75
115
|
#sizes = [];
|
|
116
|
+
/** Each edited page's revision (`invalidate` bumps it), part of its bitmaps' cache keys. */
|
|
117
|
+
#revisions = new Map();
|
|
76
118
|
#layout;
|
|
77
119
|
#zoom;
|
|
78
120
|
#rotation = 0;
|
|
121
|
+
/**
|
|
122
|
+
* Where the view goes once it has a size, when it was opened or restored without one (a hidden
|
|
123
|
+
* host ignores scrolling): the top of a new document, or a kept position.
|
|
124
|
+
*/
|
|
125
|
+
#pending;
|
|
79
126
|
#state;
|
|
80
127
|
#frame;
|
|
81
128
|
#settling;
|
|
@@ -114,31 +161,28 @@ export class Viewer {
|
|
|
114
161
|
this.#spacer.className = "lamina-viewer-content";
|
|
115
162
|
Object.assign(this.#spacer.style, { position: "relative" });
|
|
116
163
|
this.#root.append(this.#spacer);
|
|
117
|
-
this.#state =
|
|
118
|
-
pageCount: 0,
|
|
119
|
-
currentPage: 0,
|
|
120
|
-
zoom: this.#zoom,
|
|
121
|
-
percent: 100,
|
|
122
|
-
rotation: 0,
|
|
123
|
-
scrollTop: 0,
|
|
124
|
-
visibleRange: { first: 0, last: -1 },
|
|
125
|
-
};
|
|
164
|
+
this.#state = emptyState(this.#zoom);
|
|
126
165
|
this.#listen();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
root: this.#root,
|
|
130
|
-
client: this.#client,
|
|
131
|
-
rotation: () => this.#rotation,
|
|
132
|
-
keepMounted: (pages) => this.keepMounted(pages),
|
|
133
|
-
};
|
|
134
|
-
this.#text = new TextLayer(host, options.text === true || options.text === undefined ? {} : options.text);
|
|
166
|
+
this.#text = this.#textLayer(options.text);
|
|
167
|
+
if (this.#text !== undefined)
|
|
135
168
|
this.addLayer(this.#text);
|
|
169
|
+
this.history = new NavigationHistory({ place: () => this.#place(), show: (p) => this.#show(p) }, options.history);
|
|
170
|
+
this.#regions = options.pages ?? {};
|
|
171
|
+
if (this.#regions.focusOnJump === true) {
|
|
172
|
+
this.history.onEntry((entry, replaced) => {
|
|
173
|
+
if (replaced || this.#keepFocus)
|
|
174
|
+
return;
|
|
175
|
+
this.#focusTarget = entry.point.page;
|
|
176
|
+
this.#schedule();
|
|
177
|
+
});
|
|
136
178
|
}
|
|
137
179
|
this.find = new FindController({
|
|
138
180
|
find: (query, find, pages, signal) => this.#client.find(query, find, pages, { signal }),
|
|
139
181
|
pageCount: () => this.#sizes.length,
|
|
140
182
|
currentPage: () => this.#state.currentPage,
|
|
141
|
-
|
|
183
|
+
// Each match find moves to is a jump, so Back returns to where the search started.
|
|
184
|
+
// A match is a jump too, but focus stays in the find field.
|
|
185
|
+
reveal: (page, quads) => this.#jumpKeepingFocus(() => this.#reveal(page, quads)),
|
|
142
186
|
redraw: (pages) => this.#highlights.refresh(pages),
|
|
143
187
|
});
|
|
144
188
|
this.#highlights = new HighlightLayer({
|
|
@@ -147,6 +191,28 @@ export class Viewer {
|
|
|
147
191
|
rotation: () => this.#rotation,
|
|
148
192
|
});
|
|
149
193
|
this.addLayer(this.#highlights);
|
|
194
|
+
this.#links = this.#linkLayer(options.links);
|
|
195
|
+
this.edits = this.#editSession();
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The edit session over this view's calls, which follows a restarted worker: its replay tells
|
|
199
|
+
* the session its history (`edit`), and lost edits leave it empty.
|
|
200
|
+
*/
|
|
201
|
+
#editSession() {
|
|
202
|
+
const edits = new EditSession({
|
|
203
|
+
applyEdit: (ops, call) => this.applyEdit(ops, call),
|
|
204
|
+
undoEdit: () => this.#editCall(() => this.#client.undoEdit()),
|
|
205
|
+
redoEdit: () => this.#editCall(() => this.#client.redoEdit()),
|
|
206
|
+
goToStep: (position) => this.#editCall(() => this.#client.goToStep(position)),
|
|
207
|
+
editHistory: () => this.#client.editHistory(),
|
|
208
|
+
});
|
|
209
|
+
this.#cleanup.push(this.#client.on("restarted", ({ reopened, edits: replay }) => {
|
|
210
|
+
if (reopened)
|
|
211
|
+
this.#reopened();
|
|
212
|
+
if (replay === "lost")
|
|
213
|
+
edits.reset(this.#client.document);
|
|
214
|
+
}), this.#client.on("edit", ({ history }) => edits.follow(history)));
|
|
215
|
+
return edits;
|
|
150
216
|
}
|
|
151
217
|
/** The scroll container (append it, or use {@link mount}). */
|
|
152
218
|
get element() {
|
|
@@ -161,14 +227,22 @@ export class Viewer {
|
|
|
161
227
|
open(document) {
|
|
162
228
|
this.#unmountAll();
|
|
163
229
|
this.#cache.clear();
|
|
164
|
-
this.#
|
|
230
|
+
this.#previews?.clear();
|
|
231
|
+
this.#documentId = document.id;
|
|
232
|
+
this.#opens++;
|
|
233
|
+
this.#revisions = new Map();
|
|
165
234
|
this.#pinned = new Set();
|
|
235
|
+
this.#focusTarget = undefined;
|
|
166
236
|
this.find.reset();
|
|
167
|
-
this
|
|
237
|
+
this.history.reset();
|
|
238
|
+
this.edits.reset(document);
|
|
239
|
+
this.#text?.open();
|
|
168
240
|
this.#sizes = document.pages.map((p) => ({ width: p.width, height: p.height }));
|
|
169
241
|
this.#layout = undefined;
|
|
170
242
|
this.#root.scrollTop = 0;
|
|
171
243
|
this.#root.scrollLeft = 0;
|
|
244
|
+
// A hidden view keeps its old scroll: the new document starts at its top once it shows.
|
|
245
|
+
this.#pending = this.#sized() ? undefined : "top";
|
|
172
246
|
this.#relayout(this.#viewport());
|
|
173
247
|
this.#schedule();
|
|
174
248
|
}
|
|
@@ -192,6 +266,23 @@ export class Viewer {
|
|
|
192
266
|
set.add(l);
|
|
193
267
|
return () => set.delete(l);
|
|
194
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Copies the text selection to the clipboard: by paragraph in reading order, or exact, as laid
|
|
271
|
+
* out. Call it in a user gesture (a click, a key). Resolves false when nothing is selected or
|
|
272
|
+
* the clipboard refused.
|
|
273
|
+
*/
|
|
274
|
+
copySelection(mode = "paragraph") {
|
|
275
|
+
return this.#text?.copy(mode) ?? Promise.resolve(false);
|
|
276
|
+
}
|
|
277
|
+
/** Whether a plain drag selects a rectangle, as an Alt/Option-drag does (column selection). */
|
|
278
|
+
get columnSelect() {
|
|
279
|
+
return this.#text?.columnMode ?? false;
|
|
280
|
+
}
|
|
281
|
+
/** Turns column selection by plain drag on or off; Alt/Option-drag selects a rectangle either way. */
|
|
282
|
+
set columnSelect(on) {
|
|
283
|
+
if (this.#text !== undefined)
|
|
284
|
+
this.#text.columnMode = on;
|
|
285
|
+
}
|
|
195
286
|
/**
|
|
196
287
|
* Keeps `pages` mounted wherever the view scrolls, until the next call replaces them: the pages
|
|
197
288
|
* a text selection touches, so scrolling never takes the selection away. A pinned page out of
|
|
@@ -253,6 +344,14 @@ export class Viewer {
|
|
|
253
344
|
this.#root.scrollLeft = Math.max(0, at.x);
|
|
254
345
|
this.#schedule();
|
|
255
346
|
}
|
|
347
|
+
/** {@link goToPage} as a history jump: Back returns to where the view was. */
|
|
348
|
+
jumpToPage(index) {
|
|
349
|
+
this.history.jump(() => this.goToPage(index));
|
|
350
|
+
}
|
|
351
|
+
/** {@link goToDestination} as a history jump: Back returns to where the view was. */
|
|
352
|
+
jumpToDestination(target) {
|
|
353
|
+
this.history.jump(() => this.goToDestination(target));
|
|
354
|
+
}
|
|
256
355
|
/**
|
|
257
356
|
* Sets the zoom (a mode, or a percentage clamped to 10–800 %), keeping the document point at
|
|
258
357
|
* `anchor` (viewport CSS px; default its centre) where it is.
|
|
@@ -275,6 +374,177 @@ export class Viewer {
|
|
|
275
374
|
// A half turn keeps every page's size, so no layer hears of it from a resize.
|
|
276
375
|
this.#text?.refresh();
|
|
277
376
|
this.#highlights.refresh();
|
|
377
|
+
this.#links?.refresh();
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Draws the pages in `appearance` (Sepia, Dark, High contrast or their own colours), keeping the
|
|
381
|
+
* scroll position: pages in view paint again, a quick pass then sharp tiles, and none of their
|
|
382
|
+
* old pixels is kept; other pages give their canvas back and paint when they come into view.
|
|
383
|
+
* The thumbnails follow.
|
|
384
|
+
*/
|
|
385
|
+
setAppearance(appearance) {
|
|
386
|
+
if (appearanceKey(appearance) === appearanceKey(this.#state.appearance))
|
|
387
|
+
return;
|
|
388
|
+
const { first, last } = this.#state.visibleRange;
|
|
389
|
+
for (const view of [...this.#pages.values()]) {
|
|
390
|
+
this.#abort(view);
|
|
391
|
+
view.tiles.clear();
|
|
392
|
+
view.detail?.tiles.clear();
|
|
393
|
+
if (view.index >= first && view.index <= last) {
|
|
394
|
+
view.appearance = appearanceKey(appearance);
|
|
395
|
+
view.painted = "";
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
// Its shell shows the new paper until it is seen (`#readyForAppearance`).
|
|
399
|
+
this.#releaseCanvas(view);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
this.#previews?.clear();
|
|
403
|
+
this.#setState({ appearance });
|
|
404
|
+
this.#schedule();
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Where `target` goes, as a picture for a link preview (R07): one render of the region around its
|
|
408
|
+
* point (or the top of its page) through the document worker, and the text of the lines it shows.
|
|
409
|
+
* The viewer keeps the last few (`PREVIEW_CACHE`) and closes them on a zoom, rotation or
|
|
410
|
+
* appearance change and when the document closes, so draw the bitmap at once and never close it.
|
|
411
|
+
* Rejects with `AbortError` when `signal` aborts or the pictures are cleared while it draws.
|
|
412
|
+
*/
|
|
413
|
+
preview(target, options = {}) {
|
|
414
|
+
return this.#linkPreviews().then((previews) => previews.picture(target, options.signal ?? new AbortController().signal));
|
|
415
|
+
}
|
|
416
|
+
/** The pointer is over the link preview (`true`) or has left it: a hovered preview stays (WCAG 1.4.13). */
|
|
417
|
+
holdLinkPreview(held) {
|
|
418
|
+
this.#links?.hold(held);
|
|
419
|
+
}
|
|
420
|
+
/** Escape or Close on the link preview: it goes, and stays hidden until its link is left. */
|
|
421
|
+
dismissLinkPreview() {
|
|
422
|
+
this.#links?.dismiss();
|
|
423
|
+
}
|
|
424
|
+
/** Whether a link is previewed or focused, so {@link followLinkPreview} has one (`nav.follow-link`). */
|
|
425
|
+
canFollowLink() {
|
|
426
|
+
return this.#links?.canFollow() ?? false;
|
|
427
|
+
}
|
|
428
|
+
/** Follows the previewed link, else the focused one (`nav.follow-link`); `false` when there is neither. */
|
|
429
|
+
followLinkPreview() {
|
|
430
|
+
return this.#links?.follow() ?? false;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Draws `pages` again: an edit changed them. Their bitmaps go, here and in a thumbnail strip,
|
|
434
|
+
* and their layers read their text and links again; every other page keeps what it drew.
|
|
435
|
+
* `sizes` gives the pages whose displayed size in points changed (a quarter turn swaps width and
|
|
436
|
+
* height): the layout takes them, and the page at the top of the viewport stays there. A page in
|
|
437
|
+
* view keeps its old pixels until the new ones are drawn over them.
|
|
438
|
+
*/
|
|
439
|
+
invalidate(pages, sizes) {
|
|
440
|
+
const count = this.#sizes.length;
|
|
441
|
+
const changed = new Set([...pages, ...(sizes?.keys() ?? [])].filter((p) => Number.isInteger(p) && p >= 0 && p < count));
|
|
442
|
+
if (changed.size === 0)
|
|
443
|
+
return;
|
|
444
|
+
const resized = new Set();
|
|
445
|
+
for (const [page, size] of sizes ?? []) {
|
|
446
|
+
const old = this.#sizes[page];
|
|
447
|
+
if (old === undefined || (old.width === size.width && old.height === size.height))
|
|
448
|
+
continue;
|
|
449
|
+
this.#sizes[page] = { width: size.width, height: size.height };
|
|
450
|
+
resized.add(page);
|
|
451
|
+
}
|
|
452
|
+
for (const page of changed) {
|
|
453
|
+
this.#revisions.set(page, (this.#revisions.get(page) ?? 0) + 1);
|
|
454
|
+
this.#cache.prune(page, () => false);
|
|
455
|
+
}
|
|
456
|
+
if (resized.size > 0)
|
|
457
|
+
this.#resize();
|
|
458
|
+
const { first, last } = this.#state.visibleRange;
|
|
459
|
+
for (const page of changed) {
|
|
460
|
+
const view = this.#pages.get(page);
|
|
461
|
+
if (view === undefined)
|
|
462
|
+
continue;
|
|
463
|
+
this.#redraw(view, resized.has(page) || page < first || page > last);
|
|
464
|
+
this.#remountLayers(view);
|
|
465
|
+
}
|
|
466
|
+
this.find.refresh([...changed]);
|
|
467
|
+
const list = [...changed].sort((a, b) => a - b);
|
|
468
|
+
this.#emit("invalidate", { pages: list.map((index) => ({ index, ...this.#sizes[index] })) });
|
|
469
|
+
this.#emit("renderstats", this.stats());
|
|
470
|
+
this.#schedule();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Applies `ops` to the open document through the client (`ViewerClient.applyEdit`) and redraws
|
|
474
|
+
* the pages they touched ({@link invalidate}): an edit on screen in one call. A failed edit
|
|
475
|
+
* changes nothing and redraws nothing.
|
|
476
|
+
*/
|
|
477
|
+
applyEdit(ops, options) {
|
|
478
|
+
return this.#editCall(() => this.#client.applyEdit(ops, options));
|
|
479
|
+
}
|
|
480
|
+
/** Runs an edit session call and redraws the pages it touched, unless another document opened meanwhile. */
|
|
481
|
+
async #editCall(call) {
|
|
482
|
+
const opens = this.#opens;
|
|
483
|
+
const result = await call();
|
|
484
|
+
// Another document opened meanwhile: these pages aren't its pages.
|
|
485
|
+
if (this.#opens === opens) {
|
|
486
|
+
this.invalidate(result.pages.map((page) => page.index), new Map(result.pages.map((page) => [page.index, page])));
|
|
487
|
+
}
|
|
488
|
+
return result;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Every page's displayed size in points, as the view lays it out: the sizes the document opened
|
|
492
|
+
* with, and those edits gave since (`invalidate`). A thumbnail strip lays its pages out from it.
|
|
493
|
+
* With `documentId`, empty unless the view shows that document: a page count alone can't tell two
|
|
494
|
+
* documents apart.
|
|
495
|
+
*/
|
|
496
|
+
pageSizes(documentId) {
|
|
497
|
+
if (documentId !== undefined && documentId !== this.#documentId)
|
|
498
|
+
return [];
|
|
499
|
+
return this.#sizes;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Where the reader is: the document point a gap below the viewport's top-left, the zoom and the
|
|
503
|
+
* rotation; `undefined` without a document. {@link restore} puts the view back there.
|
|
504
|
+
*/
|
|
505
|
+
viewPosition() {
|
|
506
|
+
const pending = this.#pending;
|
|
507
|
+
if (pending === "top" && this.#sizes.length > 0) {
|
|
508
|
+
return { point: { page: 0, x: 0, y: 0 }, zoom: this.#zoom, rotation: this.#rotation };
|
|
509
|
+
}
|
|
510
|
+
if (pending !== undefined && pending !== "top")
|
|
511
|
+
return pending;
|
|
512
|
+
const place = this.#place();
|
|
513
|
+
return place === undefined ? undefined : { ...place, rotation: this.#rotation };
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Puts the view at `position` (a {@link viewPosition} kept from an earlier visit), as a history
|
|
517
|
+
* jump from where the view is, so Back returns there. Its page is clamped to the document's and
|
|
518
|
+
* its point to the page. Called in the task that opened the document, before the next frame, it
|
|
519
|
+
* paints no other page first.
|
|
520
|
+
*/
|
|
521
|
+
restore(position) {
|
|
522
|
+
if (this.#layout === undefined || this.#sizes.length === 0)
|
|
523
|
+
return;
|
|
524
|
+
// Hidden: it takes place once the view has a size (`#listenResize`).
|
|
525
|
+
if (!this.#sized()) {
|
|
526
|
+
const page = Math.min(this.#sizes.length - 1, Math.max(0, Math.trunc(position.point.page) || 0));
|
|
527
|
+
this.#pending = { ...position, point: { ...position.point, page } };
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
this.#pending = undefined;
|
|
531
|
+
this.history.jump(() => {
|
|
532
|
+
const turned = isRotation(position.rotation) && position.rotation !== this.#rotation;
|
|
533
|
+
if (turned)
|
|
534
|
+
this.#rotation = position.rotation;
|
|
535
|
+
const clamp = (v) => (Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0);
|
|
536
|
+
const page = Math.min(this.#sizes.length - 1, Math.max(0, Math.trunc(position.point.page) || 0));
|
|
537
|
+
// The zoom is set before the scroll: #show only rezooms when the zoom changes.
|
|
538
|
+
this.#zoom = position.zoom;
|
|
539
|
+
this.#rezoom(undefined);
|
|
540
|
+
this.#show({ point: { page, x: clamp(position.point.x), y: clamp(position.point.y) }, zoom: position.zoom });
|
|
541
|
+
// As rotate() does: the layers follow once the pages are laid out for the new rotation.
|
|
542
|
+
if (turned) {
|
|
543
|
+
this.#text?.refresh();
|
|
544
|
+
this.#highlights.refresh();
|
|
545
|
+
this.#links?.refresh();
|
|
546
|
+
}
|
|
547
|
+
});
|
|
278
548
|
}
|
|
279
549
|
/** The document point at viewport position (`x`, `y`) in CSS px. */
|
|
280
550
|
pointAt(x, y) {
|
|
@@ -308,6 +578,7 @@ export class Viewer {
|
|
|
308
578
|
dropped: this.#dropped,
|
|
309
579
|
aborted: this.#aborted,
|
|
310
580
|
cacheBytes: this.#cache.bytes,
|
|
581
|
+
previewBitmaps: this.#previews?.size ?? 0,
|
|
311
582
|
pages,
|
|
312
583
|
};
|
|
313
584
|
}
|
|
@@ -319,13 +590,76 @@ export class Viewer {
|
|
|
319
590
|
clearTimeout(this.#settling);
|
|
320
591
|
this.#unmountAll();
|
|
321
592
|
this.#cache.clear();
|
|
593
|
+
this.#previews?.clear();
|
|
322
594
|
this.find.reset();
|
|
323
595
|
this.#text?.destroy();
|
|
596
|
+
this.#links?.destroy();
|
|
324
597
|
for (const f of this.#cleanup.splice(0))
|
|
325
598
|
f();
|
|
326
599
|
this.#root.remove();
|
|
327
600
|
}
|
|
328
601
|
// --- internals ---
|
|
602
|
+
/**
|
|
603
|
+
* The link previews' pictures, drawn at this viewer's zoom, rotation and appearance. Their code
|
|
604
|
+
* loads on the first preview, so the reader's startup script doesn't carry it.
|
|
605
|
+
*/
|
|
606
|
+
#linkPreviews() {
|
|
607
|
+
this.#loadingPreviews ??= import("./previews.js").then(({ LinkPreviews }) => {
|
|
608
|
+
this.#previews = new LinkPreviews({
|
|
609
|
+
client: this.#client,
|
|
610
|
+
size: (page) => this.#sizes[page],
|
|
611
|
+
rotation: () => this.#rotation,
|
|
612
|
+
percent: () => this.#state.percent,
|
|
613
|
+
appearance: () => this.#state.appearance,
|
|
614
|
+
dpr: () => this.#dpr(),
|
|
615
|
+
});
|
|
616
|
+
return this.#previews;
|
|
617
|
+
});
|
|
618
|
+
return this.#loadingPreviews;
|
|
619
|
+
}
|
|
620
|
+
/** The link layer, added to every page, unless `links` is `false`. */
|
|
621
|
+
#linkLayer(links) {
|
|
622
|
+
if (links === false)
|
|
623
|
+
return undefined;
|
|
624
|
+
const layer = new LinkLayer({
|
|
625
|
+
root: this.#root,
|
|
626
|
+
client: this.#client,
|
|
627
|
+
points: (page) => this.#sizes[page],
|
|
628
|
+
rotation: () => this.#rotation,
|
|
629
|
+
pageCount: () => this.#sizes.length,
|
|
630
|
+
goTo: (target) => this.jumpToDestination(target),
|
|
631
|
+
goToPage: (page) => this.jumpToPage(page),
|
|
632
|
+
preview: (request) => this.#emit("linkpreview", request),
|
|
633
|
+
previewEnd: (anchor) => this.#emit("linkpreviewend", { anchor }),
|
|
634
|
+
}, links === true || links === undefined ? {} : links);
|
|
635
|
+
this.addLayer(layer);
|
|
636
|
+
return layer;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Where the view is, for the history: the document point a gap below the viewport's top-left (so
|
|
640
|
+
* a page that `goToPage` put at the top, half a gap down, is the one named), and the zoom.
|
|
641
|
+
*/
|
|
642
|
+
#place() {
|
|
643
|
+
const layout = this.#layout;
|
|
644
|
+
if (layout === undefined || layout.pageCount === 0)
|
|
645
|
+
return undefined;
|
|
646
|
+
return { point: pointAt(layout, this.#root.clientWidth, this.#scroll(), { x: 0, y: this.#gap }), zoom: this.#zoom };
|
|
647
|
+
}
|
|
648
|
+
/** Puts the view back at a history `place`: its zoom, then its point a gap below the viewport's top-left. */
|
|
649
|
+
#show(place) {
|
|
650
|
+
this.#touch.stopMomentum();
|
|
651
|
+
if (place.zoom !== this.#zoom) {
|
|
652
|
+
this.#zoom = place.zoom;
|
|
653
|
+
this.#rezoom(undefined);
|
|
654
|
+
}
|
|
655
|
+
const layout = this.#layout;
|
|
656
|
+
if (layout === undefined || place.point.page >= layout.pageCount)
|
|
657
|
+
return;
|
|
658
|
+
const at = contentPosition(layout, this.#root.clientWidth, place.point);
|
|
659
|
+
this.#root.scrollTop = Math.max(0, at.y - this.#gap);
|
|
660
|
+
this.#root.scrollLeft = Math.max(0, at.x);
|
|
661
|
+
this.#schedule();
|
|
662
|
+
}
|
|
329
663
|
/**
|
|
330
664
|
* Scrolls so that `quads` (a find match, in page `page`'s points) are in view: nothing moves
|
|
331
665
|
* when they already are, else they come to the viewport's centre.
|
|
@@ -364,12 +698,124 @@ export class Viewer {
|
|
|
364
698
|
}
|
|
365
699
|
this.#schedule();
|
|
366
700
|
}
|
|
701
|
+
/**
|
|
702
|
+
* The worker restarted and opened the file again as it was, without the edits: the edited pages
|
|
703
|
+
* take their sizes from it and draw again (at a new revision, so no bitmap of theirs is reused).
|
|
704
|
+
*/
|
|
705
|
+
#reopened() {
|
|
706
|
+
const document = this.#client.document;
|
|
707
|
+
if (document === undefined || document.pages.length !== this.#sizes.length)
|
|
708
|
+
return;
|
|
709
|
+
this.#documentId = document.id;
|
|
710
|
+
const edited = [...this.#revisions.keys()];
|
|
711
|
+
const sizes = new Map(edited.map((index) => [index, document.pages[index]]));
|
|
712
|
+
this.invalidate(edited, sizes);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Lays the pages out again after some changed size, keeping the page at the viewport's top where
|
|
716
|
+
* it is (scaled into its new height, if it is one that changed). A fixed zoom stays; a fit mode
|
|
717
|
+
* fits again, since a page turned a quarter can be wider than the window at the old scale.
|
|
718
|
+
*/
|
|
719
|
+
#resize() {
|
|
720
|
+
const before = this.#layout;
|
|
721
|
+
if (before === undefined)
|
|
722
|
+
return;
|
|
723
|
+
const viewport = this.#viewport();
|
|
724
|
+
const scale = typeof this.#zoom === "string"
|
|
725
|
+
? scaleFor(this.#zoom, this.#reference(), this.#rotation, viewport, this.#gap)
|
|
726
|
+
: before.scale;
|
|
727
|
+
const after = new PageLayout(this.#sizes, { scale, rotation: before.rotation, gap: this.#gap });
|
|
728
|
+
if (after.height > MAX_HEIGHT) {
|
|
729
|
+
// Past what browsers can scroll: the zoom is capped again.
|
|
730
|
+
this.#rezoom(undefined);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const top = keptTop(before, after, this.#root.scrollTop);
|
|
734
|
+
this.#layout = after;
|
|
735
|
+
this.#spacer.style.height = `${after.height}px`;
|
|
736
|
+
this.#spacer.style.width = `${after.contentWidth(viewport.width)}px`;
|
|
737
|
+
this.#root.scrollTop = top;
|
|
738
|
+
if (scale !== before.scale) {
|
|
739
|
+
const percent = percentOfScale(scale);
|
|
740
|
+
this.#setState({ zoom: this.#zoom, percent });
|
|
741
|
+
this.#emit("zoomchange", { zoom: this.#zoom, percent });
|
|
742
|
+
this.#staleRenders();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Makes `view` draw its page again: its renders aborted (a late one is dropped), its tiles
|
|
747
|
+
* forgotten. A page in view at its old size keeps its pixels, and sharp tiles replace them; a
|
|
748
|
+
* page that changed size, or one outside the view, paints its quick pass first.
|
|
749
|
+
*/
|
|
750
|
+
#redraw(view, repaint) {
|
|
751
|
+
this.#abort(view);
|
|
752
|
+
view.waiting = false;
|
|
753
|
+
view.tiles.clear();
|
|
754
|
+
// A page whose render failed tries again from the start.
|
|
755
|
+
if (repaint || view.state === "error") {
|
|
756
|
+
this.#releaseDetail(view);
|
|
757
|
+
view.painted = "";
|
|
758
|
+
view.state = "placeholder";
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
view.detail?.tiles.clear();
|
|
762
|
+
if (view.state !== "placeholder")
|
|
763
|
+
view.state = "low";
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** Mounts `view`'s layers again, so they read its text and links again, at its size now. */
|
|
767
|
+
#remountLayers(view) {
|
|
768
|
+
const size = { width: this.#layout?.widths[view.index] ?? 0, height: this.#layout?.heights[view.index] ?? 0 };
|
|
769
|
+
for (const layer of this.#layers) {
|
|
770
|
+
const slot = this.#slot(view, layer.name);
|
|
771
|
+
layer.onUnmount?.(view.index, slot);
|
|
772
|
+
layer.onMount?.(view.index, slot);
|
|
773
|
+
layer.onZoom?.(view.index, slot, size);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
367
776
|
#scroll() {
|
|
368
777
|
return { left: this.#root.scrollLeft, top: this.#root.scrollTop };
|
|
369
778
|
}
|
|
370
779
|
#viewport() {
|
|
371
780
|
return { width: this.#root.clientWidth || 800, height: this.#root.clientHeight || 600 };
|
|
372
781
|
}
|
|
782
|
+
/** Whether the view has a size (it is in a shown container), so it can scroll. */
|
|
783
|
+
#sized() {
|
|
784
|
+
return this.#root.clientWidth > 0 && this.#root.clientHeight > 0;
|
|
785
|
+
}
|
|
786
|
+
/** Puts a view that just got its size where it was opened or restored to. */
|
|
787
|
+
#showPending(pending) {
|
|
788
|
+
this.#pending = undefined;
|
|
789
|
+
this.#relayout(this.#viewport());
|
|
790
|
+
// The scroll kept while hidden is the last document's: the top first, so a restore's history
|
|
791
|
+
// jump leaves from page 1.
|
|
792
|
+
this.#root.scrollTop = 0;
|
|
793
|
+
this.#root.scrollLeft = 0;
|
|
794
|
+
if (pending === "top")
|
|
795
|
+
this.#schedule();
|
|
796
|
+
else
|
|
797
|
+
this.restore(pending);
|
|
798
|
+
}
|
|
799
|
+
/** The text layer `option` asks for, or none (`false`); it reports the selection as an event too. */
|
|
800
|
+
#textLayer(option) {
|
|
801
|
+
if (option === false)
|
|
802
|
+
return undefined;
|
|
803
|
+
const host = {
|
|
804
|
+
root: this.#root,
|
|
805
|
+
client: this.#client,
|
|
806
|
+
rotation: () => this.#rotation,
|
|
807
|
+
points: (page) => this.#sizes[page],
|
|
808
|
+
keepMounted: (pages) => this.keepMounted(pages),
|
|
809
|
+
};
|
|
810
|
+
const textOptions = option === true || option === undefined ? {} : option;
|
|
811
|
+
return new TextLayer(host, {
|
|
812
|
+
...textOptions,
|
|
813
|
+
onSelection: (selection) => {
|
|
814
|
+
textOptions.onSelection?.(selection);
|
|
815
|
+
this.#emit("selectionchange", { selection });
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
}
|
|
373
819
|
#emit(event, data) {
|
|
374
820
|
for (const l of this.#events.get(event) ?? [])
|
|
375
821
|
l(data);
|
|
@@ -404,6 +850,8 @@ export class Viewer {
|
|
|
404
850
|
this.#spacer.style.height = `${layout.height}px`;
|
|
405
851
|
this.#spacer.style.width = `${layout.contentWidth(viewport.width)}px`;
|
|
406
852
|
if (zoomed) {
|
|
853
|
+
// Pictures drawn at the old zoom or rotation would show the wrong size or turn.
|
|
854
|
+
this.#previews?.clear();
|
|
407
855
|
const percent = percentOfScale(layout.scale);
|
|
408
856
|
this.#setState({ pageCount: layout.pageCount, zoom: this.#zoom, percent, rotation: this.#rotation });
|
|
409
857
|
this.#emit("zoomchange", { zoom: this.#zoom, percent });
|
|
@@ -424,8 +872,13 @@ export class Viewer {
|
|
|
424
872
|
const scroll = scrollToPoint(after, viewport.width, point, at);
|
|
425
873
|
this.#root.scrollTop = scroll.top;
|
|
426
874
|
this.#root.scrollLeft = scroll.left;
|
|
427
|
-
|
|
428
|
-
|
|
875
|
+
this.#staleRenders();
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Renders for the old geometry are stale: abort them (a page that failed gets another try); the
|
|
879
|
+
* canvases stretch until the zoom settles and the new pass paints, so no page is sharp.
|
|
880
|
+
*/
|
|
881
|
+
#staleRenders() {
|
|
429
882
|
let unsharpened = false;
|
|
430
883
|
for (const view of this.#pages.values()) {
|
|
431
884
|
this.#abort(view);
|
|
@@ -452,7 +905,9 @@ export class Viewer {
|
|
|
452
905
|
return;
|
|
453
906
|
this.#frame = requestAnimationFrame(() => {
|
|
454
907
|
this.#frame = undefined;
|
|
455
|
-
|
|
908
|
+
// A view waiting for its size has only an old scroll to go by: it draws once it is placed.
|
|
909
|
+
if (this.#pending === undefined)
|
|
910
|
+
this.#update();
|
|
456
911
|
});
|
|
457
912
|
}
|
|
458
913
|
/** The mounted range: visible ± margin pages, and at least a viewport above and below. */
|
|
@@ -480,6 +935,7 @@ export class Viewer {
|
|
|
480
935
|
const [first, last] = this.#mountRange(frame);
|
|
481
936
|
// Writes.
|
|
482
937
|
this.#placeShells(frame, first, last);
|
|
938
|
+
this.#focusJumpTarget();
|
|
483
939
|
this.#setState({
|
|
484
940
|
currentPage: layout.pageAt(frame.top + frame.height / 2),
|
|
485
941
|
scrollTop: frame.top,
|
|
@@ -516,6 +972,24 @@ export class Viewer {
|
|
|
516
972
|
if (this.#pages.size > this.#peakMounted)
|
|
517
973
|
this.#peakMounted = this.#pages.size;
|
|
518
974
|
}
|
|
975
|
+
#jumpKeepingFocus(go) {
|
|
976
|
+
this.#keepFocus = true;
|
|
977
|
+
try {
|
|
978
|
+
this.history.jump(go);
|
|
979
|
+
}
|
|
980
|
+
finally {
|
|
981
|
+
this.#keepFocus = false;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/** Focuses the page the last jump landed on, once it is mounted, keeping the scroll where it is. */
|
|
985
|
+
#focusJumpTarget() {
|
|
986
|
+
const target = this.#focusTarget;
|
|
987
|
+
const view = target === undefined ? undefined : this.#pages.get(target);
|
|
988
|
+
if (view === undefined)
|
|
989
|
+
return;
|
|
990
|
+
this.#focusTarget = undefined;
|
|
991
|
+
view.shell.focus({ preventScroll: true });
|
|
992
|
+
}
|
|
519
993
|
#notifyResize(view, width, height) {
|
|
520
994
|
for (const layer of this.#layers)
|
|
521
995
|
layer.onZoom?.(view.index, this.#slot(view, layer.name), { width, height });
|
|
@@ -533,7 +1007,17 @@ export class Viewer {
|
|
|
533
1007
|
const shell = document.createElement("div");
|
|
534
1008
|
shell.className = "lamina-page";
|
|
535
1009
|
shell.dataset.pageIndex = String(index);
|
|
536
|
-
|
|
1010
|
+
// A region for assistive technology, and a place focus can land after a jump.
|
|
1011
|
+
shell.setAttribute("role", "region");
|
|
1012
|
+
shell.setAttribute("aria-roledescription", this.#regions.roleDescription ?? "page");
|
|
1013
|
+
shell.setAttribute("aria-label", this.#pageName(index));
|
|
1014
|
+
shell.tabIndex = -1;
|
|
1015
|
+
// The paper shows before the page is drawn: white, or the page appearance's (--lamina-page-paper).
|
|
1016
|
+
Object.assign(shell.style, {
|
|
1017
|
+
position: "absolute",
|
|
1018
|
+
contain: "strict",
|
|
1019
|
+
background: "var(--lamina-page-paper, #fff)",
|
|
1020
|
+
});
|
|
537
1021
|
this.#spacer.append(shell);
|
|
538
1022
|
const view = {
|
|
539
1023
|
index,
|
|
@@ -543,6 +1027,7 @@ export class Viewer {
|
|
|
543
1027
|
controller: new AbortController(),
|
|
544
1028
|
state: "placeholder",
|
|
545
1029
|
painted: "",
|
|
1030
|
+
appearance: appearanceKey(this.#state.appearance),
|
|
546
1031
|
scale: 0,
|
|
547
1032
|
tiles: new Set(),
|
|
548
1033
|
detail: undefined,
|
|
@@ -565,9 +1050,13 @@ export class Viewer {
|
|
|
565
1050
|
slot.className = `lamina-layer-${name}`;
|
|
566
1051
|
Object.assign(slot.style, { position: "absolute", inset: "0" });
|
|
567
1052
|
// The slots above the text cover the whole page: taking the pointer, they would sit between
|
|
568
|
-
// every press and the spans, and no drag could select text.
|
|
569
|
-
|
|
1053
|
+
// every press and the spans, and no drag could select text. Nor are they part of a selection:
|
|
1054
|
+
// each element the viewer adds that holds nothing to select sets `none` itself, never an
|
|
1055
|
+
// ancestor of the spans (user-select.ts).
|
|
1056
|
+
if (name !== "text") {
|
|
570
1057
|
slot.style.pointerEvents = "none";
|
|
1058
|
+
setUserSelect(slot, "none");
|
|
1059
|
+
}
|
|
571
1060
|
view.slots.set(name, slot);
|
|
572
1061
|
view.shell.append(slot);
|
|
573
1062
|
}
|
|
@@ -580,6 +1069,10 @@ export class Viewer {
|
|
|
580
1069
|
view.pass++;
|
|
581
1070
|
view.busy = false;
|
|
582
1071
|
}
|
|
1072
|
+
#pageName(index) {
|
|
1073
|
+
const count = this.#sizes.length;
|
|
1074
|
+
return this.#regions.name?.(index, count) ?? `Page ${index + 1} of ${count}`;
|
|
1075
|
+
}
|
|
583
1076
|
#unmount(view) {
|
|
584
1077
|
this.#abort(view);
|
|
585
1078
|
for (const layer of this.#layers)
|
|
@@ -635,8 +1128,12 @@ export class Viewer {
|
|
|
635
1128
|
}
|
|
636
1129
|
/** The tile `clip` of page `index` at `scale`: its render request and cache key. */
|
|
637
1130
|
#tile(index, layout, scale, clip) {
|
|
638
|
-
const request = { page: index, scale, rotation: layout.rotation, clip };
|
|
639
|
-
return { clip, request, key:
|
|
1131
|
+
const request = { page: index, scale, rotation: layout.rotation, clip, ...appearanceField(this.#state.appearance) };
|
|
1132
|
+
return { clip, request, key: this.#key(request) };
|
|
1133
|
+
}
|
|
1134
|
+
/** `request`'s cache key: the render key with its page's revision. */
|
|
1135
|
+
#key(request) {
|
|
1136
|
+
return viewKey(request, this.#documentId ?? 0, this.#revisions.get(request.page) ?? 0);
|
|
640
1137
|
}
|
|
641
1138
|
/** The device px per pt of a sharp tile: the layout's scale × the device pixel ratio. */
|
|
642
1139
|
#fullScale(layout) {
|
|
@@ -665,31 +1162,53 @@ export class Viewer {
|
|
|
665
1162
|
return false;
|
|
666
1163
|
return tiles.every((t) => detail.tiles.has(t.key));
|
|
667
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Whether `view` may paint now, catching it up with the appearance: a page out of view when the
|
|
1167
|
+
* appearance changed waits until it is seen (`prefetch` doesn't paint it), then starts afresh.
|
|
1168
|
+
*/
|
|
1169
|
+
#readyForAppearance(view, priority) {
|
|
1170
|
+
const appearance = appearanceKey(this.#state.appearance);
|
|
1171
|
+
if (view.appearance === appearance)
|
|
1172
|
+
return true;
|
|
1173
|
+
if (priority === "prefetch")
|
|
1174
|
+
return false;
|
|
1175
|
+
view.appearance = appearance;
|
|
1176
|
+
view.painted = "";
|
|
1177
|
+
return true;
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* Whether `view`'s canvas needs nothing for `frame`: painted at its geometry and, in view, with
|
|
1181
|
+
* every tile in view drawn (then it is sharp). A margin page painted at its geometry waits.
|
|
1182
|
+
*/
|
|
1183
|
+
#upToDate(view, priority, frame) {
|
|
1184
|
+
if (view.painted !== geometryOf(frame.layout))
|
|
1185
|
+
return false;
|
|
1186
|
+
if (priority === "prefetch")
|
|
1187
|
+
return true;
|
|
1188
|
+
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
1189
|
+
if (view.scale > 0 && this.#sharpInView(view, frame)) {
|
|
1190
|
+
if (view.state !== "sharp") {
|
|
1191
|
+
view.state = "sharp";
|
|
1192
|
+
this.#emit("renderstats", this.stats());
|
|
1193
|
+
}
|
|
1194
|
+
return true;
|
|
1195
|
+
}
|
|
1196
|
+
// Tiles in view are missing (the page scrolled): it is no longer sharp until they are drawn.
|
|
1197
|
+
if (view.state === "sharp")
|
|
1198
|
+
view.state = "low";
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
668
1201
|
/** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
|
|
669
1202
|
#paint(index, priority, frame) {
|
|
670
1203
|
const view = this.#pages.get(index);
|
|
671
1204
|
// A page in the margin gives its detail canvas back for the pages in view.
|
|
672
1205
|
if (view !== undefined && priority === "prefetch")
|
|
673
1206
|
this.#releaseDetail(view);
|
|
674
|
-
if (view === undefined || this.#
|
|
1207
|
+
if (view === undefined || this.#documentId === undefined || view.busy || view.waiting || view.state === "error") {
|
|
675
1208
|
return;
|
|
676
1209
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
if (priority === "prefetch")
|
|
680
|
-
return;
|
|
681
|
-
// Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
|
|
682
|
-
if (view.scale > 0 && this.#sharpInView(view, frame)) {
|
|
683
|
-
if (view.state !== "sharp") {
|
|
684
|
-
view.state = "sharp";
|
|
685
|
-
this.#emit("renderstats", this.stats());
|
|
686
|
-
}
|
|
687
|
-
return;
|
|
688
|
-
}
|
|
689
|
-
// Tiles in view are missing (the page scrolled): it is no longer sharp until they are drawn.
|
|
690
|
-
if (view.state === "sharp")
|
|
691
|
-
view.state = "low";
|
|
692
|
-
}
|
|
1210
|
+
if (!this.#readyForAppearance(view, priority) || this.#upToDate(view, priority, frame))
|
|
1211
|
+
return;
|
|
693
1212
|
view.busy = true;
|
|
694
1213
|
const pass = view.pass;
|
|
695
1214
|
void this.#pass(view, pass, priority, frame)
|
|
@@ -744,8 +1263,13 @@ export class Viewer {
|
|
|
744
1263
|
const cssW = layout.widths[index];
|
|
745
1264
|
const cssH = layout.heights[index];
|
|
746
1265
|
const shown = rotated(this.#sizes[index], layout.rotation);
|
|
747
|
-
const request = {
|
|
748
|
-
|
|
1266
|
+
const request = {
|
|
1267
|
+
page: index,
|
|
1268
|
+
scale: lowScale(layout.scale * dpr, shown.width),
|
|
1269
|
+
rotation: layout.rotation,
|
|
1270
|
+
...appearanceField(this.#state.appearance),
|
|
1271
|
+
};
|
|
1272
|
+
const low = await this.#bitmap(request, this.#key(request), priority, signal);
|
|
749
1273
|
if (!live()) {
|
|
750
1274
|
this.#drop(low);
|
|
751
1275
|
return false;
|
|
@@ -921,6 +1445,7 @@ export class Viewer {
|
|
|
921
1445
|
});
|
|
922
1446
|
if (old === undefined) {
|
|
923
1447
|
canvas.className = "lamina-page-detail";
|
|
1448
|
+
setUserSelect(canvas, "none");
|
|
924
1449
|
if (view.canvas === undefined)
|
|
925
1450
|
view.shell.prepend(canvas);
|
|
926
1451
|
else
|
|
@@ -953,7 +1478,17 @@ export class Viewer {
|
|
|
953
1478
|
const cached = this.#cache.get(key);
|
|
954
1479
|
if (cached !== undefined && !isClosed(cached))
|
|
955
1480
|
return { bitmap: cached, owned: false };
|
|
1481
|
+
const revision = this.#revisions.get(request.page) ?? 0;
|
|
956
1482
|
const { bitmap } = await this.#client.render(request, { priority, signal });
|
|
1483
|
+
// The page was edited while this render waited: its pixels may be the old file's, and its key
|
|
1484
|
+
// names a revision nothing asks for again. The worker runs one call at a time and `invalidate`
|
|
1485
|
+
// runs only once the edit has answered, so an answer that comes after it, at the same
|
|
1486
|
+
// revision, drew the edited file; the worker's coalescing key needs no revision for the same
|
|
1487
|
+
// reason (no render can run across an edit).
|
|
1488
|
+
if ((this.#revisions.get(request.page) ?? 0) !== revision) {
|
|
1489
|
+
bitmap.close();
|
|
1490
|
+
throw new DOMException("the page changed while it rendered", "AbortError");
|
|
1491
|
+
}
|
|
957
1492
|
const kept = this.#cache.set(key, request.page, bitmap, this.#state.currentPage);
|
|
958
1493
|
return { bitmap, owned: !kept };
|
|
959
1494
|
}
|
|
@@ -1010,6 +1545,7 @@ export class Viewer {
|
|
|
1010
1545
|
if (canvas === undefined)
|
|
1011
1546
|
return undefined;
|
|
1012
1547
|
canvas.className = "lamina-page-canvas";
|
|
1548
|
+
setUserSelect(canvas, "none");
|
|
1013
1549
|
Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
|
|
1014
1550
|
view.shell.prepend(canvas);
|
|
1015
1551
|
view.canvas = canvas;
|
|
@@ -1122,6 +1658,10 @@ export class Viewer {
|
|
|
1122
1658
|
const resize = new ResizeObserver(() => {
|
|
1123
1659
|
if (this.#layout === undefined)
|
|
1124
1660
|
return;
|
|
1661
|
+
if (this.#pending !== undefined && this.#sized()) {
|
|
1662
|
+
this.#showPending(this.#pending);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1125
1665
|
if (typeof this.#zoom === "string")
|
|
1126
1666
|
this.#rezoom(undefined);
|
|
1127
1667
|
else
|