@internetarchive/bookreader 5.0.0-113 → 5.0.0-115

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 (42) hide show
  1. package/BookReader/474.js.map +1 -1
  2. package/BookReader/BookReader.css +34 -10
  3. package/BookReader/BookReader.js +1 -1
  4. package/BookReader/BookReader.js.map +1 -1
  5. package/BookReader/ia-bookreader-bundle.js +20 -38
  6. package/BookReader/ia-bookreader-bundle.js.map +1 -1
  7. package/BookReader/plugins/plugin.archive_analytics.js.map +1 -1
  8. package/BookReader/plugins/plugin.autoplay.js.map +1 -1
  9. package/BookReader/plugins/plugin.chapters.js +1 -1
  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.map +1 -1
  16. package/BookReader/plugins/plugin.resume.js.map +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 +1 -1
  20. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  21. package/BookReader/plugins/plugin.translate.js +1 -1
  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.map +1 -1
  26. package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
  27. package/BookReader/plugins/translator-worker.js.map +1 -1
  28. package/BookReader/webcomponents-bundle.js.map +1 -1
  29. package/package.json +37 -31
  30. package/src/BookReader/utils/SelectionObserver.js +4 -2
  31. package/src/BookReader.js +2 -1
  32. package/src/css/_TextSelection.scss +32 -11
  33. package/src/ia-bookreader/ia-bookreader.js +13 -2
  34. package/src/plugins/plugin.experiments.js +13 -3
  35. package/src/plugins/plugin.iiif.js +7 -1
  36. package/src/plugins/plugin.text_selection.js +8 -1
  37. package/src/plugins/tts/WebTTSEngine.js +2 -2
  38. package/src/plugins/tts/plugin.tts.js +3 -3
  39. package/src/plugins/tts/utils.js +0 -19
  40. package/src/util/TextSelectionManager.js +276 -110
  41. package/src/util/browserSniffing.js +9 -0
  42. package/src/util/strings.js +10 -0
@@ -1,12 +1,17 @@
1
1
  // @ts-check
2
+ import { countWords } from './strings.js';
2
3
  import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
4
+ import { clamp } from "../BookReader/utils.js";
3
5
  import { html, LitElement } from 'lit';
4
6
  import { customElement, property, query } from 'lit/decorators.js';
5
7
  import { ifDefined } from 'lit/directives/if-defined.js';
6
8
  import '@internetarchive/icon-share';
7
9
  import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
10
+ import { isIOS, isAndroid } from './browserSniffing.js';
8
11
 
9
12
  const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
13
+ const MAX_FULL_QUOTE_URL_CHARS = 80;
14
+ const TRUNCATED_QUOTE_WORD_COUNT = 3;
10
15
 
