@internetarchive/bookreader 5.0.0-116 → 5.0.0-117

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 (36) hide show
  1. package/BookReader/BookReader.css +44 -18
  2. package/BookReader/BookReader.js +1 -1
  3. package/BookReader/BookReader.js.map +1 -1
  4. package/BookReader/ia-bookreader-bundle.js +74 -74
  5. package/BookReader/ia-bookreader-bundle.js.map +1 -1
  6. package/BookReader/plugins/plugin.archive_analytics.js +1 -1
  7. package/BookReader/plugins/plugin.autoplay.js +1 -1
  8. package/BookReader/plugins/plugin.autoplay.js.map +1 -1
  9. package/BookReader/plugins/plugin.chapters.js +2 -2
  10. package/BookReader/plugins/plugin.chapters.js.map +1 -1
  11. package/BookReader/plugins/plugin.experiments.js +1 -1
  12. package/BookReader/plugins/plugin.experiments.js.map +1 -1
  13. package/BookReader/plugins/plugin.iframe.js +1 -1
  14. package/BookReader/plugins/plugin.iframe.js.map +1 -1
  15. package/BookReader/plugins/plugin.iiif.js +1 -1
  16. package/BookReader/plugins/plugin.resume.js +1 -1
  17. package/BookReader/plugins/plugin.search.js +1 -1
  18. package/BookReader/plugins/plugin.search.js.map +1 -1
  19. package/BookReader/plugins/plugin.text_selection.js +22 -2
  20. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  21. package/BookReader/plugins/plugin.translate.js +23 -3
  22. package/BookReader/plugins/plugin.translate.js.map +1 -1
  23. package/BookReader/plugins/plugin.tts.js +1 -1
  24. package/BookReader/plugins/plugin.tts.js.map +1 -1
  25. package/BookReader/plugins/plugin.url.js +1 -1
  26. package/README.md +3 -1
  27. package/package.json +33 -29
  28. package/src/BookReader.js +16 -0
  29. package/src/css/_TextSelection.scss +51 -18
  30. package/src/plugins/plugin.chapters.js +8 -4
  31. package/src/plugins/plugin.iframe.js +36 -37
  32. package/src/plugins/plugin.text_selection.js +36 -11
  33. package/src/plugins/search/plugin.search.js +2 -8
  34. package/src/plugins/tts/plugin.tts.js +3 -8
  35. package/src/util/TextSelectionManager.js +53 -8
  36. package/src/util/dom.js +88 -0
@@ -6,6 +6,7 @@ import { Cache } from '../util/cache.js';
6
6
  import { toISO6391 } from './tts/utils.js';
7
7
  import { BookReaderTextFragment, renderHighlight, TextSelectionManager } from '../util/TextSelectionManager.js';
8
8
  import { genMap, lookAroundWindow, zip } from '../util/generators.js';
9
+ import textSelectionCss from '../css/_TextSelection.scss';
9
10
  /** @typedef {import('../util/strings.js').StringWithVars} StringWithVars */
10
11
  /** @typedef {import('../BookReader/PageContainer.js').PageContainer} PageContainer */
11
12
 
