@internetarchive/bookreader 5.0.0-115 → 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 (42) hide show
  1. package/BookReader/BookReader.css +44 -18
  2. package/BookReader/BookReader.js +1 -43
  3. package/BookReader/BookReader.js.map +1 -1
  4. package/BookReader/ia-bookreader-bundle.js +109 -67
  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 +63 -1
  20. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  21. package/BookReader/plugins/plugin.translate.js +65 -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/BookReader/plugins/plugin.url.js.map +1 -1
  27. package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -1
  28. package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
  29. package/README.md +3 -1
  30. package/jsconfig.json +2 -3
  31. package/package.json +38 -31
  32. package/src/BookReader.js +24 -20
  33. package/src/css/_TextSelection.scss +51 -18
  34. package/src/plugins/plugin.chapters.js +8 -4
  35. package/src/plugins/plugin.iframe.js +36 -37
  36. package/src/plugins/plugin.text_selection.js +40 -101
  37. package/src/plugins/search/plugin.search.js +4 -8
  38. package/src/plugins/tts/plugin.tts.js +3 -8
  39. package/src/plugins/url/UrlPlugin.js +12 -0
  40. package/src/util/TextSelectionManager.js +221 -154
  41. package/src/util/dom.js +88 -0
  42. package/src/util/generators.js +89 -0
@@ -7,7 +7,9 @@ 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';
12
+ import { genAt, genFilter } from './generators.js';
11
13
 
12
14
  const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
13
15
  const MAX_FULL_QUOTE_URL_CHARS = 80;
