@internetarchive/bookreader 5.0.0-114 → 5.0.0-116

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 (43) hide show
  1. package/BookReader/474.js.map +1 -1
  2. package/BookReader/BookReader.css +27 -10
  3. package/BookReader/BookReader.js +1 -43
  4. package/BookReader/BookReader.js.map +1 -1
  5. package/BookReader/ia-bookreader-bundle.js +63 -39
  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 +2 -2
  10. package/BookReader/plugins/plugin.chapters.js.map +1 -1
  11. package/BookReader/plugins/plugin.experiments.js.map +1 -1
  12. package/BookReader/plugins/plugin.iframe.js +1 -1
  13. package/BookReader/plugins/plugin.iframe.js.map +1 -1
  14. package/BookReader/plugins/plugin.iiif.js.map +1 -1
  15. package/BookReader/plugins/plugin.resume.js.map +1 -1
  16. package/BookReader/plugins/plugin.search.js +1 -1
  17. package/BookReader/plugins/plugin.search.js.map +1 -1
  18. package/BookReader/plugins/plugin.text_selection.js +43 -1
  19. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  20. package/BookReader/plugins/plugin.translate.js +45 -3
  21. package/BookReader/plugins/plugin.translate.js.map +1 -1
  22. package/BookReader/plugins/plugin.tts.js +1 -1
  23. package/BookReader/plugins/plugin.tts.js.map +1 -1
  24. package/BookReader/plugins/plugin.url.js +1 -1
  25. package/BookReader/plugins/plugin.url.js.map +1 -1
  26. package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -1
  27. package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
  28. package/BookReader/plugins/translator-worker.js.map +1 -1
  29. package/BookReader/webcomponents-bundle.js.map +1 -1
  30. package/jsconfig.json +2 -3
  31. package/package.json +41 -32
  32. package/src/BookReader/utils/SelectionObserver.js +4 -2
  33. package/src/BookReader.js +10 -21
  34. package/src/css/_TextSelection.scss +25 -10
  35. package/src/plugins/plugin.iiif.js +7 -1
  36. package/src/plugins/plugin.text_selection.js +4 -90
  37. package/src/plugins/search/plugin.search.js +2 -0
  38. package/src/plugins/tts/WebTTSEngine.js +2 -2
  39. package/src/plugins/tts/utils.js +0 -9
  40. package/src/plugins/url/UrlPlugin.js +12 -0
  41. package/src/util/TextSelectionManager.js +246 -173
  42. package/src/util/browserSniffing.js +9 -0
  43. package/src/util/generators.js +89 -0
@@ -1,11 +1,14 @@
1
1
  // @ts-check
2
2
  import { countWords } from './strings.js';
3
3
  import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
4
+ import { clamp } from "../BookReader/utils.js";
4
5
  import { html, LitElement } from 'lit';
5
6
  import { customElement, property, query } from 'lit/decorators.js';
6
7
  import { ifDefined } from 'lit/directives/if-defined.js';
7
8
  import '@internetarchive/icon-share';
8
9
  import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
10
+ import { isIOS, isAndroid } from './browserSniffing.js';
11
+ import { genAt, genFilter } from './generators.js';
9
12
 
10
13
  const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
11
14
  const MAX_FULL_QUOTE_URL_CHARS = 80;
