@laminapdf/viewer 0.2.0-alpha.4 → 0.2.0-alpha.5

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.
Files changed (90) hide show
  1. package/README.md +36 -8
  2. package/dist/client.d.ts +28 -1
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +105 -5
  5. package/dist/client.js.map +1 -1
  6. package/dist/first-draw.d.ts +10 -0
  7. package/dist/first-draw.d.ts.map +1 -0
  8. package/dist/first-draw.js +33 -0
  9. package/dist/first-draw.js.map +1 -0
  10. package/dist/history/history.d.ts +65 -0
  11. package/dist/history/history.d.ts.map +1 -0
  12. package/dist/history/history.js +135 -0
  13. package/dist/history/history.js.map +1 -0
  14. package/dist/index.d.ts +7 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/layers/copy.d.ts +26 -0
  19. package/dist/layers/copy.d.ts.map +1 -0
  20. package/dist/layers/copy.js +150 -0
  21. package/dist/layers/copy.js.map +1 -0
  22. package/dist/layers/link-layer.d.ts +68 -0
  23. package/dist/layers/link-layer.d.ts.map +1 -0
  24. package/dist/layers/link-layer.js +279 -0
  25. package/dist/layers/link-layer.js.map +1 -0
  26. package/dist/layers/link-url.d.ts +19 -0
  27. package/dist/layers/link-url.d.ts.map +1 -0
  28. package/dist/layers/link-url.js +47 -0
  29. package/dist/layers/link-url.js.map +1 -0
  30. package/dist/layers/spans.d.ts +0 -6
  31. package/dist/layers/spans.d.ts.map +1 -1
  32. package/dist/layers/spans.js +2 -20
  33. package/dist/layers/spans.js.map +1 -1
  34. package/dist/layers/structure-layer.d.ts.map +1 -1
  35. package/dist/layers/structure-layer.js +3 -0
  36. package/dist/layers/structure-layer.js.map +1 -1
  37. package/dist/layers/text-layer.d.ts +20 -3
  38. package/dist/layers/text-layer.d.ts.map +1 -1
  39. package/dist/layers/text-layer.js +307 -23
  40. package/dist/layers/text-layer.js.map +1 -1
  41. package/dist/protocol.d.ts +79 -5
  42. package/dist/protocol.d.ts.map +1 -1
  43. package/dist/protocol.js +16 -4
  44. package/dist/protocol.js.map +1 -1
  45. package/dist/source/opfs.d.ts +35 -0
  46. package/dist/source/opfs.d.ts.map +1 -0
  47. package/dist/source/opfs.js +178 -0
  48. package/dist/source/opfs.js.map +1 -0
  49. package/dist/thumbnails/strip.d.ts +1 -1
  50. package/dist/thumbnails/strip.d.ts.map +1 -1
  51. package/dist/thumbnails/strip.js +14 -3
  52. package/dist/thumbnails/strip.js.map +1 -1
  53. package/dist/view/user-select.d.ts +3 -0
  54. package/dist/view/user-select.d.ts.map +1 -0
  55. package/dist/view/user-select.js +10 -0
  56. package/dist/view/user-select.js.map +1 -0
  57. package/dist/view/viewer.d.ts +61 -1
  58. package/dist/view/viewer.d.ts.map +1 -1
  59. package/dist/view/viewer.js +248 -40
  60. package/dist/view/viewer.js.map +1 -1
  61. package/dist/worker/document.d.ts +23 -1
  62. package/dist/worker/document.d.ts.map +1 -1
  63. package/dist/worker/document.js +68 -22
  64. package/dist/worker/document.js.map +1 -1
  65. package/dist/worker/find.d.ts.map +1 -1
  66. package/dist/worker/find.js +5 -18
  67. package/dist/worker/find.js.map +1 -1
  68. package/dist/worker/methods.d.ts.map +1 -1
  69. package/dist/worker/methods.js +23 -4
  70. package/dist/worker/methods.js.map +1 -1
  71. package/dist/worker/navigation.d.ts +5 -1
  72. package/dist/worker/navigation.d.ts.map +1 -1
  73. package/dist/worker/navigation.js +22 -4
  74. package/dist/worker/navigation.js.map +1 -1
  75. package/dist/worker/print.d.ts +14 -0
  76. package/dist/worker/print.d.ts.map +1 -0
  77. package/dist/worker/print.js +49 -0
  78. package/dist/worker/print.js.map +1 -0
  79. package/dist/worker/recent-pages.d.ts +14 -0
  80. package/dist/worker/recent-pages.d.ts.map +1 -0
  81. package/dist/worker/recent-pages.js +53 -0
  82. package/dist/worker/recent-pages.js.map +1 -0
  83. package/dist/worker/render.d.ts.map +1 -1
  84. package/dist/worker/render.js +5 -2
  85. package/dist/worker/render.js.map +1 -1
  86. package/dist/worker/text.d.ts +9 -1
  87. package/dist/worker/text.d.ts.map +1 -1
  88. package/dist/worker/text.js +29 -22
  89. package/dist/worker/text.js.map +1 -1
  90. package/package.json +6 -5