@@ -39,6 +40,13 @@ export class TextSelectionPlugin extends BookReaderPlugin {
39
40
 
40
41
  _jumpedToHighlight = false;
41
42
 
43
+ /**
44
+ * Isolated document/layout used to performantly measure OCR text-layer
45
+ * elements.
46
+ * @type {Document}
47
+ */
48
+ _measurementDocument;
49
+
42
50
  /**
43
51
  * @param {import('../BookReader.js').default} br
44
52
  */
@@ -56,6 +64,19 @@ export class TextSelectionPlugin extends BookReaderPlugin {
56
64
  init() {
57
65
  if (!this.options.enabled) return;
58
66
 
67
+ // Setup measurement iframe for OCR
68
+ const measurementIframe = document.createElement('iframe');
69
+ measurementIframe.setAttribute('aria-hidden', 'true');
70
+ measurementIframe.tabIndex = -1;
71
+ measurementIframe.style.cssText = 'position:fixed; top:-99999px; left:-99999px; width:2000px; height:4000px; border:0; visibility:hidden;';
72
+ document.body.appendChild(measurementIframe);
73
+ this._measurementDocument = measurementIframe.contentDocument;
74
+ // Injects _TextSelection.scss so measurements match the real
75
+ // rendering
76
+ const style = this._measurementDocument.createElement('style');
77
+ style.textContent = textSelectionCss;
78
+ this._measurementDocument.head.appendChild(style);
79
+
59
80
  this.br.on('pageVisible', (_, {pageContainerEl}) => {
60
81
  const textLayer = pageContainerEl.querySelector('.BRtextLayer');
61
82
  if (textLayer) {
@@ -78,7 +99,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
78
99
  const markEls = renderHighlight(textLayer, targetTextFragment, 'BRhighlight--target-text');
79
100
  // Only jump once; presumably on first page load.
80
101
  if (!this._jumpedToHighlight) {
81
- markEls[0].scrollIntoView({behavior: 'smooth', block: 'center'});
102
+ this.br.scrollIntoView(markEls[0], {behavior: 'smooth', block: 'center'});
82
103
  this._jumpedToHighlight = true;
83
104
  }
84
105
  }
@@ -94,7 +115,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
94
115
  _configurePageContainer(pageContainer) {
95
116
  // Disable if thumb mode; it's too janky
96
117
  // .page can be null for "pre-cover" region
97
- if (this.br.mode !== this.br.constModeThumb && pageContainer.page?.isViewable) {
118
+ if (this.options.enabled && this.br.mode !== this.br.constModeThumb && pageContainer.page?.isViewable) {
98
119
  this.createTextLayer(pageContainer);
99
120
  }
100
121
  return pageContainer;
@@ -204,7 +225,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
204
225
  });
205
226
 
206
227
  // Fix up paragraph positions
207
- const paragraphRects = determineRealRects(textLayer, '.BRparagraphElement');
228
+ const paragraphRects = determineRealRects(textLayer, '.BRparagraphElement', this._measurementDocument);
208
229
  let yAdded = 0;
209
230
  for (const [ocrParagraph, paragEl] of zip(ocrParagraphs, paragEls)) {
210
231
  const ocrParagBounds = $(ocrParagraph).attr("coords").split(",").map(parseFloat);
@@ -312,7 +333,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
312
333
  paragEl.style.fontSize = `${paragWordHeight}px`;
313
334
 
314
335
  // Fix up sizes - stretch/crush words as necessary using letter spacing
315
- let wordRects = determineRealRects(paragEl, '.BRwordElement');
336
+ let wordRects = determineRealRects(paragEl, '.BRwordElement', this._measurementDocument);
316
337
  const ocrWords = $(ocrParagraph).find("WORD").toArray();
317
338
  const wordEls = paragEl.querySelectorAll('.BRwordElement');
318
339
  for (const [ocrWord, wordEl] of zip(ocrWords, wordEls)) {
@@ -333,8 +354,8 @@ export class TextSelectionPlugin extends BookReaderPlugin {
333
354
 
334
355
  // Stretch/crush lines as necessary using line spacing
335
356
  // Recompute rects after letter spacing
336
- wordRects = determineRealRects(paragEl, '.BRwordElement');
337
- const spaceRects = determineRealRects(paragEl, '.BRspace');
357
+ wordRects = determineRealRects(paragEl, '.BRwordElement', this._measurementDocument);
358
+ const spaceRects = determineRealRects(paragEl, '.BRspace', this._measurementDocument);
338
359
 
339
360
  const ocrLines = $(ocrParagraph).find("LINE[coords]").toArray();
340
361
  const lineEls = Array.from(paragEl.querySelectorAll('.BRlineElement'));
@@ -384,9 +405,11 @@ BookReader?.registerPlugin('textSelection', TextSelectionPlugin);
384
405
  /**
385
406
  * @param {HTMLElement} parentEl
386
407
  * @param {string} selector
408
+ * @param {Document} measurementDocument Isolated document to measure within
409
+ * (see TextSelectionPlugin#_measurementDocument for why).
387
410
  * @returns {Map<Element, Rect>}
388
411
  */
389
- function determineRealRects(parentEl, selector) {
412
+ function determineRealRects(parentEl, selector, measurementDocument) {
390
413
  const initals = {
391
414
  position: parentEl.style.position,
392
415
  visibility: parentEl.style.visibility,
@@ -399,21 +422,23 @@ function determineRealRects(parentEl, selector) {
399
422
  parentEl.style.top = '0';
400
423
  parentEl.style.left = '0';
401
424
  parentEl.style.transform = 'none';
402
- document.body.appendChild(parentEl);
425
+ measurementDocument.body.appendChild(parentEl);
403
426
  const rects = new Map(
404
427
  Array.from(parentEl.querySelectorAll(selector))
405
428
  .map(wordEl => {
406
429
  const origRect = wordEl.getBoundingClientRect();
407
430
  return [wordEl, new Rect(
408
- origRect.left + window.scrollX,
409
- origRect.top + window.scrollY,
431
+ origRect.left + measurementDocument.defaultView.scrollX,
432
+ origRect.top + measurementDocument.defaultView.scrollY,
410
433
  origRect.width,
411
434
  origRect.height,
412
435
  )];
413
436
  }),
414
437
  );
415
- document.body.removeChild(parentEl);
438
+ measurementDocument.body.removeChild(parentEl);
416
439
  Object.assign(parentEl.style, initals);
440
+ // Need to restore the document to the main window document
441
+ document.adoptNode(parentEl);
417
442
  return rects;
418
443
  }
419
444
 
@@ -383,14 +383,8 @@ export class SearchPlugin extends BookReaderPlugin {
383
383
  const $boxes = await poll(() => $(`rect.match-index-${match.matchIndex}`), { until: result => result.length > 0 });
384
384
  if ($boxes.length) {
385
385
  $boxes.css('animation', 'none');
386
- $boxes[0].scrollIntoView({
387
- // Only vertically center the highlight if we're in 1up or in full screen. In
388
- // 2up, if we're not fullscreen, the whole body gets scrolled around to try to
389
- // center the highlight 🙄 See:
390
- // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move/11041376
391
- // Note: nearest doesn't quite work great, because the ReadAloud toolbar is now
392
- // full-width, and covers up the last line of the highlight.
393
- block: this.br.constMode1up == this.br.mode || this.br.isFullscreenActive ? 'center' : 'nearest',
386
+ this.br.scrollIntoView($boxes[0], {
387
+ block: 'center',
394
388
  inline: 'center',
395
389
  behavior: onNearbyPage ? 'smooth' : 'auto',
396
390
  });
@@ -350,14 +350,9 @@ export class TtsPlugin extends BookReaderPlugin {
350
350
  // It behaves weird if used in thumb mode
351
351
  if (this.br.constModeThumb == this.br.mode) return;
352
352
 
353
- $(`.pagediv${chunk.leafIndex} .ttsHiliteLayer rect`).last()?.[0]?.scrollIntoView({
354
- // Only vertically center the highlight if we're in 1up or in full screen. In
355
- // 2up, if we're not fullscreen, the whole body gets scrolled around to try to
356
- // center the highlight 🙄 See:
357
- // https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move/11041376
358
- // Note: nearest doesn't quite work great, because the ReadAloud toolbar is now
359
- // full-width, and covers up the last line of the highlight.
360
- block: this.br.constMode1up == this.br.mode || this.br.isFullscreenActive ? 'center' : 'nearest',
353
+ const highlightRect = $(`.pagediv${chunk.leafIndex} .ttsHiliteLayer rect`).last()?.[0];
354
+ this.br.scrollIntoView(highlightRect, {
355
+ block: 'center',
361
356
  inline: 'center',
362
357
  behavior: 'smooth',
363
358
  });
@@ -7,6 +7,7 @@ import { customElement, property, query } from 'lit/decorators.js';
7
7
  import { ifDefined } from 'lit/directives/if-defined.js';
8
8
  import '@internetarchive/icon-share';
9
9
  import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
10
+ import '@internetarchive/icon-ellipses';
10
11
  import { isIOS, isAndroid } from './browserSniffing.js';
11
12
  import { genAt, genFilter } from './generators.js';
12
13
 
@@ -412,6 +413,9 @@ export class BRSelectMenuOption extends LitElement {
412
413
  if (this.icon === 'edit-pencil') {
413
414
  return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
414
415
  }
416
+ if (this.icon === 'ellipses') {
417
+ return html`<ia-icon-ellipses class="br-select-menu__icon" aria-hidden="true"></ia-icon-ellipses>`;
418
+ }
415
419
  return '';
416
420
  }
417
421
 
@@ -433,7 +437,9 @@ export class BRSelectMenuOption extends LitElement {
433
437
  aria-label=${ifDefined(accessibleLabel)}
434
438
  >
435
439
  ${this.renderIcon()}
436
- ${hasTemporaryText ? html`
440
+ ${
441
+ !hasTemporaryText && !baseLabel ? '' :
442
+ hasTemporaryText ? html`
437
443
  <span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
438
444
  <span
439
445
  class="br-select-menu__label"
@@ -459,8 +465,12 @@ class BRSelectMenu extends LitElement {
459
465
  /** @type {import('../BookReader.js').default} */
460
466
  br;
461
467
 
468
+ /** @type {boolean} */
469
+ @property({type: Boolean, reflect: true})
470
+ showExtended = false;
471
+
462
472
  /** @type {BRSelectMenuOption | null} */
463
- @query('#copy-link-option')
473
+ @query('#br-select-copy-link-option')
464
474
  copyLinkOption;
465
475
 
466
476
  @property({type: Boolean, reflect: true})
@@ -521,7 +531,7 @@ class BRSelectMenu extends LitElement {
521
531
  // Mousedown needed to prevent selection from being cleared on iOS
522
532
  return html`
523
533
  <br-menu-option
524
- id="copy-link-option"
534
+ id="br-select-copy-link-option"
525
535
  @mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
526
536
  @click=${this.handleCopyLinkToHighlight}
527
537
  icon="share"
@@ -560,6 +570,19 @@ class BRSelectMenu extends LitElement {
560
570
  ></br-menu-option>
561
571
  `;
562
572
  }
573
+
574
+ renderShowMoreOption() {
575
+ return html`
576
+ <br-menu-option
577
+ id="br-select-more"
578
+ @mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
579
+ @click=${this.toggleExtendedMenu}
580
+ icon="ellipses"
581
+ aria-label="Show more options"
582
+ ></br-menu-option>
583
+ `;
584
+ }
585
+
563
586
  renderLocalStorageOptions() {
564
587
  return html`
565
588
  <br-menu-option
@@ -574,16 +597,30 @@ class BRSelectMenu extends LitElement {
574
597
  ></br-menu-option>`;
575
598
  }
576
599
 
577
- render() {
578
- // TODO change the second button to use a different icon
600
+ renderDefaultOptions() {
579
601
  return html`
580
602
  ${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
581
603
  ${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
582
- ${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
583
604
  ${this.nodesForRemoval ? this.renderRemoveOption() : ''}
584
605
  `;
585
606
  }
586
607
 
608
+ renderExtendedOptions() {
609
+ return html`
610
+ ${this.renderDefaultOptions()}
611
+ ${this.renderLocalStorageOptions()}
612
+ `;
613
+ }
614
+
615
+ render() {
616
+ const hasMoreOptions = this.br.plugins.experiments?.isEnabled('annotateHighlight');
617
+ // TODO change the second button to use a different icon
618
+ return html`
619
+ ${this.showExtended ? this.renderExtendedOptions() : this.renderDefaultOptions()}
620
+ ${!this.showExtended && hasMoreOptions ? this.renderShowMoreOption() : ""}
621
+ `;
622
+ }
623
+
587
624
  /**
588
625
  * @param {MouseEvent} e
589
626
  */
@@ -615,6 +652,14 @@ class BRSelectMenu extends LitElement {
615
652
  this.copyLinkOption?.showTemporaryText('Copied!');
616
653
  }
617
654
 
655
+ /**
656
+ * @param {MouseEvent} e
657
+ */
658
+ toggleExtendedMenu(e) {
659
+ e.preventDefault();
660
+ this.showExtended = !this.showExtended;
661
+ }
662
+
618
663
  /**
619
664
  * Returns the closest BRtextLayer element on the page that contains the target node
620
665
  * @param {Node} node
@@ -761,12 +806,12 @@ class BRSelectMenu extends LitElement {
761
806
  this.style.left = `${left}px`;
762
807
  }
763
808
 
809
+ // Will always show the simplified menu when rendered after hiding
764
810
  async show() {
765
811
  if (this.br.plugins.translate?.userToggleTranslate) return;
766
812
 
767
813
  this.style.zIndex = '1';
768
814
  this.style.position = 'absolute';
769
- this.style.display = 'block';
770
815
  this.open = true;
771
816
  this.classList.remove('br-select-menu__root--scrolling');
772
817
  window.removeEventListener('scroll', this._onScroll, { capture: true });
@@ -778,7 +823,7 @@ class BRSelectMenu extends LitElement {
778
823
 
779
824
  hide() {
780
825
  if (!this.open) return;
781
- this.style.display = 'none';
826
+ this.showExtended = false;
782
827
  this.open = false;
783
828
  window.removeEventListener('scroll', this._onScroll, { capture: true });
784
829
  this.clearNodesForRemoval();
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ import { clamp } from '../BookReader/utils.js';
3
+
4
+ /**
5
+ * Walks up the DOM tree starting at (but not including) the given element,
6
+ * returning the nearest ancestor that can actually scroll its overflow.
7
+ * @param {Element} el
8
+ * @return {Element | null}
9
+ */
10
+ export function findScrollableAncestor(el) {
11
+ let current = el.parentElement;
12
+ while (current) {
13
+ const style = getComputedStyle(current);
14
+ const canScrollY = (style.overflowY === 'auto' || style.overflowY === 'scroll') && current.scrollHeight > current.clientHeight;
15
+ const canScrollX = (style.overflowX === 'auto' || style.overflowX === 'scroll') && current.scrollWidth > current.clientWidth;
16
+ if (canScrollY || canScrollX) return current;
17
+ current = current.parentElement;
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /**
23
+ * Computes the scroll offset for a single axis needed to align an element
24
+ * within a container, per the same semantics as scrollIntoView's block/inline.
25
+ * @param {number} elStart
26
+ * @param {number} elEnd
27
+ * @param {number} containerStart
28
+ * @param {number} containerEnd
29
+ * @param {number} currentScroll
30
+ * @param {'start' | 'center' | 'end' | 'nearest'} align
31
+ * @return {number}
32
+ */
33
+ function computeAxisScroll(elStart, elEnd, containerStart, containerEnd, currentScroll, align) {
34
+ let delta = 0;
35
+ switch (align) {
36
+ case 'start':
37
+ delta = elStart - containerStart;
38
+ break;
39
+ case 'end':
40
+ delta = elEnd - containerEnd;
41
+ break;
42
+ case 'center':
43
+ delta = (elStart + elEnd) / 2 - (containerStart + containerEnd) / 2;
44
+ break;
45
+ case 'nearest':
46
+ default:
47
+ if (elStart < containerStart) delta = elStart - containerStart;
48
+ else if (elEnd > containerEnd) delta = elEnd - containerEnd;
49
+ break;
50
+ }
51
+ return currentScroll + delta;
52
+ }
53
+
54
+ /**
55
+ * Scrolls the given element into view within a single scroll container.
56
+ *
57
+ * Unlike the native `Element.scrollIntoView`, this never walks up and scrolls
58
+ * every scrollable ancestor in turn (which can otherwise unexpectedly move
59
+ * the whole page) — it only ever adjusts the scroll position of one
60
+ * container: either the one explicitly passed in, or the nearest scrollable
61
+ * ancestor found via {@link findScrollableAncestor}.
62
+ * @param {Element} el
63
+ * @param {object} [options]
64
+ * @param {'auto' | 'smooth'} [options.behavior]
65
+ * @param {'start' | 'center' | 'end' | 'nearest'} [options.block]
66
+ * @param {'start' | 'center' | 'end' | 'nearest'} [options.inline]
67
+ * @param {Element} [options.scrollContainer] The scrollable element whose
68
+ * scroll position should be adjusted. Defaults to `findScrollableAncestor(el)`.
69
+ */
70
+ export function singleScrollIntoView(el, options) {
71
+ const { block = 'start', inline = 'nearest', behavior = 'auto', scrollContainer = findScrollableAncestor(el) } = options || {};
72
+ if (!scrollContainer) {
73
+ el.scrollIntoView({ block, inline, behavior });
74
+ return;
75
+ }
76
+
77
+ const containerRect = scrollContainer.getBoundingClientRect();
78
+ const elRect = el.getBoundingClientRect();
79
+
80
+ const top = computeAxisScroll(elRect.top, elRect.bottom, containerRect.top, containerRect.bottom, scrollContainer.scrollTop, block);
81
+ const left = computeAxisScroll(elRect.left, elRect.right, containerRect.left, containerRect.right, scrollContainer.scrollLeft, inline);
82
+
83
+ scrollContainer.scrollTo({
84
+ top: clamp(top, 0, scrollContainer.scrollHeight - scrollContainer.clientHeight),
85
+ left: clamp(left, 0, scrollContainer.scrollWidth - scrollContainer.clientWidth),
86
+ behavior,
87
+ });
88
+ }