@@ -55,15 +58,12 @@ export class TextSelectionManager {
55
58
  // Set a class on the page to avoid hiding it when zooming/etc
56
59
  this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
57
60
  $(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
58
- this.showSelectMenu();
59
-
61
+ // Don't show while still doing selection
62
+ if (!this.mouseIsDown) this.showSelectMenu();
60
63
  }
61
64
 
62
65
  if (selectEvent == 'changed') {
63
- // hide the button as user changes their selection
64
- if (this.mouseIsDown) {
65
- this.hideSelectMenu();
66
- } else if (window.getSelection()?.toString()) {
66
+ if (!this.mouseIsDown && window.getSelection()?.toString()) {
67
67
  this.showSelectMenu();
68
68
  }
69
69
  }
@@ -165,7 +165,6 @@ export class TextSelectionManager {
165
165
  // blocking selection
166
166
  $(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
167
167
  this.mouseIsDown = true;
168
- this.hideSelectMenu();
169
168
  if ($(event.target).is(this.selectionElement.join(", "))) {
170
169
  event.stopPropagation();
171
170
  }
@@ -173,7 +172,6 @@ export class TextSelectionManager {
173
172
 
174
173
  $(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
175
174
  this.mouseIsDown = false;
176
- this.hideSelectMenu();
177
175
  textLayer.style.pointerEvents = "none";
178
176
  if (skipNextMouseup) {
179
177
  skipNextMouseup = false;
@@ -199,7 +197,6 @@ export class TextSelectionManager {
199
197
  if (event.which != 1) return;
200
198
  this.mouseIsDown = true;
201
199
  event.stopPropagation();
202
- this.hideSelectMenu();
203
200
  });
204
201
 
205
202
  // Prevent page flip on click
@@ -221,7 +218,11 @@ export class TextSelectionManager {
221
218
  document.body.append(this.selectMenu);
222
219
  }
223
220
 
224
- this.selectMenu.show();
221
+ if (this.selectMenu.open) {
222
+ this.selectMenu.repositionToSelection();
223
+ } else {
224
+ this.selectMenu.show();
225
+ }
225
226
  }
226
227
 
227
228
  hideSelectMenu() {
@@ -247,8 +248,8 @@ export class TextSelectionManager {
247
248
  // Find the last allowed word in the selection
248
249
  const lastAllowedWord = genAt(
249
250
  genFilter(
250
- walkBetweenNodes(range.startContainer, range.endContainer),
251
- (node) => node.classList?.contains(
251
+ treeWalkRange(range, 'element'),
252
+ (node) => node.classList.contains(
252
253
  this.selectionElement[0].replace(".", ""),
253
254
  ),
254
255
  ),
@@ -267,55 +268,58 @@ export class TextSelectionManager {
267
268
  }
268
269
 
269
270
  /**
270
- * @template T
271
- * Get the i-th element of an iterable
272
- * @param {Iterable<T>} iterable
273
- * @param {number} index
271
+ * @overload
272
+ * @param {Range} range
273
+ * @param {'text'} filter
274
+ * @returns {Generator<Text>}
274
275
  */
275
- export function genAt(iterable, index) {
276
- let i = 0;
277
- for (const x of iterable) {
278
- if (i == index) {
279
- return x;
280
- }
281
- i++;
282
- }
283
- return undefined;
284
- }
285
-
286
276
  /**
287
- * @template T
288
- * Generator version of filter
289
- * @param {Iterable<T>} iterable
290
- * @param {function(T): boolean} fn
277
+ * @overload
278
+ * @param {Range} range
279
+ * @param {'element'} filter
280
+ * @returns {Generator<Element>}
281
+ */
282
+ /**
283
+ * @overload
284
+ * @param {Range} range
285
+ * @param {'text+element'} [filter]
286
+ * @returns {Generator<Text | Element>}
291
287
  */
292
- export function* genFilter(iterable, fn) {
293
- for (const x of iterable) {
294
- if (fn(x)) yield x;
295
- }
296
- }
297
-
298
288
  /**
299
289
  * Depth traverse the DOM tree starting at `start`, and ending at `end`.
300
- * @param {Node} start
301
- * @param {Node} end
290
+ * @param {Range} range
291
+ * @param {'text' | 'element' | 'text+element'} [filter]
302
292
  * @returns {Generator<Node>}
303
293
  */
304
- export function* walkBetweenNodes(start, end) {
294
+ export function* treeWalkRange(range, filter = 'text+element') {
295
+ const start = range.startContainer;
296
+ const end = range.endContainer;
305
297
  let done = false;
306
298
 
307
299
  /**
308
300
  * @param {Node} node
309
301
  */
302
+ const passesFilter = node => {
303
+ return (
304
+ (filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
305
+ (filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
306
+ (filter === 'text+element')
307
+ );
308
+ };
309
+
310
+ /**
311
+ * @param {Node} node
312
+ * @returns {Generator<Node>}
313
+ */
310
314
  function* walk(node, {children = true, parents = true, siblings = true} = {}) {
311
315
  if (node === end) {
312
316
  done = true;
313
- yield node;
317
+ if (passesFilter(node)) yield node;
314
318
  return;
315
319
  }
316
320
 
317
321
  // yield self
318
- yield node;
322
+ if (passesFilter(node)) yield node;
319
323
 
320
324
  // First iterate children (depth-first traversal)
321
325
  if (children && node.firstChild) {
@@ -459,6 +463,9 @@ class BRSelectMenu extends LitElement {
459
463
  @query('#copy-link-option')
460
464
  copyLinkOption;
461
465
 
466
+ @property({type: Boolean, reflect: true})
467
+ open = false;
468
+
462
469
  @property({type: Boolean})
463
470
  copyLinkToHighlightEnabled = true;
464
471
 
@@ -479,6 +486,19 @@ class BRSelectMenu extends LitElement {
479
486
  return this;
480
487
  }
481
488
 
489
+ /** @type {number | null} */
490
+ _scrollTimeoutId = null;
491
+
492
+ _onScroll = () => {
493
+ this.classList.add('br-select-menu__root--scrolling');
494
+ if (this._scrollTimeoutId != null) clearTimeout(this._scrollTimeoutId);
495
+ this._scrollTimeoutId = window.setTimeout(() => {
496
+ this._scrollTimeoutId = null;
497
+ this.repositionToSelection();
498
+ this.classList.remove('br-select-menu__root--scrolling');
499
+ }, 150);
500
+ };
501
+
482
502
  /** @override */
483
503
  connectedCallback() {
484
504
  super.connectedCallback();
@@ -487,10 +507,22 @@ class BRSelectMenu extends LitElement {
487
507
  this.setAttribute('aria-orientation', 'vertical');
488
508
  }
489
509
 
510
+ /** @override */
511
+ disconnectedCallback() {
512
+ super.disconnectedCallback();
513
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
514
+ if (this._scrollTimeoutId != null) {
515
+ clearTimeout(this._scrollTimeoutId);
516
+ this._scrollTimeoutId = null;
517
+ }
518
+ }
519
+
490
520
  renderCopyLinkToHighlightOption() {
521
+ // Mousedown needed to prevent selection from being cleared on iOS
491
522
  return html`
492
523
  <br-menu-option
493
524
  id="copy-link-option"
525
+ @mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
494
526
  @click=${this.handleCopyLinkToHighlight}
495
527
  icon="share"
496
528
  label="Copy Link to Highlight"
@@ -557,7 +589,6 @@ class BRSelectMenu extends LitElement {
557
589
  */
558
590
  async handleCopyLinkToHighlight(e) {
559
591
  e.preventDefault();
560
- const currentParams = this.br.readQueryString();
561
592
  const currentSelection = /** @type {Selection} */ (window.getSelection());
562
593
  const range = currentSelection.getRangeAt(0);
563
594
  const textLayer = this.getNodeTextLayer(range.startContainer);
@@ -567,19 +598,18 @@ class BRSelectMenu extends LitElement {
567
598
  }
568
599
  const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
569
600
 
570
- // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
571
- let linkToHighlightParams = currentParams;
572
- if (currentParams.includes('text=')) {
573
- linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
574
- } else {
575
- const sep = linkToHighlightParams ? '&' : '?';
576
- linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
577
- }
601
+ const currentParams = new URLSearchParams(this.br.readQueryString());
602
+ if (currentParams.has('text')) currentParams.delete('text');
603
+
604
+ let linkToHighlightParams = currentParams.toString();
605
+ // Use custom toUrlString, which decodes the delimiters for better readability
606
+ linkToHighlightParams += (linkToHighlightParams ? '&' : '') + `text=${textFragment.toUrlString()}`;
607
+
578
608
  const currentUrl = window.location;
579
609
  const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
580
610
  const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
581
611
  const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
582
- const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
612
+ const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}?${linkToHighlightParams}${hash}`;
583
613
 
584
614
  await navigator.clipboard.writeText(linkToHighlight);
585
615
  this.copyLinkOption?.showTemporaryText('Copied!');
@@ -703,31 +733,54 @@ class BRSelectMenu extends LitElement {
703
733
  this.show();
704
734
  }
705
735
 
706
- show() {
736
+ repositionToSelection() {
737
+ const SCREEN_MARGIN = 10;
738
+ const currentSelection = /** @type {Selection} */ (window.getSelection());
739
+ const range = currentSelection.getRangeAt(0);
740
+ const selectionRect = range.getBoundingClientRect();
741
+
742
+ const isMobile = isIOS() || isAndroid();
743
+ // Leave room for mobile teardrop selection handles
744
+ const SELECTION_PADDING = isAndroid() ? 25 : isIOS() ? 15 : 10;
745
+ // On mobile, avoid the native OS text-selection bar which appears near the top of the screen
746
+ const OS_BAR_H = isMobile ? 60 : 0;
747
+ const idealTop = (selectionRect.top < OS_BAR_H
748
+ ? selectionRect.bottom + OS_BAR_H
749
+ : selectionRect.bottom) + SELECTION_PADDING;
750
+ const top = Math.max(SCREEN_MARGIN, idealTop) + window.scrollY;
751
+
752
+ const menuWidth = this.getBoundingClientRect().width;
753
+ // Excludes scrollbars
754
+ const windowWidth = document.documentElement.clientWidth;
755
+ const idealLeft = menuWidth > selectionRect.width
756
+ ? selectionRect.left + selectionRect.width / 2 - menuWidth / 2
757
+ : selectionRect.left;
758
+ const left = clamp(idealLeft, SCREEN_MARGIN, windowWidth - menuWidth - SCREEN_MARGIN) + window.scrollX;
759
+
760
+ this.style.top = `${top}px`;
761
+ this.style.left = `${left}px`;
762
+ }
763
+
764
+ async show() {
707
765
  if (this.br.plugins.translate?.userToggleTranslate) return;
708
- const currentSelection = window.getSelection();
709
- const start = currentSelection.anchorNode.parentElement;
710
- const end = currentSelection.focusNode.parentElement; // will always be a text node
711
- const height = 30;
712
- const width = 60;
713
- const startBoundingRect = start.getBoundingClientRect();
714
- const endBoundingRect = end.getBoundingClientRect();
715
- let hlButtonTop = startBoundingRect.top - height;
716
- let hlButtonLeft = startBoundingRect.left - width;
717
- if (currentSelection.direction == 'backward') {
718
- hlButtonTop = endBoundingRect.top - height;
719
- hlButtonLeft = endBoundingRect.left - width;
720
- }
721
- this.style.top = `${hlButtonTop}px`;
722
- this.style.left = `${hlButtonLeft}px`;
766
+
723
767
  this.style.zIndex = '1';
724
768
  this.style.position = 'absolute';
725
769
  this.style.display = 'block';
770
+ this.open = true;
771
+ this.classList.remove('br-select-menu__root--scrolling');
772
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
773
+ window.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
774
+ await this.updateComplete; // ensure Lit has rendered so getBoundingClientRect() returns real width
775
+ this.repositionToSelection();
726
776
  this.clearNodesForRemoval();
727
777
  }
728
778
 
729
- hide = () => {
779
+ hide() {
780
+ if (!this.open) return;
730
781
  this.style.display = 'none';
782
+ this.open = false;
783
+ window.removeEventListener('scroll', this._onScroll, { capture: true });
731
784
  this.clearNodesForRemoval();
732
785
  return;
733
786
  }
@@ -788,12 +841,23 @@ export function getLastMostNode(parent) {
788
841
  }
789
842
 
790
843
  /**
791
- * Strips the whitespace to normalize text
844
+ * Normalizes text fragment whitespace and removes special delimiter characters
845
+ * to allow for reliable url encoding/decoding
846
+ *
847
+ * Note: It's very important this *does not* change the length of the string to
848
+ * ensure matching works smoothly later.
849
+ *
792
850
  * @param {String} string
793
- * @returns
794
851
  */
795
- function replaceWhitespace(string) {
796
- return string.replace(/\s+/g, " ");
852
+ export function replaceTextFragmentDelimiters(string) {
853
+ return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
854
+ }
855
+
856
+ /**
857
+ * @param {String} s
858
+ */
859
+ export function collapseWhitespace(s) {
860
+ return s.replace(/\s+/g, ' ').trim();
797
861
  }
798
862
 
799
863
  /**
@@ -810,7 +874,7 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
810
874
  const startOffset = textNodes[0] === range.startContainer ?
811
875
  range.startOffset :
812
876
  0;
813
- const normalizedWholePageString = normalize(range.toString());
877
+ const normalizedWholePageString = normalize(textLayerRangeToString(range));
814
878
  const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
815
879
  const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
816
880
 
@@ -835,8 +899,8 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
835
899
  }
836
900
 
837
901
  const foundRange = new Range();
838
- foundRange.setStart(start.node, 0);
839
- foundRange.setEnd(end.node, 1);
902
+ foundRange.setStart(start.node, start.offset);
903
+ foundRange.setEnd(end.node, end.offset);
840
904
  return foundRange;
841
905
  });
842
906
 
@@ -846,68 +910,74 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
846
910
  }
847
911
 
848
912
  /**
849
- * Uses the index that matches the quote string and normalizes the string contents to find the correct node
913
+ * Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
850
914
  * @param {Number} index
851
915
  * @param {Node[]} nodes
852
916
  * @param {boolean} isEnd
853
917
  * @param {{ normalize?: function(string): string }} [options]
854
918
  */
855
919
  export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
856
- let counted = 0;
857
- let normalizedData;
858
- for (let i = 0; i < nodes.length; i++) {
859
- const node = nodes[i];
860
- if (node.className === 'BRlineElement') {
861
- // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
862
- normalizedData = ' ';
863
- } else {
864
- if (!normalizedData) normalizedData = normalize(node.textContent);
865
- }
866
- let nodeEnd = counted + normalizedData.length;
867
- if (isEnd) nodeEnd += 1;
868
- if (nodeEnd > index) {
869
- const normalizedOffset = index - counted;
870
- let denormalizedOffset = Math.min(index - counted, node.textContent.length);
871
-
872
- const targetSubstring = isEnd ?
873
- normalizedData.substring(0, normalizedOffset) :
874
- normalizedData.substring(normalizedOffset);
875
-
876
- let candidateSubstring = isEnd ?
877
- normalize(node.textContent.substring(0, normalizedOffset)) :
878
- normalize(node.textContent.substring(normalizedOffset));
879
-
880
- const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
881
- while (denormalizedOffset >= 0 &&
882
- denormalizedOffset <= node.textContent.length) {
883
- if (candidateSubstring.length === targetSubstring.length) {
884
- return {node : node, offset: denormalizedOffset};
920
+ // Rename to have consistent wording
921
+ const normalizedTargetIndex = index;
922
+ /** Characters in node-space counted so far */
923
+ let normalizedI = 0;
924
+
925
+ for (const node of nodes) {
926
+ const nodeText = node.textContent || '';
927
+ const normalizedText = normalize(nodeText);
928
+ const nodeNormalizedStart = normalizedI;
929
+ const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
930
+
931
+ if (nodeNormalizedEnd > normalizedTargetIndex) {
932
+ // We have found the node that contains the normalizedTargetIndex we are looking for!
933
+ const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
934
+
935
+ // But the normalized text could be a shorter length than the node text,
936
+ // so we try to continually increase the nodeOffset
937
+ let nodeOffset = Math.min(normalizedOffset, nodeText.length);
938
+
939
+ const normalizedSubstring = isEnd ?
940
+ normalizedText.substring(0, normalizedOffset) :
941
+ normalizedText.substring(normalizedOffset);
942
+
943
+ let nodeSubstring = isEnd ?
944
+ normalize(nodeText.substring(0, normalizedOffset)) :
945
+ normalize(nodeText.substring(normalizedOffset));
946
+
947
+ const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
948
+ while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
949
+ if (nodeSubstring.length === normalizedSubstring.length) {
950
+ return {node, offset: nodeOffset};
885
951
  }
886
- denormalizedOffset += direction;
952
+ nodeOffset += direction;
887
953
 
888
- candidateSubstring = isEnd ?
889
- node.textContent.substring(0, denormalizedOffset) :
890
- node.textContent.substring(denormalizedOffset);
954
+ nodeSubstring = isEnd ?
955
+ normalize(nodeText.substring(0, nodeOffset)) :
956
+ normalize(nodeText.substring(nodeOffset));
891
957
  }
892
958
  }
893
- counted += normalizedData.length;
894
-
895
- if (i + 1 < nodes.length) {
896
- const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
897
- // Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
898
- // 1st case explicitly check the node class to prevent double counted spaces
899
- // 2nd case can happen from node traversal when loading from localStorage
900
- if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
901
- counted -= 1;
902
- } else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
903
- counted -= 1;
904
- }
905
- normalizedData = nextNormalizedData;
906
- }
959
+ normalizedI += normalizedText.length;
907
960
  }
908
961
  return undefined;
909
962
  }
910
963
 
964
+ /**
965
+ * @param {Range} range
966
+ */
967
+ export function textLayerRangeToString(range) {
968
+ const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
969
+ let result = textNodes.map(node => node.textContent).join('');
970
+ const firstTextNode = textNodes[0];
971
+ const lastTextNode = textNodes[textNodes.length - 1];
972
+ if (range.startContainer === firstTextNode) {
973
+ result = result.substring(range.startOffset);
974
+ }
975
+ if (range.endContainer === lastTextNode) {
976
+ result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
977
+ }
978
+ return result;
979
+ }
980
+
911
981
  /**
912
982
  * Takes a text quote object and a container element, and wraps the quote
913
983
  * within the container element in a span to apply highlight-like styling
@@ -928,37 +998,29 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
928
998
  const wholePageRange = new Range();
929
999
  wholePageRange.setStart(firstPageNode, 0);
930
1000
  wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
931
- const normalize = replaceWhitespace;
932
1001
 
933
- // Retrieve the text nodes and relevant whitespace elements
934
- // 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
935
- const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
936
- pageWordNodes.splice(0, 1);
1002
+ const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
937
1003
 
938
1004
  const broadRanges = findRangeForRegExp(
939
- textFragment.toRegExp({ normalize, context: true }),
1005
+ textFragment.toRegExp({ context: true }),
940
1006
  wholePageRange,
941
1007
  pageWordNodes,
942
- { normalize },
1008
+ { normalize: replaceTextFragmentDelimiters },
943
1009
  );
944
1010
  if (!broadRanges) {
945
1011
  console.warn("Could not find quote with context in page");
946
1012
  return;
947
1013
  }
948
1014
 
949
- const broadRangeWordNodes = [];
950
- for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
951
- if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
952
- broadRangeWordNodes.push(el);
953
- }
954
- }
1015
+ const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
955
1016
 
956
1017
  // At which point the quote should now be unambiguous!
1018
+ // FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
957
1019
  const exactRanges = findRangeForRegExp(
958
- textFragment.toRegExp({ normalize, context: false }),
1020
+ textFragment.toRegExp({ context: false }),
959
1021
  broadRanges[0],
960
1022
  broadRangeWordNodes,
961
- { normalize },
1023
+ { normalize: replaceTextFragmentDelimiters },
962
1024
  );
963
1025
  if (!exactRanges) {
964
1026
  throw new Error("Could not find quote in page");
@@ -986,6 +1048,14 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
986
1048
  });
987
1049
  }
988
1050
 
1051
+ /**
1052
+ * @param {Text} node
1053
+ */
1054
+ export function isBRVisibleTextNode(node) {
1055
+ // Exclude the duplicated spaces between words for MS Edge
1056
+ return !node.previousElementSibling?.classList.contains("BRspace");
1057
+ }
1058
+
989
1059
  /**
990
1060
  * Given a Range, wraps its text contents in one or more <mark> elements.
991
1061
  * <mark> elements can't cross block boundaries, so this function walks the
@@ -1171,39 +1241,33 @@ export class BookReaderTextFragment {
1171
1241
  });
1172
1242
  }
1173
1243
 
1174
- /**
1175
- * Extract and parse a text fragment from a URL string containing a `text=` parameter.
1176
- * @param {string} urlString
1177
- * @param {import('@/src/BookReader/BookModel.js').BookModel} book
1178
- * @param {number} fallbackPageIndex A fallback page index to use if the text
1179
- * fragment does not specify a page number or page index.
1180
- * @returns {BookReaderTextFragment|null}
1181
- */
1182
- static fromUrl(urlString, book, fallbackPageIndex) {
1183
- // Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
1184
- // commas and colons, so need to do a regex match to extract the text fragment string
1185
- const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
1186
- if (!textMatch) return null;
1187
- return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
1188
- }
1189
1244
 
1190
1245
  /**
1191
1246
  * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1192
1247
  * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1193
- * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1194
- * Note the ':' and ',' separators must not and are not encoded, but
1248
+ *
1249
+ * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1250
+ * Note the ':' and ',' separators must not and are not encoded, but
1195
1251
  * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1252
+ *
1196
1253
  * @returns {string}
1197
1254
  */
1198
1255
  toUrlString() {
1256
+ // When constructing to url, we can collapse the whitespace since the corresponding
1257
+ // toRegExp method is resilient to multiple whitespace characters.
1258
+ /** @param {string} s */
1259
+ const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
1260
+
1199
1261
  // First the page number or index
1200
- let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
1262
+ let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
1263
+ str += ':';
1201
1264
 
1202
1265
  if (this.prefix) {
1203
- str += `${encodeURIComponent(this.prefix)}-,`;
1266
+ str += normalize(this.prefix);
1267
+ str += '-,';
1204
1268
  }
1205
1269
 
1206
- const quote = this.quote?.trim() || null;
1270
+ const quote = this.quote ? normalize(this.quote) : null;
1207
1271
  let shortenedQuoteParts = null;
1208
1272
  if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
1209
1273
  const words = quote.match(/\S+/g) || [];
@@ -1217,27 +1281,33 @@ export class BookReaderTextFragment {
1217
1281
  }
1218
1282
 
1219
1283
  if (quote && !shortenedQuoteParts) {
1220
- str += encodeURIComponent(quote);
1284
+ str += quote;
1221
1285
  } else if (shortenedQuoteParts) {
1222
- str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
1286
+ str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
1223
1287
  } else if (this.quoteStart && this.quoteEnd) {
1224
- str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
1288
+ str += `${this.quoteStart},${this.quoteEnd}`;
1225
1289
  } else {
1226
1290
  throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
1227
1291
  }
1228
1292
 
1229
1293
  if (this.suffix) {
1230
- str += `,-${encodeURIComponent(this.suffix)}`;
1294
+ str += ',-';
1295
+ str += normalize(this.suffix);
1231
1296
  }
1232
- return str;
1297
+
1298
+ return encodeURIComponent(str)
1299
+ // Bring back the delimiters so it's easier to read, but note not necessary.
1300
+ // Note hyphens are not encoded by encodeURIComponent, so no need to replace them
1301
+ .replace(/%2C/g, ',')
1302
+ .replace(/%3A/g, ':');
1233
1303
  }
1234
1304
 
1235
1305
  /**
1236
1306
  * Build a regex that matches this quote payload.
1237
- * @param {{ normalize?: function(string): string, context?: boolean }} [options]
1307
+ * @param {{ context?: boolean }} [options]
1238
1308
  * @returns {RegExp}
1239
1309
  */
1240
- toRegExp({ normalize = (s) => s, context = false } = {}) {
1310
+ toRegExp({ context = false } = {}) {
1241
1311
  /** @type {[String] | [String, String]} */
1242
1312
  const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
1243
1313
 
@@ -1246,12 +1316,16 @@ export class BookReaderTextFragment {
1246
1316
  if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
1247
1317
  }
1248
1318
 
1319
+ // Make it resilient to extra whitespace
1320
+ /** @param {String} quote */
1321
+ const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
1322
+
1249
1323
  if (quotes.length === 1) {
1250
- return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
1324
+ return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
1251
1325
  } else {
1252
1326
  return new RegExp(
1253
- RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
1254
- 'g',
1327
+ quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
1328
+ 'gi',
1255
1329
  );
1256
1330
  }
1257
1331
  }
@@ -1305,11 +1379,11 @@ export class BookReaderTextFragment {
1305
1379
 
1306
1380
  const CONTEXT_WORD_COUNT = 3;
1307
1381
 
1308
- const preStartText = replaceWhitespace(preStartRange.toString());
1382
+ const preStartText = textLayerRangeToString(preStartRange);
1309
1383
  let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
1310
1384
  let prefixWords = countWords(prefix);
1311
1385
 
1312
- const postEndText = replaceWhitespace(postEndRange.toString());
1386
+ const postEndText = textLayerRangeToString(postEndRange);
1313
1387
  let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
1314
1388
  let suffixWords = countWords(suffix);
1315
1389
 
@@ -1325,8 +1399,7 @@ export class BookReaderTextFragment {
1325
1399
  prefixWords = countWords(prefix);
1326
1400
  }
1327
1401
 
1328
- // Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
1329
- const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
1402
+ const quote = textLayerRangeToString(fullQuoteRange);
1330
1403
  const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1331
1404
 
1332
1405
  return new BookReaderTextFragment({