@@ -247,8 +249,8 @@ export class TextSelectionManager {
247
249
  // Find the last allowed word in the selection
248
250
  const lastAllowedWord = genAt(
249
251
  genFilter(
250
- walkBetweenNodes(range.startContainer, range.endContainer),
251
- (node) => node.classList?.contains(
252
+ treeWalkRange(range, 'element'),
253
+ (node) => node.classList.contains(
252
254
  this.selectionElement[0].replace(".", ""),
253
255
  ),
254
256
  ),
@@ -267,55 +269,58 @@ export class TextSelectionManager {
267
269
  }
268
270
 
269
271
  /**
270
- * @template T
271
- * Get the i-th element of an iterable
272
- * @param {Iterable<T>} iterable
273
- * @param {number} index
272
+ * @overload
273
+ * @param {Range} range
274
+ * @param {'text'} filter
275
+ * @returns {Generator<Text>}
274
276
  */
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
277
  /**
287
- * @template T
288
- * Generator version of filter
289
- * @param {Iterable<T>} iterable
290
- * @param {function(T): boolean} fn
278
+ * @overload
279
+ * @param {Range} range
280
+ * @param {'element'} filter
281
+ * @returns {Generator<Element>}
282
+ */
283
+ /**
284
+ * @overload
285
+ * @param {Range} range
286
+ * @param {'text+element'} [filter]
287
+ * @returns {Generator<Text | Element>}
291
288
  */
292
- export function* genFilter(iterable, fn) {
293
- for (const x of iterable) {
294
- if (fn(x)) yield x;
295
- }
296
- }
297
-
298
289
  /**
299
290
  * Depth traverse the DOM tree starting at `start`, and ending at `end`.
300
- * @param {Node} start
301
- * @param {Node} end
291
+ * @param {Range} range
292
+ * @param {'text' | 'element' | 'text+element'} [filter]
302
293
  * @returns {Generator<Node>}
303
294
  */
304
- export function* walkBetweenNodes(start, end) {
295
+ export function* treeWalkRange(range, filter = 'text+element') {
296
+ const start = range.startContainer;
297
+ const end = range.endContainer;
305
298
  let done = false;
306
299
 
307
300
  /**
308
301
  * @param {Node} node
309
302
  */
303
+ const passesFilter = node => {
304
+ return (
305
+ (filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
306
+ (filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
307
+ (filter === 'text+element')
308
+ );
309
+ };
310
+
311
+ /**
312
+ * @param {Node} node
313
+ * @returns {Generator<Node>}
314
+ */
310
315
  function* walk(node, {children = true, parents = true, siblings = true} = {}) {
311
316
  if (node === end) {
312
317
  done = true;
313
- yield node;
318
+ if (passesFilter(node)) yield node;
314
319
  return;
315
320
  }
316
321
 
317
322
  // yield self
318
- yield node;
323
+ if (passesFilter(node)) yield node;
319
324
 
320
325
  // First iterate children (depth-first traversal)
321
326
  if (children && node.firstChild) {
@@ -408,6 +413,9 @@ export class BRSelectMenuOption extends LitElement {
408
413
  if (this.icon === 'edit-pencil') {
409
414
  return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
410
415
  }
416
+ if (this.icon === 'ellipses') {
417
+ return html`<ia-icon-ellipses class="br-select-menu__icon" aria-hidden="true"></ia-icon-ellipses>`;
418
+ }
411
419
  return '';
412
420
  }
413
421
 
@@ -429,7 +437,9 @@ export class BRSelectMenuOption extends LitElement {
429
437
  aria-label=${ifDefined(accessibleLabel)}
430
438
  >
431
439
  ${this.renderIcon()}
432
- ${hasTemporaryText ? html`
440
+ ${
441
+ !hasTemporaryText && !baseLabel ? '' :
442
+ hasTemporaryText ? html`
433
443
  <span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
434
444
  <span
435
445
  class="br-select-menu__label"
@@ -455,8 +465,12 @@ class BRSelectMenu extends LitElement {
455
465
  /** @type {import('../BookReader.js').default} */
456
466
  br;
457
467
 
468
+ /** @type {boolean} */
469
+ @property({type: Boolean, reflect: true})
470
+ showExtended = false;
471
+
458
472
  /** @type {BRSelectMenuOption | null} */
459
- @query('#copy-link-option')
473
+ @query('#br-select-copy-link-option')
460
474
  copyLinkOption;
461
475
 
462
476
  @property({type: Boolean, reflect: true})
@@ -517,7 +531,7 @@ class BRSelectMenu extends LitElement {
517
531
  // Mousedown needed to prevent selection from being cleared on iOS
518
532
  return html`
519
533
  <br-menu-option
520
- id="copy-link-option"
534
+ id="br-select-copy-link-option"
521
535
  @mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
522
536
  @click=${this.handleCopyLinkToHighlight}
523
537
  icon="share"
@@ -556,6 +570,19 @@ class BRSelectMenu extends LitElement {
556
570
  ></br-menu-option>
557
571
  `;
558
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
+
559
586
  renderLocalStorageOptions() {
560
587
  return html`
561
588
  <br-menu-option
@@ -570,22 +597,35 @@ class BRSelectMenu extends LitElement {
570
597
  ></br-menu-option>`;
571
598
  }
572
599
 
573
- render() {
574
- // TODO change the second button to use a different icon
600
+ renderDefaultOptions() {
575
601
  return html`
576
602
  ${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
577
603
  ${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
578
- ${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
579
604
  ${this.nodesForRemoval ? this.renderRemoveOption() : ''}
580
605
  `;
581
606
  }
582
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
+
583
624
  /**
584
625
  * @param {MouseEvent} e
585
626
  */
586
627
  async handleCopyLinkToHighlight(e) {
587
628
  e.preventDefault();
588
- const currentParams = this.br.readQueryString();
589
629
  const currentSelection = /** @type {Selection} */ (window.getSelection());
590
630
  const range = currentSelection.getRangeAt(0);
591
631
  const textLayer = this.getNodeTextLayer(range.startContainer);
@@ -595,24 +635,31 @@ class BRSelectMenu extends LitElement {
595
635
  }
596
636
  const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
597
637
 
598
- // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
599
- let linkToHighlightParams = currentParams;
600
- if (currentParams.includes('text=')) {
601
- linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
602
- } else {
603
- const sep = linkToHighlightParams ? '&' : '?';
604
- linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
605
- }
638
+ const currentParams = new URLSearchParams(this.br.readQueryString());
639
+ if (currentParams.has('text')) currentParams.delete('text');
640
+
641
+ let linkToHighlightParams = currentParams.toString();
642
+ // Use custom toUrlString, which decodes the delimiters for better readability
643
+ linkToHighlightParams += (linkToHighlightParams ? '&' : '') + `text=${textFragment.toUrlString()}`;
644
+
606
645
  const currentUrl = window.location;
607
646
  const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
608
647
  const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
609
648
  const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
610
- const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
649
+ const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}?${linkToHighlightParams}${hash}`;
611
650
 
612
651
  await navigator.clipboard.writeText(linkToHighlight);
613
652
  this.copyLinkOption?.showTemporaryText('Copied!');
614
653
  }
615
654
 
655
+ /**
656
+ * @param {MouseEvent} e
657
+ */
658
+ toggleExtendedMenu(e) {
659
+ e.preventDefault();
660
+ this.showExtended = !this.showExtended;
661
+ }
662
+
616
663
  /**
617
664
  * Returns the closest BRtextLayer element on the page that contains the target node
618
665
  * @param {Node} node
@@ -759,12 +806,12 @@ class BRSelectMenu extends LitElement {
759
806
  this.style.left = `${left}px`;
760
807
  }
761
808
 
809
+ // Will always show the simplified menu when rendered after hiding
762
810
  async show() {
763
811
  if (this.br.plugins.translate?.userToggleTranslate) return;
764
812
 
765
813
  this.style.zIndex = '1';
766
814
  this.style.position = 'absolute';
767
- this.style.display = 'block';
768
815
  this.open = true;
769
816
  this.classList.remove('br-select-menu__root--scrolling');
770
817
  window.removeEventListener('scroll', this._onScroll, { capture: true });
@@ -776,7 +823,7 @@ class BRSelectMenu extends LitElement {
776
823
 
777
824
  hide() {
778
825
  if (!this.open) return;
779
- this.style.display = 'none';
826
+ this.showExtended = false;
780
827
  this.open = false;
781
828
  window.removeEventListener('scroll', this._onScroll, { capture: true });
782
829
  this.clearNodesForRemoval();
@@ -839,12 +886,23 @@ export function getLastMostNode(parent) {
839
886
  }
840
887
 
841
888
  /**
842
- * Strips the whitespace to normalize text
889
+ * Normalizes text fragment whitespace and removes special delimiter characters
890
+ * to allow for reliable url encoding/decoding
891
+ *
892
+ * Note: It's very important this *does not* change the length of the string to
893
+ * ensure matching works smoothly later.
894
+ *
843
895
  * @param {String} string
844
- * @returns
845
896
  */
846
- function replaceWhitespace(string) {
847
- return string.replace(/\s+/g, " ");
897
+ export function replaceTextFragmentDelimiters(string) {
898
+ return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
899
+ }
900
+
901
+ /**
902
+ * @param {String} s
903
+ */
904
+ export function collapseWhitespace(s) {
905
+ return s.replace(/\s+/g, ' ').trim();
848
906
  }
849
907
 
850
908
  /**
@@ -861,7 +919,7 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
861
919
  const startOffset = textNodes[0] === range.startContainer ?
862
920
  range.startOffset :
863
921
  0;
864
- const normalizedWholePageString = normalize(range.toString());
922
+ const normalizedWholePageString = normalize(textLayerRangeToString(range));
865
923
  const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
866
924
  const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
867
925
 
@@ -886,8 +944,8 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
886
944
  }
887
945
 
888
946
  const foundRange = new Range();
889
- foundRange.setStart(start.node, 0);
890
- foundRange.setEnd(end.node, 1);
947
+ foundRange.setStart(start.node, start.offset);
948
+ foundRange.setEnd(end.node, end.offset);
891
949
  return foundRange;
892
950
  });
893
951
 
@@ -897,68 +955,74 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
897
955
  }
898
956
 
899
957
  /**
900
- * Uses the index that matches the quote string and normalizes the string contents to find the correct node
958
+ * Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
901
959
  * @param {Number} index
902
960
  * @param {Node[]} nodes
903
961
  * @param {boolean} isEnd
904
962
  * @param {{ normalize?: function(string): string }} [options]
905
963
  */
906
964
  export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
907
- let counted = 0;
908
- let normalizedData;
909
- for (let i = 0; i < nodes.length; i++) {
910
- const node = nodes[i];
911
- if (node.className === 'BRlineElement') {
912
- // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
913
- normalizedData = ' ';
914
- } else {
915
- if (!normalizedData) normalizedData = normalize(node.textContent);
916
- }
917
- let nodeEnd = counted + normalizedData.length;
918
- if (isEnd) nodeEnd += 1;
919
- if (nodeEnd > index) {
920
- const normalizedOffset = index - counted;
921
- let denormalizedOffset = Math.min(index - counted, node.textContent.length);
922
-
923
- const targetSubstring = isEnd ?
924
- normalizedData.substring(0, normalizedOffset) :
925
- normalizedData.substring(normalizedOffset);
926
-
927
- let candidateSubstring = isEnd ?
928
- normalize(node.textContent.substring(0, normalizedOffset)) :
929
- normalize(node.textContent.substring(normalizedOffset));
930
-
931
- const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
932
- while (denormalizedOffset >= 0 &&
933
- denormalizedOffset <= node.textContent.length) {
934
- if (candidateSubstring.length === targetSubstring.length) {
935
- return {node : node, offset: denormalizedOffset};
965
+ // Rename to have consistent wording
966
+ const normalizedTargetIndex = index;
967
+ /** Characters in node-space counted so far */
968
+ let normalizedI = 0;
969
+
970
+ for (const node of nodes) {
971
+ const nodeText = node.textContent || '';
972
+ const normalizedText = normalize(nodeText);
973
+ const nodeNormalizedStart = normalizedI;
974
+ const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
975
+
976
+ if (nodeNormalizedEnd > normalizedTargetIndex) {
977
+ // We have found the node that contains the normalizedTargetIndex we are looking for!
978
+ const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
979
+
980
+ // But the normalized text could be a shorter length than the node text,
981
+ // so we try to continually increase the nodeOffset
982
+ let nodeOffset = Math.min(normalizedOffset, nodeText.length);
983
+
984
+ const normalizedSubstring = isEnd ?
985
+ normalizedText.substring(0, normalizedOffset) :
986
+ normalizedText.substring(normalizedOffset);
987
+
988
+ let nodeSubstring = isEnd ?
989
+ normalize(nodeText.substring(0, normalizedOffset)) :
990
+ normalize(nodeText.substring(normalizedOffset));
991
+
992
+ const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
993
+ while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
994
+ if (nodeSubstring.length === normalizedSubstring.length) {
995
+ return {node, offset: nodeOffset};
936
996
  }
937
- denormalizedOffset += direction;
997
+ nodeOffset += direction;
938
998
 
939
- candidateSubstring = isEnd ?
940
- node.textContent.substring(0, denormalizedOffset) :
941
- node.textContent.substring(denormalizedOffset);
999
+ nodeSubstring = isEnd ?
1000
+ normalize(nodeText.substring(0, nodeOffset)) :
1001
+ normalize(nodeText.substring(nodeOffset));
942
1002
  }
943
1003
  }
944
- counted += normalizedData.length;
945
-
946
- if (i + 1 < nodes.length) {
947
- const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
948
- // Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
949
- // 1st case explicitly check the node class to prevent double counted spaces
950
- // 2nd case can happen from node traversal when loading from localStorage
951
- if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
952
- counted -= 1;
953
- } else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
954
- counted -= 1;
955
- }
956
- normalizedData = nextNormalizedData;
957
- }
1004
+ normalizedI += normalizedText.length;
958
1005
  }
959
1006
  return undefined;
960
1007
  }
961
1008
 
1009
+ /**
1010
+ * @param {Range} range
1011
+ */
1012
+ export function textLayerRangeToString(range) {
1013
+ const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
1014
+ let result = textNodes.map(node => node.textContent).join('');
1015
+ const firstTextNode = textNodes[0];
1016
+ const lastTextNode = textNodes[textNodes.length - 1];
1017
+ if (range.startContainer === firstTextNode) {
1018
+ result = result.substring(range.startOffset);
1019
+ }
1020
+ if (range.endContainer === lastTextNode) {
1021
+ result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
1022
+ }
1023
+ return result;
1024
+ }
1025
+
962
1026
  /**
963
1027
  * Takes a text quote object and a container element, and wraps the quote
964
1028
  * within the container element in a span to apply highlight-like styling
@@ -979,37 +1043,29 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
979
1043
  const wholePageRange = new Range();
980
1044
  wholePageRange.setStart(firstPageNode, 0);
981
1045
  wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
982
- const normalize = replaceWhitespace;
983
1046
 
984
- // Retrieve the text nodes and relevant whitespace elements
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
986
- const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
987
- pageWordNodes.splice(0, 1);
1047
+ const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
988
1048
 
989
1049
  const broadRanges = findRangeForRegExp(
990
- textFragment.toRegExp({ normalize, context: true }),
1050
+ textFragment.toRegExp({ context: true }),
991
1051
  wholePageRange,
992
1052
  pageWordNodes,
993
- { normalize },
1053
+ { normalize: replaceTextFragmentDelimiters },
994
1054
  );
995
1055
  if (!broadRanges) {
996
1056
  console.warn("Could not find quote with context in page");
997
1057
  return;
998
1058
  }
999
1059
 
1000
- const broadRangeWordNodes = [];
1001
- for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
1002
- if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
1003
- broadRangeWordNodes.push(el);
1004
- }
1005
- }
1060
+ const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
1006
1061
 
1007
1062
  // At which point the quote should now be unambiguous!
1063
+ // FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
1008
1064
  const exactRanges = findRangeForRegExp(
1009
- textFragment.toRegExp({ normalize, context: false }),
1065
+ textFragment.toRegExp({ context: false }),
1010
1066
  broadRanges[0],
1011
1067
  broadRangeWordNodes,
1012
- { normalize },
1068
+ { normalize: replaceTextFragmentDelimiters },
1013
1069
  );
1014
1070
  if (!exactRanges) {
1015
1071
  throw new Error("Could not find quote in page");
@@ -1037,6 +1093,14 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
1037
1093
  });
1038
1094
  }
1039
1095
 
1096
+ /**
1097
+ * @param {Text} node
1098
+ */
1099
+ export function isBRVisibleTextNode(node) {
1100
+ // Exclude the duplicated spaces between words for MS Edge
1101
+ return !node.previousElementSibling?.classList.contains("BRspace");
1102
+ }
1103
+
1040
1104
  /**
1041
1105
  * Given a Range, wraps its text contents in one or more <mark> elements.
1042
1106
  * <mark> elements can't cross block boundaries, so this function walks the
@@ -1222,39 +1286,33 @@ export class BookReaderTextFragment {
1222
1286
  });
1223
1287
  }
1224
1288
 
1225
- /**
1226
- * Extract and parse a text fragment from a URL string containing a `text=` parameter.
1227
- * @param {string} urlString
1228
- * @param {import('@/src/BookReader/BookModel.js').BookModel} book
1229
- * @param {number} fallbackPageIndex A fallback page index to use if the text
1230
- * fragment does not specify a page number or page index.
1231
- * @returns {BookReaderTextFragment|null}
1232
- */
1233
- static fromUrl(urlString, book, fallbackPageIndex) {
1234
- // Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
1235
- // commas and colons, so need to do a regex match to extract the text fragment string
1236
- const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
1237
- if (!textMatch) return null;
1238
- return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
1239
- }
1240
1289
 
1241
1290
  /**
1242
1291
  * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1243
1292
  * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1244
- * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1245
- * Note the ':' and ',' separators must not and are not encoded, but
1293
+ *
1294
+ * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1295
+ * Note the ':' and ',' separators must not and are not encoded, but
1246
1296
  * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1297
+ *
1247
1298
  * @returns {string}
1248
1299
  */
1249
1300
  toUrlString() {
1301
+ // When constructing to url, we can collapse the whitespace since the corresponding
1302
+ // toRegExp method is resilient to multiple whitespace characters.
1303
+ /** @param {string} s */
1304
+ const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
1305
+
1250
1306
  // First the page number or index
1251
- let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
1307
+ let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
1308
+ str += ':';
1252
1309
 
1253
1310
  if (this.prefix) {
1254
- str += `${encodeURIComponent(this.prefix)}-,`;
1311
+ str += normalize(this.prefix);
1312
+ str += '-,';
1255
1313
  }
1256
1314
 
1257
- const quote = this.quote?.trim() || null;
1315
+ const quote = this.quote ? normalize(this.quote) : null;
1258
1316
  let shortenedQuoteParts = null;
1259
1317
  if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
1260
1318
  const words = quote.match(/\S+/g) || [];
@@ -1268,27 +1326,33 @@ export class BookReaderTextFragment {
1268
1326
  }
1269
1327
 
1270
1328
  if (quote && !shortenedQuoteParts) {
1271
- str += encodeURIComponent(quote);
1329
+ str += quote;
1272
1330
  } else if (shortenedQuoteParts) {
1273
- str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
1331
+ str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
1274
1332
  } else if (this.quoteStart && this.quoteEnd) {
1275
- str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
1333
+ str += `${this.quoteStart},${this.quoteEnd}`;
1276
1334
  } else {
1277
1335
  throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
1278
1336
  }
1279
1337
 
1280
1338
  if (this.suffix) {
1281
- str += `,-${encodeURIComponent(this.suffix)}`;
1339
+ str += ',-';
1340
+ str += normalize(this.suffix);
1282
1341
  }
1283
- return str;
1342
+
1343
+ return encodeURIComponent(str)
1344
+ // Bring back the delimiters so it's easier to read, but note not necessary.
1345
+ // Note hyphens are not encoded by encodeURIComponent, so no need to replace them
1346
+ .replace(/%2C/g, ',')
1347
+ .replace(/%3A/g, ':');
1284
1348
  }
1285
1349
 
1286
1350
  /**
1287
1351
  * Build a regex that matches this quote payload.
1288
- * @param {{ normalize?: function(string): string, context?: boolean }} [options]
1352
+ * @param {{ context?: boolean }} [options]
1289
1353
  * @returns {RegExp}
1290
1354
  */
1291
- toRegExp({ normalize = (s) => s, context = false } = {}) {
1355
+ toRegExp({ context = false } = {}) {
1292
1356
  /** @type {[String] | [String, String]} */
1293
1357
  const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
1294
1358
 
@@ -1297,12 +1361,16 @@ export class BookReaderTextFragment {
1297
1361
  if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
1298
1362
  }
1299
1363
 
1364
+ // Make it resilient to extra whitespace
1365
+ /** @param {String} quote */
1366
+ const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
1367
+
1300
1368
  if (quotes.length === 1) {
1301
- return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
1369
+ return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
1302
1370
  } else {
1303
1371
  return new RegExp(
1304
- RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
1305
- 'g',
1372
+ quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
1373
+ 'gi',
1306
1374
  );
1307
1375
  }
1308
1376
  }
@@ -1356,11 +1424,11 @@ export class BookReaderTextFragment {
1356
1424
 
1357
1425
  const CONTEXT_WORD_COUNT = 3;
1358
1426
 
1359
- const preStartText = replaceWhitespace(preStartRange.toString());
1427
+ const preStartText = textLayerRangeToString(preStartRange);
1360
1428
  let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
1361
1429
  let prefixWords = countWords(prefix);
1362
1430
 
1363
- const postEndText = replaceWhitespace(postEndRange.toString());
1431
+ const postEndText = textLayerRangeToString(postEndRange);
1364
1432
  let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
1365
1433
  let suffixWords = countWords(suffix);
1366
1434
 
@@ -1376,8 +1444,7 @@ export class BookReaderTextFragment {
1376
1444
  prefixWords = countWords(prefix);
1377
1445
  }
1378
1446
 
1379
- // Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
1380
- const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
1447
+ const quote = textLayerRangeToString(fullQuoteRange);
1381
1448
  const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1382
1449
 
1383
1450
  return new BookReaderTextFragment({