11
16
  export class TextSelectionManager {
12
17
  options = {
@@ -52,15 +57,12 @@ export class TextSelectionManager {
52
57
  // Set a class on the page to avoid hiding it when zooming/etc
53
58
  this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
54
59
  $(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
55
- this.showSelectMenu();
56
-
60
+ // Don't show while still doing selection
61
+ if (!this.mouseIsDown) this.showSelectMenu();
57
62
  }
58
63
 
59
64
  if (selectEvent == 'changed') {
60
- // hide the button as user changes their selection
61
- if (this.mouseIsDown) {
62
- this.hideSelectMenu();
63
- } else if (window.getSelection()?.toString()) {
65
+ if (!this.mouseIsDown && window.getSelection()?.toString()) {
64
66
  this.showSelectMenu();
65
67
  }
66
68
  }
@@ -162,7 +164,6 @@ export class TextSelectionManager {
162
164
  // blocking selection
163
165
  $(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
164
166
  this.mouseIsDown = true;
165
- this.hideSelectMenu();
166
167
  if ($(event.target).is(this.selectionElement.join(", "))) {
167
168
  event.stopPropagation();
168
169
  }
@@ -170,7 +171,6 @@ export class TextSelectionManager {
170
171
 
171
172
  $(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
172
173
  this.mouseIsDown = false;
173
- this.hideSelectMenu();
174
174
  textLayer.style.pointerEvents = "none";
175
175
  if (skipNextMouseup) {
176
176
  skipNextMouseup = false;
@@ -196,7 +196,6 @@ export class TextSelectionManager {
196
196
  if (event.which != 1) return;
197
197
  this.mouseIsDown = true;
198
198
  event.stopPropagation();
199
- this.hideSelectMenu();
200
199
  });
201
200
 
202
201
  // Prevent page flip on click
@@ -218,7 +217,11 @@ export class TextSelectionManager {
218
217
  document.body.append(this.selectMenu);
219
218
  }
220
219
 
221
- this.selectMenu.show();
220
+ if (this.selectMenu.open) {
221
+ this.selectMenu.repositionToSelection();
222
+ } else {
223
+ this.selectMenu.show();
224
+ }
222
225
  }
223
226
 
224
227
  hideSelectMenu() {
@@ -456,6 +459,9 @@ class BRSelectMenu extends LitElement {
456
459
  @query('#copy-link-option')
457
460
  copyLinkOption;
458
461
 
462
+ @property({type: Boolean, reflect: true})
463
+ open = false;
464
+
459
465
  @property({type: Boolean})
460
466
  copyLinkToHighlightEnabled = true;
461
467
 
@@ -476,6 +482,19 @@ class BRSelectMenu extends LitElement {
476
482
  return this;
477
483
  }
478
484
 
485
+ /** @type {number | null} */
486
+ _scrollTimeoutId = null;
487
+
488
+ _onScroll = () => {
489
+ this.classList.add('br-select-menu__root--scrolling');
490
+ if (this._scrollTimeoutId != null) clearTimeout(this._scrollTimeoutId);
491
+ this._scrollTimeoutId = window.setTimeout(() => {
492
+ this._scrollTimeoutId = null;
493
+ this.repositionToSelection();
494
+ this.classList.remove('br-select-menu__root--scrolling');
495
+ }, 150);
496
+ };
497
+
479
498
  /** @override */
480
499
  connectedCallback() {
481
500
  super.connectedCallback();
@@ -484,10 +503,22 @@ class BRSelectMenu extends LitElement {
484
503
  this.setAttribute('aria-orientation', 'vertical');
485
504
  }
486
505
 
506
+ /** @override */
507
+ disconnectedCallback() {
508
+ super.disconnectedCallback();
509
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
510
+ if (this._scrollTimeoutId != null) {
511
+ clearTimeout(this._scrollTimeoutId);
512
+ this._scrollTimeoutId = null;
513
+ }
514
+ }
515
+
487
516
  renderCopyLinkToHighlightOption() {
517
+ // Mousedown needed to prevent selection from being cleared on iOS
488
518
  return html`
489
519
  <br-menu-option
490
520
  id="copy-link-option"
521
+ @mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
491
522
  @click=${this.handleCopyLinkToHighlight}
492
523
  icon="share"
493
524
  label="Copy Link to Highlight"
@@ -555,8 +586,14 @@ class BRSelectMenu extends LitElement {
555
586
  async handleCopyLinkToHighlight(e) {
556
587
  e.preventDefault();
557
588
  const currentParams = this.br.readQueryString();
558
- const currentSelection = window.getSelection();
559
- const textFragment = BookReaderTextFragment.fromSelection(currentSelection, this.br.$('.BRpage-visible').toArray());
589
+ const currentSelection = /** @type {Selection} */ (window.getSelection());
590
+ const range = currentSelection.getRangeAt(0);
591
+ const textLayer = this.getNodeTextLayer(range.startContainer);
592
+ if (!textLayer) {
593
+ console.warn("No text layer found for selection");
594
+ return;
595
+ }
596
+ const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
560
597
 
561
598
  // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
562
599
  let linkToHighlightParams = currentParams;
@@ -579,11 +616,11 @@ class BRSelectMenu extends LitElement {
579
616
  /**
580
617
  * Returns the closest BRtextLayer element on the page that contains the target node
581
618
  * @param {Node} node
582
- * @returns {HTMLElement | null}
619
+ * @returns {Element | null}
583
620
  */
584
621
  getNodeTextLayer(node) {
585
- if (!node) return;
586
- const element = 'closest' in node ? node : node.parentElement;
622
+ if (!node) return null;
623
+ const element = node instanceof Element ? node : node.parentElement;
587
624
  return element?.closest('.BRtextLayer') ?? null;
588
625
  }
589
626
 
@@ -694,31 +731,54 @@ class BRSelectMenu extends LitElement {
694
731
  this.show();
695
732
  }
696
733
 
697
- show() {
734
+ repositionToSelection() {
735
+ const SCREEN_MARGIN = 10;
736
+ const currentSelection = /** @type {Selection} */ (window.getSelection());
737
+ const range = currentSelection.getRangeAt(0);
738
+ const selectionRect = range.getBoundingClientRect();
739
+
740
+ const isMobile = isIOS() || isAndroid();
741
+ // Leave room for mobile teardrop selection handles
742
+ const SELECTION_PADDING = isAndroid() ? 25 : isIOS() ? 15 : 10;
743
+ // On mobile, avoid the native OS text-selection bar which appears near the top of the screen
744
+ const OS_BAR_H = isMobile ? 60 : 0;
745
+ const idealTop = (selectionRect.top < OS_BAR_H
746
+ ? selectionRect.bottom + OS_BAR_H
747
+ : selectionRect.bottom) + SELECTION_PADDING;
748
+ const top = Math.max(SCREEN_MARGIN, idealTop) + window.scrollY;
749
+
750
+ const menuWidth = this.getBoundingClientRect().width;
751
+ // Excludes scrollbars
752
+ const windowWidth = document.documentElement.clientWidth;
753
+ const idealLeft = menuWidth > selectionRect.width
754
+ ? selectionRect.left + selectionRect.width / 2 - menuWidth / 2
755
+ : selectionRect.left;
756
+ const left = clamp(idealLeft, SCREEN_MARGIN, windowWidth - menuWidth - SCREEN_MARGIN) + window.scrollX;
757
+
758
+ this.style.top = `${top}px`;
759
+ this.style.left = `${left}px`;
760
+ }
761
+
762
+ async show() {
698
763
  if (this.br.plugins.translate?.userToggleTranslate) return;
699
- const currentSelection = window.getSelection();
700
- const start = currentSelection.anchorNode.parentElement;
701
- const end = currentSelection.focusNode.parentElement; // will always be a text node
702
- const height = 30;
703
- const width = 60;
704
- const startBoundingRect = start.getBoundingClientRect();
705
- const endBoundingRect = end.getBoundingClientRect();
706
- let hlButtonTop = startBoundingRect.top - height;
707
- let hlButtonLeft = startBoundingRect.left - width;
708
- if (currentSelection.direction == 'backward') {
709
- hlButtonTop = endBoundingRect.top - height;
710
- hlButtonLeft = endBoundingRect.left - width;
711
- }
712
- this.style.top = `${hlButtonTop}px`;
713
- this.style.left = `${hlButtonLeft}px`;
764
+
714
765
  this.style.zIndex = '1';
715
766
  this.style.position = 'absolute';
716
767
  this.style.display = 'block';
768
+ this.open = true;
769
+ this.classList.remove('br-select-menu__root--scrolling');
770
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
771
+ window.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
772
+ await this.updateComplete; // ensure Lit has rendered so getBoundingClientRect() returns real width
773
+ this.repositionToSelection();
717
774
  this.clearNodesForRemoval();
718
775
  }
719
776
 
720
- hide = () => {
777
+ hide() {
778
+ if (!this.open) return;
721
779
  this.style.display = 'none';
780
+ this.open = false;
781
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
722
782
  this.clearNodesForRemoval();
723
783
  return;
724
784
  }
@@ -788,53 +848,62 @@ function replaceWhitespace(string) {
788
848
  }
789
849
 
790
850
  /**
791
- * Checks if quote matches the text content and existing range, then identifies the start and end nodes that contain the quote string.
792
- * @param {String} quote - The text to find
851
+ * Finds all regex matches within a range and returns their ranges.
852
+ * @param {RegExp} regex
793
853
  * @param {Range} range - the range to search in
794
854
  * @param {Node[]} textNodes - visible text nodes within the range
795
- * @returns {Range | undefined} Range that encompasses the quote, or undefined if no match is found
796
- *
797
- * Lightly adapted from
798
- * https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L765
855
+ * @param {{ normalize?: function(string): string }} [options]
856
+ * @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
799
857
  */
800
- export function findRangeForQuote(quote, range, textNodes) {
858
+ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
859
+ if (!textNodes.length) return undefined;
860
+
801
861
  const startOffset = textNodes[0] === range.startContainer ?
802
862
  range.startOffset :
803
863
  0;
804
- const normalizedWholePageString = replaceWhitespace(range.toString());
805
- const normalizedQuote = replaceWhitespace(quote);
806
- let searchStart = 0;
807
- let start;
808
- let end;
809
- while (searchStart < normalizedWholePageString.length) {
810
- const matchedIndex = normalizedWholePageString.indexOf(normalizedQuote, searchStart);
811
- if (matchedIndex === -1) return undefined;
812
- const normalizedStartOffset = replaceWhitespace(textNodes[0].textContent.slice(0, startOffset)).length;
813
- start = getBoundaryPointAtIndex(
814
- normalizedStartOffset + matchedIndex,
815
- textNodes, false);
816
- end = getBoundaryPointAtIndex(
817
- normalizedStartOffset + matchedIndex + normalizedQuote.length,
818
- textNodes, true);
819
- if (start != null && end != null) {
820
- const foundRange = new Range();
821
- foundRange.setStart(start.node, 0);
822
- foundRange.setEnd(end.node, 1);
823
- return foundRange;
864
+ const normalizedWholePageString = normalize(range.toString());
865
+ const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
866
+ const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
867
+
868
+ const matches = Array.from(normalizedWholePageString.matchAll(matchRegex));
869
+ const resultRanges = matches.map((match) => {
870
+ const start = getBoundaryPointAtIndex(
871
+ normalizedStartOffset + match.index,
872
+ textNodes,
873
+ false,
874
+ { normalize },
875
+ );
876
+ const end = getBoundaryPointAtIndex(
877
+ normalizedStartOffset + match.index + match[0].length,
878
+ textNodes,
879
+ true,
880
+ { normalize },
881
+ );
882
+
883
+ if (!start || !end) {
884
+ console.warn("Could not find boundary points for regex match, skipping this match", {match, start, end});
885
+ return undefined;
824
886
  }
825
- searchStart = matchedIndex + 1;
826
- }
827
- return undefined;
828
- }
829
887
 
888
+ const foundRange = new Range();
889
+ foundRange.setStart(start.node, 0);
890
+ foundRange.setEnd(end.node, 1);
891
+ return foundRange;
892
+ });
893
+
894
+ const definedRanges = resultRanges.filter((matchRange) => !!matchRange);
895
+ if (!definedRanges?.length) return undefined;
896
+ return definedRanges;
897
+ }
830
898
 
831
899
  /**
832
900
  * Uses the index that matches the quote string and normalizes the string contents to find the correct node
833
901
  * @param {Number} index
834
902
  * @param {Node[]} nodes
835
903
  * @param {boolean} isEnd
904
+ * @param {{ normalize?: function(string): string }} [options]
836
905
  */
837
- export function getBoundaryPointAtIndex(index, nodes, isEnd) {
906
+ export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
838
907
  let counted = 0;
839
908
  let normalizedData;
840
909
  for (let i = 0; i < nodes.length; i++) {
@@ -843,7 +912,7 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
843
912
  // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
844
913
  normalizedData = ' ';
845
914
  } else {
846
- if (!normalizedData) normalizedData = replaceWhitespace(node.textContent);
915
+ if (!normalizedData) normalizedData = normalize(node.textContent);
847
916
  }
848
917
  let nodeEnd = counted + normalizedData.length;
849
918
  if (isEnd) nodeEnd += 1;
@@ -856,8 +925,8 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
856
925
  normalizedData.substring(normalizedOffset);
857
926
 
858
927
  let candidateSubstring = isEnd ?
859
- replaceWhitespace(node.textContent.substring(0, normalizedOffset)) :
860
- replaceWhitespace(node.textContent.substring(normalizedOffset));
928
+ normalize(node.textContent.substring(0, normalizedOffset)) :
929
+ normalize(node.textContent.substring(normalizedOffset));
861
930
 
862
931
  const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
863
932
  while (denormalizedOffset >= 0 &&
@@ -899,52 +968,54 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
899
968
  * https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
900
969
  *
901
970
  * @param {HTMLElement} textLayer
902
- * @param {BookReaderTextFragment} quote
971
+ * @param {BookReaderTextFragment} textFragment
903
972
  * @param {string | null} cssClassName optional css class to add to the highlight span element
973
+ * @return {Element[]} the elements that were created to highlight the text
904
974
  */
905
- export function renderHighlight(textLayer, quote, cssClassName = null) {
975
+ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
906
976
  // Create a range that encompasses the entire text content
907
977
  const firstPageNode = getFirstMostNode(textLayer);
908
978
  const lastPageNode = getLastMostNode(textLayer);
909
979
  const wholePageRange = new Range();
910
980
  wholePageRange.setStart(firstPageNode, 0);
911
981
  wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
912
-
913
- // Convert the whole page range into a normalized string, get the index of where the stored string matches the quote
914
- const convertedString = replaceWhitespace(wholePageRange.toString());
915
- const convertedQuote = replaceWhitespace(quote.quote);
916
- const foundStringIndex = convertedString.indexOf(convertedQuote);
917
- if (foundStringIndex == -1) {
918
- console.warn("Could not find quote in page:", quote.quote);
919
- return;
920
- }
921
- const fullContext = [quote.prefix, quote.quote, quote.suffix].join(" ");
922
- const convertedFullContext = replaceWhitespace(fullContext);
982
+ const normalize = replaceWhitespace;
923
983
 
924
984
  // Retrieve the text nodes and relevant whitespace elements
925
985
  // Need to keep the BRlineElement nodes in between to keep the index count consistent, remove first BRlineElement since text starts from the first real text node
926
986
  const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
927
987
  pageWordNodes.splice(0, 1);
928
- const broadRange = findRangeForQuote(convertedFullContext, wholePageRange, pageWordNodes);
929
- if (!broadRange) {
930
- console.warn("Could not find quote with context in page, falling back to finding quote without context:", quote.quote);
988
+
989
+ const broadRanges = findRangeForRegExp(
990
+ textFragment.toRegExp({ normalize, context: true }),
991
+ wholePageRange,
992
+ pageWordNodes,
993
+ { normalize },
994
+ );
995
+ if (!broadRanges) {
996
+ console.warn("Could not find quote with context in page");
931
997
  return;
932
998
  }
933
999
 
934
1000
  const broadRangeWordNodes = [];
935
- for (const el of walkBetweenNodes(broadRange?.startContainer, broadRange?.endContainer)) {
1001
+ for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
936
1002
  if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
937
1003
  broadRangeWordNodes.push(el);
938
1004
  }
939
1005
  }
940
1006
 
941
1007
  // At which point the quote should now be unambiguous!
942
- const exactRange = findRangeForQuote(quote.quote, broadRange, broadRangeWordNodes);
943
- if (!exactRange) {
1008
+ const exactRanges = findRangeForRegExp(
1009
+ textFragment.toRegExp({ normalize, context: false }),
1010
+ broadRanges[0],
1011
+ broadRangeWordNodes,
1012
+ { normalize },
1013
+ );
1014
+ if (!exactRanges) {
944
1015
  throw new Error("Could not find quote in page");
945
1016
  }
946
- const startTextNode = getFirstMostNode(exactRange.startContainer);
947
- const endTextNode = getFirstMostNode(exactRange.endContainer);
1017
+ const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
1018
+ const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
948
1019
 
949
1020
  // markRange requires the range to start and end within text nodes
950
1021
  const exactRangeTextNodes = new Range();
@@ -957,11 +1028,11 @@ export function renderHighlight(textLayer, quote, cssClassName = null) {
957
1028
  return;
958
1029
  }
959
1030
 
960
- markRange(exactRangeTextNodes, () => {
1031
+ return markRange(exactRangeTextNodes, () => {
961
1032
  const mark = document.createElement("mark");
962
1033
  mark.classList.add("BRhighlight");
963
1034
  if (cssClassName) mark.classList.add(cssClassName);
964
- if (quote.uuid) mark.classList.add(quote.uuid);
1035
+ if (textFragment.uuid) mark.classList.add(textFragment.uuid);
965
1036
  return mark;
966
1037
  });
967
1038
  }
@@ -1062,24 +1133,31 @@ function retrieveUUID(ele) {
1062
1133
  * See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
1063
1134
  *
1064
1135
  * Text fragment string format: `pageNumber:prefix-,quote,-suffix`
1136
+ * Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
1065
1137
  * Note the ':' and ',' separators must not be encoded, but
1066
- * the pageNumber, prefix, quote, and suffix text can be encoded.
1138
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
1067
1139
  */
1068
1140
  export class BookReaderTextFragment {
1069
1141
  /**
1070
1142
  * @param {object} params
1071
1143
  * @param {string | null}params.prefix
1072
- * @param {string} params.quote
1144
+ * @param {string | null} [params.quote]
1145
+ * @param {string | null} [params.quoteStart]
1146
+ * @param {string | null} [params.quoteEnd]
1073
1147
  * @param {string | null} params.suffix
1074
1148
  * @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
1075
1149
  * @param {number} params.pageIndex Page index; e.g. zero-based index of the page
1076
1150
  * @param {string | null} [params.uuid] UUID for the text fragment if it has one
1077
1151
  */
1078
- constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
1152
+ constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
1079
1153
  /** @type {string|null} */
1080
1154
  this.prefix = prefix;
1081
- /** @type {string} */
1082
- this.quote = quote;
1155
+ /** @type {string | null} */
1156
+ this.quote = quote ?? null;
1157
+ /** @type {string | null} */
1158
+ this.quoteStart = quoteStart ?? null;
1159
+ /** @type {string | null} */
1160
+ this.quoteEnd = quoteEnd ?? null;
1083
1161
  /** @type {string|null} */
1084
1162
  this.suffix = suffix;
1085
1163
  /** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
@@ -1108,14 +1186,40 @@ export class BookReaderTextFragment {
1108
1186
  }
1109
1187
  const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
1110
1188
  const prefix = match[2] ? decodeURIComponent(match[2]) : null;
1111
- const quote = decodeURIComponent(match[3]);
1189
+ const quoteRegion = match[3] || '';
1190
+ const quoteParts = quoteRegion.split(',');
1191
+ let quote = null;
1192
+ let quoteStart = null;
1193
+ let quoteEnd = null;
1194
+
1195
+ if (quoteParts.length === 1) {
1196
+ quote = decodeURIComponent(quoteParts[0]);
1197
+ } else if (quoteParts.length === 2) {
1198
+ quoteStart = decodeURIComponent(quoteParts[0]);
1199
+ quoteEnd = decodeURIComponent(quoteParts[1]);
1200
+ } else {
1201
+ throw new Error(`Invalid text fragment quote format: ${str}`);
1202
+ }
1203
+
1112
1204
  const suffix = match[4] ? decodeURIComponent(match[4]) : null;
1113
1205
  const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
1114
1206
  if (typeof pageIndex !== 'number') {
1115
1207
  throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
1116
1208
  }
1117
1209
 
1118
- return new BookReaderTextFragment({ prefix, quote, suffix, pageNumber, pageIndex });
1210
+ if (!quote && (!quoteStart || !quoteEnd)) {
1211
+ throw new Error(`Invalid text fragment quote format: ${str}`);
1212
+ }
1213
+
1214
+ return new BookReaderTextFragment({
1215
+ prefix,
1216
+ quote,
1217
+ quoteStart,
1218
+ quoteEnd,
1219
+ suffix,
1220
+ pageNumber,
1221
+ pageIndex,
1222
+ });
1119
1223
  }
1120
1224
 
1121
1225
  /**
@@ -1137,8 +1241,9 @@ export class BookReaderTextFragment {
1137
1241
  /**
1138
1242
  * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1139
1243
  * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1244
+ * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1140
1245
  * Note the ':' and ',' separators must not and are not encoded, but
1141
- * the pageNumber, prefix, quote, and suffix text are encoded.
1246
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1142
1247
  * @returns {string}
1143
1248
  */
1144
1249
  toUrlString() {
@@ -1148,17 +1253,66 @@ export class BookReaderTextFragment {
1148
1253
  if (this.prefix) {
1149
1254
  str += `${encodeURIComponent(this.prefix)}-,`;
1150
1255
  }
1151
- str += encodeURIComponent(this.quote);
1256
+
1257
+ const quote = this.quote?.trim() || null;
1258
+ let shortenedQuoteParts = null;
1259
+ if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
1260
+ const words = quote.match(/\S+/g) || [];
1261
+ if (words.length >= TRUNCATED_QUOTE_WORD_COUNT * 2) {
1262
+ const quoteStart = getFirstWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
1263
+ const quoteEnd = getLastWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
1264
+ if (quoteStart && quoteEnd) {
1265
+ shortenedQuoteParts = { quoteStart, quoteEnd };
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ if (quote && !shortenedQuoteParts) {
1271
+ str += encodeURIComponent(quote);
1272
+ } else if (shortenedQuoteParts) {
1273
+ str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
1274
+ } else if (this.quoteStart && this.quoteEnd) {
1275
+ str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
1276
+ } else {
1277
+ throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
1278
+ }
1279
+
1152
1280
  if (this.suffix) {
1153
1281
  str += `,-${encodeURIComponent(this.suffix)}`;
1154
1282
  }
1155
1283
  return str;
1156
1284
  }
1157
1285
 
1286
+ /**
1287
+ * Build a regex that matches this quote payload.
1288
+ * @param {{ normalize?: function(string): string, context?: boolean }} [options]
1289
+ * @returns {RegExp}
1290
+ */
1291
+ toRegExp({ normalize = (s) => s, context = false } = {}) {
1292
+ /** @type {[String] | [String, String]} */
1293
+ const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
1294
+
1295
+ if (context) {
1296
+ if (this.prefix) quotes[0] = this.prefix + ' ' + quotes[0];
1297
+ if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
1298
+ }
1299
+
1300
+ if (quotes.length === 1) {
1301
+ return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
1302
+ } else {
1303
+ return new RegExp(
1304
+ RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
1305
+ 'g',
1306
+ );
1307
+ }
1308
+ }
1309
+
1158
1310
  toJSON() {
1159
1311
  return {
1160
1312
  prefix: this.prefix,
1161
1313
  quote: this.quote,
1314
+ quoteStart: this.quoteStart,
1315
+ quoteEnd: this.quoteEnd,
1162
1316
  suffix: this.suffix,
1163
1317
  pageNumber: this.pageNumber,
1164
1318
  pageIndex: this.pageIndex,
@@ -1178,7 +1332,7 @@ export class BookReaderTextFragment {
1178
1332
  /**
1179
1333
  * Builds a TextFragment string from a given text selection.
1180
1334
  * @param {Selection} selection currently selected text, eg `document.getSelection()`
1181
- * @param {HTMLElement[]} contextElements elements providing context for the selection
1335
+ * @param {Element[]} contextElements elements providing context for the selection
1182
1336
  * @returns {BookReaderTextFragment}
1183
1337
  */
1184
1338
  static fromSelection(selection, contextElements) {
@@ -1200,18 +1354,30 @@ export class BookReaderTextFragment {
1200
1354
  const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
1201
1355
  postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
1202
1356
 
1203
- // prefixes/suffixes cannot contain paragraph breaks, words that are from more than one line break away should not be included
1204
- const prefix = getLastWords(3, preStartRange.toString())
1205
- .replace(/[ ]+/g, " ")
1206
- .trim()
1207
- .replace(/^[^\n]*\n/gm, "");
1208
- const suffix = getFirstWords(3, postEndRange.toString())
1209
- .replace(/[ ]+/g, " ")
1210
- .trim()
1211
- .replace(/\n[^\n]*$/gm, "");
1357
+ const CONTEXT_WORD_COUNT = 3;
1358
+
1359
+ const preStartText = replaceWhitespace(preStartRange.toString());
1360
+ let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
1361
+ let prefixWords = countWords(prefix);
1362
+
1363
+ const postEndText = replaceWhitespace(postEndRange.toString());
1364
+ let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
1365
+ let suffixWords = countWords(suffix);
1366
+
1367
+ if (prefixWords < CONTEXT_WORD_COUNT) {
1368
+ // Ran out of words! To reduce the risk of ambiguity, try extending suffix
1369
+ suffix = getFirstWords(2 * CONTEXT_WORD_COUNT - prefixWords, postEndText);
1370
+ suffixWords = countWords(suffix);
1371
+ }
1372
+
1373
+ if (suffixWords < CONTEXT_WORD_COUNT) {
1374
+ // Ran out of words on the suffix side as well, try extending prefix
1375
+ prefix = getLastWords(2 * CONTEXT_WORD_COUNT - suffixWords, preStartText);
1376
+ prefixWords = countWords(prefix);
1377
+ }
1212
1378
 
1213
1379
  // Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
1214
- const quote = fullQuoteRange.toString().replace(/\s+/g, " ").trim();
1380
+ const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
1215
1381
  const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1216
1382
 
1217
1383
  return new BookReaderTextFragment({
@@ -51,6 +51,15 @@ export function isIOS() {
51
51
  return 'ongesturestart' in window && navigator.maxTouchPoints > 0;
52
52
  }
53
53
 
54
+ /**
55
+ * Checks whether the current browser is on android
56
+ * @param {string} [userAgent]
57
+ * @return {boolean}
58
+ */
59
+ export function isAndroid(userAgent = navigator.userAgent) {
60
+ return /android/i.test(userAgent);
61
+ }
62
+
54
63
  /**
55
64
  * Checks whether the current browser is Samsung Internet
56
65
  * https://stackoverflow.com/a/40684162/2317712
@@ -5,6 +5,16 @@
5
5
  * supported list of filters)
6
6
  **/
7
7
 
8
+ /**
9
+ * Count whitespace-delimited words in a string.
10
+ * @param {string} text
11
+ * @return {number}
12
+ */
13
+ export function countWords(text) {
14
+ const matches = text.match(/\S+/g);
15
+ return matches ? matches.length : 0;
16
+ }
17
+
8
18
  /**
9
19
  * @param {StringWithVars|String} template
10
20
  * @param { {[varName: string]: { toString: () => string} } } vars