@@ -12,16 +12,19 @@ import { pagePixels } from "@laminapdf/render";
12
12
  import { BitmapCache } from "../canvas/cache.js";
13
13
  import { CanvasPool } from "../canvas/pool.js";
14
14
  import { FindController } from "../find/controller.js";
15
+ import { NavigationHistory } from "../history/history.js";
15
16
  import { HighlightLayer } from "../layers/highlight-layer.js";
17
+ import { LinkLayer } from "../layers/link-layer.js";
16
18
  import { TextLayer } from "../layers/text-layer.js";
17
19
  import { isRotation, PageLayout, rotated } from "../layout/page-layout.js";
18
20
  import { range } from "../layout/range.js";
19
- import { renderKey, renderKeyFields } from "../protocol.js";
21
+ import { appearanceField, appearanceKey, renderKey, renderKeyFields, } from "../protocol.js";
20
22
  import { backdropRatio, bounds, contains, lowScale, sharpScale, tilesFor } from "../render/tiles.js";
21
23
  import { destinationView } from "../zoom/destination.js";
22
24
  import { contentPosition, percentOfScale, pointAt, scaleFor, scrollToPoint, } from "../zoom/zoom.js";
23
25
  import { deviceHints, viewerBudgets } from "./budgets.js";
24
26
  import { TouchGestures } from "./touch.js";
27
+ import { setUserSelect } from "./user-select.js";
25
28
  /** The built-in layer slots, bottom to top, above the canvas. */
26
29
  const SLOTS = ["text", "annot", "highlight"];
27
30
  /** The tallest scroll space browsers handle (Firefox stops near 17.9 M CSS px). */
@@ -29,6 +32,19 @@ const MAX_HEIGHT = 16_000_000;
29
32
  function isAbort(e) {
30
33
  return e instanceof DOMException && e.name === "AbortError";
31
34
  }
35
+ /** The state before a document opens. */
36
+ function emptyState(zoom) {
37
+ return {
38
+ pageCount: 0,
39
+ currentPage: 0,
40
+ zoom,
41
+ percent: 100,
42
+ rotation: 0,
43
+ appearance: "original",
44
+ scrollTop: 0,
45
+ visibleRange: { first: 0, last: -1 },
46
+ };
47
+ }
32
48
  /** What a painted canvas depends on: a zoom or a rotation makes it stale. */
33
49
  function geometryOf(layout) {
34
50
  return `${layout.scale}:${layout.rotation}`;
@@ -64,10 +80,22 @@ export class Viewer {
64
80
  /** The text layer, unless `text: false`. */
65
81
  #text;
66
82
  #highlights;
83
+ /** The link layer, unless `links: false`. */
84
+ #links;
67
85
  /** Find in this viewer's document: query, options, matches, next and previous. */
68
86
  find;
87
+ /**
88
+ * Back and forward through the places jumped from and to: links, `jumpToPage`,
89
+ * `jumpToDestination` and find's matches record a jump; scrolling doesn't.
90
+ */
91
+ history;
69
92
  /** Pages kept mounted whatever the scroll (`keepMounted`): those a selection touches. */
70
93
  #pinned = new Set();
94
+ #regions;
95
+ /** The page a jump landed on, focused once it is mounted. */
96
+ #focusTarget;
97
+ /** True while a jump that leaves focus where it is (a find match) runs. */
98
+ #keepFocus = false;
71
99
  #listeners = new Set();
72
100
  #events = new Map();
73
101
  #cleanup = [];
@@ -114,31 +142,28 @@ export class Viewer {
114
142
  this.#spacer.className = "lamina-viewer-content";
115
143
  Object.assign(this.#spacer.style, { position: "relative" });
116
144
  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
- };
145
+ this.#state = emptyState(this.#zoom);
126
146
  this.#listen();
127
- if (options.text !== false) {
128
- const host = {
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);
147
+ this.#text = this.#textLayer(options.text);
148
+ if (this.#text !== undefined)
135
149
  this.addLayer(this.#text);
150
+ this.history = new NavigationHistory({ place: () => this.#place(), show: (p) => this.#show(p) }, options.history);
151
+ this.#regions = options.pages ?? {};
152
+ if (this.#regions.focusOnJump === true) {
153
+ this.history.onEntry((entry, replaced) => {
154
+ if (replaced || this.#keepFocus)
155
+ return;
156
+ this.#focusTarget = entry.point.page;
157
+ this.#schedule();
158
+ });
136
159
  }
137
160
  this.find = new FindController({
138
161
  find: (query, find, pages, signal) => this.#client.find(query, find, pages, { signal }),
139
162
  pageCount: () => this.#sizes.length,
140
163
  currentPage: () => this.#state.currentPage,
141
- reveal: (page, quads) => this.#reveal(page, quads),
164
+ // Each match find moves to is a jump, so Back returns to where the search started.
165
+ // A match is a jump too, but focus stays in the find field.
166
+ reveal: (page, quads) => this.#jumpKeepingFocus(() => this.#reveal(page, quads)),
142
167
  redraw: (pages) => this.#highlights.refresh(pages),
143
168
  });
144
169
  this.#highlights = new HighlightLayer({
@@ -147,6 +172,7 @@ export class Viewer {
147
172
  rotation: () => this.#rotation,
148
173
  });
149
174
  this.addLayer(this.#highlights);
175
+ this.#links = this.#linkLayer(options.links);
150
176
  }
151
177
  /** The scroll container (append it, or use {@link mount}). */
152
178
  get element() {
@@ -163,7 +189,9 @@ export class Viewer {
163
189
  this.#cache.clear();
164
190
  this.#document = document;
165
191
  this.#pinned = new Set();
192
+ this.#focusTarget = undefined;
166
193
  this.find.reset();
194
+ this.history.reset();
167
195
  this.#text?.open(document);
168
196
  this.#sizes = document.pages.map((p) => ({ width: p.width, height: p.height }));
169
197
  this.#layout = undefined;
@@ -192,6 +220,23 @@ export class Viewer {
192
220
  set.add(l);
193
221
  return () => set.delete(l);
194
222
  }
223
+ /**
224
+ * Copies the text selection to the clipboard: by paragraph in reading order, or exact, as laid
225
+ * out. Call it in a user gesture (a click, a key). Resolves false when nothing is selected or
226
+ * the clipboard refused.
227
+ */
228
+ copySelection(mode = "paragraph") {
229
+ return this.#text?.copy(mode) ?? Promise.resolve(false);
230
+ }
231
+ /** Whether a plain drag selects a rectangle, as an Alt/Option-drag does (column selection). */
232
+ get columnSelect() {
233
+ return this.#text?.columnMode ?? false;
234
+ }
235
+ /** Turns column selection by plain drag on or off; Alt/Option-drag selects a rectangle either way. */
236
+ set columnSelect(on) {
237
+ if (this.#text !== undefined)
238
+ this.#text.columnMode = on;
239
+ }
195
240
  /**
196
241
  * Keeps `pages` mounted wherever the view scrolls, until the next call replaces them: the pages
197
242
  * a text selection touches, so scrolling never takes the selection away. A pinned page out of
@@ -253,6 +298,14 @@ export class Viewer {
253
298
  this.#root.scrollLeft = Math.max(0, at.x);
254
299
  this.#schedule();
255
300
  }
301
+ /** {@link goToPage} as a history jump: Back returns to where the view was. */
302
+ jumpToPage(index) {
303
+ this.history.jump(() => this.goToPage(index));
304
+ }
305
+ /** {@link goToDestination} as a history jump: Back returns to where the view was. */
306
+ jumpToDestination(target) {
307
+ this.history.jump(() => this.goToDestination(target));
308
+ }
256
309
  /**
257
310
  * Sets the zoom (a mode, or a percentage clamped to 10–800 %), keeping the document point at
258
311
  * `anchor` (viewport CSS px; default its centre) where it is.
@@ -275,6 +328,33 @@ export class Viewer {
275
328
  // A half turn keeps every page's size, so no layer hears of it from a resize.
276
329
  this.#text?.refresh();
277
330
  this.#highlights.refresh();
331
+ this.#links?.refresh();
332
+ }
333
+ /**
334
+ * Draws the pages in `appearance` (Sepia, Dark, High contrast or their own colours), keeping the
335
+ * scroll position: pages in view paint again, a quick pass then sharp tiles, and none of their
336
+ * old pixels is kept; other pages give their canvas back and paint when they come into view.
337
+ * The thumbnails follow.
338
+ */
339
+ setAppearance(appearance) {
340
+ if (appearanceKey(appearance) === appearanceKey(this.#state.appearance))
341
+ return;
342
+ const { first, last } = this.#state.visibleRange;
343
+ for (const view of [...this.#pages.values()]) {
344
+ this.#abort(view);
345
+ view.tiles.clear();
346
+ view.detail?.tiles.clear();
347
+ if (view.index >= first && view.index <= last) {
348
+ view.appearance = appearanceKey(appearance);
349
+ view.painted = "";
350
+ }
351
+ else {
352
+ // Its shell shows the new paper until it is seen (`#readyForAppearance`).
353
+ this.#releaseCanvas(view);
354
+ }
355
+ }
356
+ this.#setState({ appearance });
357
+ this.#schedule();
278
358
  }
279
359
  /** The document point at viewport position (`x`, `y`) in CSS px. */
280
360
  pointAt(x, y) {
@@ -321,11 +401,53 @@ export class Viewer {
321
401
  this.#cache.clear();
322
402
  this.find.reset();
323
403
  this.#text?.destroy();
404
+ this.#links?.destroy();
324
405
  for (const f of this.#cleanup.splice(0))
325
406
  f();
326
407
  this.#root.remove();
327
408
  }
328
409
  // --- internals ---
410
+ /** The link layer, added to every page, unless `links` is `false`. */
411
+ #linkLayer(links) {
412
+ if (links === false)
413
+ return undefined;
414
+ const layer = new LinkLayer({
415
+ root: this.#root,
416
+ client: this.#client,
417
+ points: (page) => this.#sizes[page],
418
+ rotation: () => this.#rotation,
419
+ pageCount: () => this.#sizes.length,
420
+ goTo: (target) => this.jumpToDestination(target),
421
+ goToPage: (page) => this.jumpToPage(page),
422
+ }, links === true || links === undefined ? {} : links);
423
+ this.addLayer(layer);
424
+ return layer;
425
+ }
426
+ /**
427
+ * Where the view is, for the history: the document point a gap below the viewport's top-left (so
428
+ * a page that `goToPage` put at the top, half a gap down, is the one named), and the zoom.
429
+ */
430
+ #place() {
431
+ const layout = this.#layout;
432
+ if (layout === undefined || layout.pageCount === 0)
433
+ return undefined;
434
+ return { point: pointAt(layout, this.#root.clientWidth, this.#scroll(), { x: 0, y: this.#gap }), zoom: this.#zoom };
435
+ }
436
+ /** Puts the view back at a history `place`: its zoom, then its point a gap below the viewport's top-left. */
437
+ #show(place) {
438
+ this.#touch.stopMomentum();
439
+ if (place.zoom !== this.#zoom) {
440
+ this.#zoom = place.zoom;
441
+ this.#rezoom(undefined);
442
+ }
443
+ const layout = this.#layout;
444
+ if (layout === undefined || place.point.page >= layout.pageCount)
445
+ return;
446
+ const at = contentPosition(layout, this.#root.clientWidth, place.point);
447
+ this.#root.scrollTop = Math.max(0, at.y - this.#gap);
448
+ this.#root.scrollLeft = Math.max(0, at.x);
449
+ this.#schedule();
450
+ }
329
451
  /**
330
452
  * Scrolls so that `quads` (a find match, in page `page`'s points) are in view: nothing moves
331
453
  * when they already are, else they come to the viewport's centre.
@@ -370,6 +492,25 @@ export class Viewer {
370
492
  #viewport() {
371
493
  return { width: this.#root.clientWidth || 800, height: this.#root.clientHeight || 600 };
372
494
  }
495
+ /** The text layer `option` asks for, or none (`false`); it reports the selection as an event too. */
496
+ #textLayer(option) {
497
+ if (option === false)
498
+ return undefined;
499
+ const host = {
500
+ root: this.#root,
501
+ client: this.#client,
502
+ rotation: () => this.#rotation,
503
+ keepMounted: (pages) => this.keepMounted(pages),
504
+ };
505
+ const textOptions = option === true || option === undefined ? {} : option;
506
+ return new TextLayer(host, {
507
+ ...textOptions,
508
+ onSelection: (selection) => {
509
+ textOptions.onSelection?.(selection);
510
+ this.#emit("selectionchange", { selection });
511
+ },
512
+ });
513
+ }
373
514
  #emit(event, data) {
374
515
  for (const l of this.#events.get(event) ?? [])
375
516
  l(data);
@@ -480,6 +621,7 @@ export class Viewer {
480
621
  const [first, last] = this.#mountRange(frame);
481
622
  // Writes.
482
623
  this.#placeShells(frame, first, last);
624
+ this.#focusJumpTarget();
483
625
  this.#setState({
484
626
  currentPage: layout.pageAt(frame.top + frame.height / 2),
485
627
  scrollTop: frame.top,
@@ -516,6 +658,24 @@ export class Viewer {
516
658
  if (this.#pages.size > this.#peakMounted)
517
659
  this.#peakMounted = this.#pages.size;
518
660
  }
661
+ #jumpKeepingFocus(go) {
662
+ this.#keepFocus = true;
663
+ try {
664
+ this.history.jump(go);
665
+ }
666
+ finally {
667
+ this.#keepFocus = false;
668
+ }
669
+ }
670
+ /** Focuses the page the last jump landed on, once it is mounted, keeping the scroll where it is. */
671
+ #focusJumpTarget() {
672
+ const target = this.#focusTarget;
673
+ const view = target === undefined ? undefined : this.#pages.get(target);
674
+ if (view === undefined)
675
+ return;
676
+ this.#focusTarget = undefined;
677
+ view.shell.focus({ preventScroll: true });
678
+ }
519
679
  #notifyResize(view, width, height) {
520
680
  for (const layer of this.#layers)
521
681
  layer.onZoom?.(view.index, this.#slot(view, layer.name), { width, height });
@@ -533,7 +693,17 @@ export class Viewer {
533
693
  const shell = document.createElement("div");
534
694
  shell.className = "lamina-page";
535
695
  shell.dataset.pageIndex = String(index);
536
- Object.assign(shell.style, { position: "absolute", contain: "strict", background: "#fff" });
696
+ // A region for assistive technology, and a place focus can land after a jump.
697
+ shell.setAttribute("role", "region");
698
+ shell.setAttribute("aria-roledescription", this.#regions.roleDescription ?? "page");
699
+ shell.setAttribute("aria-label", this.#pageName(index));
700
+ shell.tabIndex = -1;
701
+ // The paper shows before the page is drawn: white, or the page appearance's (--lamina-page-paper).
702
+ Object.assign(shell.style, {
703
+ position: "absolute",
704
+ contain: "strict",
705
+ background: "var(--lamina-page-paper, #fff)",
706
+ });
537
707
  this.#spacer.append(shell);
538
708
  const view = {
539
709
  index,
@@ -543,6 +713,7 @@ export class Viewer {
543
713
  controller: new AbortController(),
544
714
  state: "placeholder",
545
715
  painted: "",
716
+ appearance: appearanceKey(this.#state.appearance),
546
717
  scale: 0,
547
718
  tiles: new Set(),
548
719
  detail: undefined,
@@ -565,9 +736,13 @@ export class Viewer {
565
736
  slot.className = `lamina-layer-${name}`;
566
737
  Object.assign(slot.style, { position: "absolute", inset: "0" });
567
738
  // 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
- if (name !== "text")
739
+ // every press and the spans, and no drag could select text. Nor are they part of a selection:
740
+ // each element the viewer adds that holds nothing to select sets `none` itself, never an
741
+ // ancestor of the spans (user-select.ts).
742
+ if (name !== "text") {
570
743
  slot.style.pointerEvents = "none";
744
+ setUserSelect(slot, "none");
745
+ }
571
746
  view.slots.set(name, slot);
572
747
  view.shell.append(slot);
573
748
  }
@@ -580,6 +755,10 @@ export class Viewer {
580
755
  view.pass++;
581
756
  view.busy = false;
582
757
  }
758
+ #pageName(index) {
759
+ const count = this.#sizes.length;
760
+ return this.#regions.name?.(index, count) ?? `Page ${index + 1} of ${count}`;
761
+ }
583
762
  #unmount(view) {
584
763
  this.#abort(view);
585
764
  for (const layer of this.#layers)
@@ -635,7 +814,7 @@ export class Viewer {
635
814
  }
636
815
  /** The tile `clip` of page `index` at `scale`: its render request and cache key. */
637
816
  #tile(index, layout, scale, clip) {
638
- const request = { page: index, scale, rotation: layout.rotation, clip };
817
+ const request = { page: index, scale, rotation: layout.rotation, clip, ...appearanceField(this.#state.appearance) };
639
818
  return { clip, request, key: renderKey(request, this.#document?.id ?? 0) };
640
819
  }
641
820
  /** The device px per pt of a sharp tile: the layout's scale × the device pixel ratio. */
@@ -665,6 +844,42 @@ export class Viewer {
665
844
  return false;
666
845
  return tiles.every((t) => detail.tiles.has(t.key));
667
846
  }
847
+ /**
848
+ * Whether `view` may paint now, catching it up with the appearance: a page out of view when the
849
+ * appearance changed waits until it is seen (`prefetch` doesn't paint it), then starts afresh.
850
+ */
851
+ #readyForAppearance(view, priority) {
852
+ const appearance = appearanceKey(this.#state.appearance);
853
+ if (view.appearance === appearance)
854
+ return true;
855
+ if (priority === "prefetch")
856
+ return false;
857
+ view.appearance = appearance;
858
+ view.painted = "";
859
+ return true;
860
+ }
861
+ /**
862
+ * Whether `view`'s canvas needs nothing for `frame`: painted at its geometry and, in view, with
863
+ * every tile in view drawn (then it is sharp). A margin page painted at its geometry waits.
864
+ */
865
+ #upToDate(view, priority, frame) {
866
+ if (view.painted !== geometryOf(frame.layout))
867
+ return false;
868
+ if (priority === "prefetch")
869
+ return true;
870
+ // Sharp means every tile now in view is drawn: scrolling down a tall page sharpens the rest.
871
+ if (view.scale > 0 && this.#sharpInView(view, frame)) {
872
+ if (view.state !== "sharp") {
873
+ view.state = "sharp";
874
+ this.#emit("renderstats", this.stats());
875
+ }
876
+ return true;
877
+ }
878
+ // Tiles in view are missing (the page scrolled): it is no longer sharp until they are drawn.
879
+ if (view.state === "sharp")
880
+ view.state = "low";
881
+ return false;
882
+ }
668
883
  /** Starts page `index`'s pass if it isn't busy and its canvas isn't done for `frame` yet. */
669
884
  #paint(index, priority, frame) {
670
885
  const view = this.#pages.get(index);
@@ -674,22 +889,8 @@ export class Viewer {
674
889
  if (view === undefined || this.#document === undefined || view.busy || view.waiting || view.state === "error") {
675
890
  return;
676
891
  }
677
- const { layout } = frame;
678
- if (view.painted === geometryOf(layout)) {
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
- }
892
+ if (!this.#readyForAppearance(view, priority) || this.#upToDate(view, priority, frame))
893
+ return;
693
894
  view.busy = true;
694
895
  const pass = view.pass;
695
896
  void this.#pass(view, pass, priority, frame)
@@ -744,7 +945,12 @@ export class Viewer {
744
945
  const cssW = layout.widths[index];
745
946
  const cssH = layout.heights[index];
746
947
  const shown = rotated(this.#sizes[index], layout.rotation);
747
- const request = { page: index, scale: lowScale(layout.scale * dpr, shown.width), rotation: layout.rotation };
948
+ const request = {
949
+ page: index,
950
+ scale: lowScale(layout.scale * dpr, shown.width),
951
+ rotation: layout.rotation,
952
+ ...appearanceField(this.#state.appearance),
953
+ };
748
954
  const low = await this.#bitmap(request, renderKey(request, this.#document?.id ?? 0), priority, signal);
749
955
  if (!live()) {
750
956
  this.#drop(low);
@@ -921,6 +1127,7 @@ export class Viewer {
921
1127
  });
922
1128
  if (old === undefined) {
923
1129
  canvas.className = "lamina-page-detail";
1130
+ setUserSelect(canvas, "none");
924
1131
  if (view.canvas === undefined)
925
1132
  view.shell.prepend(canvas);
926
1133
  else
@@ -1010,6 +1217,7 @@ export class Viewer {
1010
1217
  if (canvas === undefined)
1011
1218
  return undefined;
1012
1219
  canvas.className = "lamina-page-canvas";
1220
+ setUserSelect(canvas, "none");
1013
1221
  Object.assign(canvas.style, { position: "absolute", inset: "0", width: "100%", height: "100%" });
1014
1222
  view.shell.prepend(canvas);
1015
1223
  view.canvas = canvas;