@internetarchive/bookreader 5.0.0-113 → 5.0.0-114

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@internetarchive/bookreader",
3
- "version": "5.0.0-113",
3
+ "version": "5.0.0-114",
4
4
  "description": "The Internet Archive BookReader.",
5
5
  "type": "module",
6
6
  "files": [
@@ -149,6 +149,7 @@
149
149
  display: none;
150
150
  border-radius: 20px;
151
151
  background-color: #333;
152
+ color: white;
152
153
  color-scheme: dark;
153
154
  padding: 2px;
154
155
  overflow: hidden;
@@ -210,9 +211,14 @@
210
211
  color: transparent;
211
212
  }
212
213
 
213
- // Style URI TextFragments, eg #:~:text=example
214
+ @keyframes highlightTargetFlash {
215
+ to { background-color: hsla(45, 80%, 66%, 1); }
216
+ }
217
+
218
+ // Style URI TextFragments, eg ?text=example
214
219
  .BRtextLayer .BRhighlight--target-text, .BRtextLayer ::target-text {
215
220
  // Similar colour to the default one used in Safari, Firefox. Note Chrome uses a purple colour
216
221
  background-color: hsla(45, 80%, 66%, 0.6);
217
222
  color: transparent;
223
+ animation: highlightTargetFlash 600ms 1 reverse;
218
224
  }
@@ -302,8 +302,11 @@ export class IaBookReader extends LitElement {
302
302
  providers.bookmarks = new BookmarksProvider({
303
303
  ...this.baseProviderConfig,
304
304
  onProviderChange: (bookmarks) => {
305
- const method = Object.keys(bookmarks).length ? 'add' : 'remove';
306
- this[`${method}MenuShortcut`]('bookmarks');
305
+ if (Object.keys(bookmarks).length) {
306
+ this.addMenuShortcut('bookmarks');
307
+ } else {
308
+ this.removeMenuShortcut('bookmarks');
309
+ }
307
310
  this.updateMenuContents();
308
311
  },
309
312
  });
@@ -463,6 +466,14 @@ export class IaBookReader extends LitElement {
463
466
  this._sortMenuShortcuts();
464
467
  }
465
468
 
469
+ /**
470
+ * @param {string} menuId
471
+ */
472
+ removeMenuShortcut(menuId) {
473
+ this.menuShortcuts = this.menuShortcuts.filter((m) => m.id !== menuId);
474
+ this._sortMenuShortcuts();
475
+ }
476
+
466
477
  /**
467
478
  * Sorts the menuShortcuts property by comparing each provider's id to
468
479
  * the id in each iteration over the shortcutOrder array.
@@ -52,8 +52,11 @@ export class ExperimentsPlugin extends BookReaderPlugin {
52
52
  /** Where the state of this plugin is saved in localStorage */
53
53
  localStorageKey: 'BrExperiments',
54
54
 
55
- /** The experiments that should be shown in the experiments panel */
56
- enabledExperiments: ['translate', 'copyLinkToHighlight'],
55
+ /** @type {ExperimentName[]} Experiments shown in the experiments panel */
56
+ availableExperiments: ['translate', 'copyLinkToHighlight'],
57
+
58
+ /** @type {ExperimentName[]} Experiments enabled by default */
59
+ autoEnabledExperiments: [],
57
60
  }
58
61
 
59
62
  /** @type {ExperimentModel[]} */
@@ -160,7 +163,14 @@ export class ExperimentsPlugin extends BookReaderPlugin {
160
163
  if (experiment.icon) {
161
164
  experiment.icon = experiment.buildAssetPath(experiment.icon);
162
165
  }
166
+
163
167
  experiment.br = this.br;
168
+
169
+ // Enable any experiments that should be automatically enabled
170
+ if (!experiment.enabled && this.options.autoEnabledExperiments.includes(experiment.name)) {
171
+ experiment.enabled = true;
172
+ await experiment.enable({ manual: false });
173
+ }
164
174
  }
165
175
 
166
176
  this._loadExperimentStates();
@@ -222,7 +232,7 @@ export class ExperimentsPlugin extends BookReaderPlugin {
222
232
  `,
223
233
  label: 'Experiments',
224
234
  component: html`<br-experiments-panel
225
- .experiments="${this.allExperiments.filter(experiment => this.options.enabledExperiments.includes(experiment.name))}"
235
+ .experiments="${this.allExperiments.filter(experiment => this.options.availableExperiments.includes(experiment.name))}"
226
236
  @connected="${e => this._panel = e.target}"
227
237
  @toggle="${async e => {
228
238
  await this._toggleExperiment(e.detail.experiment, e.detail.enabled);
@@ -36,6 +36,8 @@ export class TextSelectionPlugin extends BookReaderPlugin {
36
36
  */
37
37
  maxWordRendered = 2500;
38
38
 
39
+ _jumpedToHighlight = false;
40
+
39
41
  /**
40
42
  * @param {import('../BookReader.js').default} br
41
43
  */
@@ -71,7 +73,12 @@ export class TextSelectionPlugin extends BookReaderPlugin {
71
73
  const pageIndex = targetTextFragment.pageIndex;
72
74
  const hasTargetText = pageIndex === parseFloat(pageContainerEl.getAttribute('data-index'));
73
75
  if (hasTargetText) {
74
- renderHighlight(textLayer, targetTextFragment, 'BRhighlight--target-text');
76
+ const markEls = renderHighlight(textLayer, targetTextFragment, 'BRhighlight--target-text');
77
+ // Only jump once; presumably on first page load.
78
+ if (!this._jumpedToHighlight) {
79
+ markEls[0].scrollIntoView({behavior: 'smooth', block: 'center'});
80
+ this._jumpedToHighlight = true;
81
+ }
75
82
  }
76
83
  });
77
84
  }
@@ -2,11 +2,11 @@
2
2
 
3
3
  import FestivalTTSEngine from './FestivalTTSEngine.js';
4
4
  import WebTTSEngine from './WebTTSEngine.js';
5
- import { toISO6391, approximateWordCount } from './utils.js';
5
+ import { toISO6391 } from './utils.js';
6
6
  import { en as tooltips } from './tooltip_dict.js';
7
7
  import { renderBoxesInPageContainerLayer } from '../../BookReader/PageContainer.js';
8
8
  import { BookReaderPlugin } from '../../BookReaderPlugin.js';
9
- import { applyVariables } from '../../util/strings.js';
9
+ import { applyVariables, countWords } from '../../util/strings.js';
10
10
  /** @typedef {import('./PageChunk.js').default} PageChunk */
11
11
  /** @typedef {import("./AbstractTTSEngine.js").default} AbstractTTSEngine */
12
12
 
@@ -291,7 +291,7 @@ export class TtsPlugin extends BookReaderPlugin {
291
291
  * @param {PageChunk} chunk
292
292
  */
293
293
  sendChunkFinishedAnalyticsEvent(chunk) {
294
- this.sendAnalyticsEvent('ChunkFinished-Words', approximateWordCount(chunk.text));
294
+ this.sendAnalyticsEvent('ChunkFinished-Words', countWords(chunk.text));
295
295
  }
296
296
 
297
297
  /**
@@ -1,16 +1,6 @@
1
1
  // @ts-check
2
2
  import langs from 'iso-language-codes';
3
3
 
4
- /**
5
- * Use regex to approximate word count in a string
6
- * @param {string} text
7
- * @return {number}
8
- */
9
- export function approximateWordCount(text) {
10
- const m = text.match(/\S+/g);
11
- return m ? m.length : 0;
12
- }
13
-
14
4
  /**
15
5
  * Checks whether the current browser is on android
16
6
  * @param {string} [userAgent]
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { countWords } from './strings.js';
2
3
  import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
3
4
  import { html, LitElement } from 'lit';
4
5
  import { customElement, property, query } from 'lit/decorators.js';
@@ -7,6 +8,8 @@ import '@internetarchive/icon-share';
7
8
  import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
8
9
 
9
10
  const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
11
+ const MAX_FULL_QUOTE_URL_CHARS = 80;
12
+ const TRUNCATED_QUOTE_WORD_COUNT = 3;
10
13
 
11
14
  export class TextSelectionManager {
12
15
  options = {
@@ -555,8 +558,14 @@ class BRSelectMenu extends LitElement {
555
558
  async handleCopyLinkToHighlight(e) {
556
559
  e.preventDefault();
557
560
  const currentParams = this.br.readQueryString();
558
- const currentSelection = window.getSelection();
559
- const textFragment = BookReaderTextFragment.fromSelection(currentSelection, this.br.$('.BRpage-visible').toArray());
561
+ const currentSelection = /** @type {Selection} */ (window.getSelection());
562
+ const range = currentSelection.getRangeAt(0);
563
+ const textLayer = this.getNodeTextLayer(range.startContainer);
564
+ if (!textLayer) {
565
+ console.warn("No text layer found for selection");
566
+ return;
567
+ }
568
+ const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
560
569
 
561
570
  // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
562
571
  let linkToHighlightParams = currentParams;
@@ -579,11 +588,11 @@ class BRSelectMenu extends LitElement {
579
588
  /**
580
589
  * Returns the closest BRtextLayer element on the page that contains the target node
581
590
  * @param {Node} node
582
- * @returns {HTMLElement | null}
591
+ * @returns {Element | null}
583
592
  */
584
593
  getNodeTextLayer(node) {
585
- if (!node) return;
586
- const element = 'closest' in node ? node : node.parentElement;
594
+ if (!node) return null;
595
+ const element = node instanceof Element ? node : node.parentElement;
587
596
  return element?.closest('.BRtextLayer') ?? null;
588
597
  }
589
598
 
@@ -788,53 +797,62 @@ function replaceWhitespace(string) {
788
797
  }
789
798
 
790
799
  /**
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
800
+ * Finds all regex matches within a range and returns their ranges.
801
+ * @param {RegExp} regex
793
802
  * @param {Range} range - the range to search in
794
803
  * @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
804
+ * @param {{ normalize?: function(string): string }} [options]
805
+ * @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
799
806
  */
800
- export function findRangeForQuote(quote, range, textNodes) {
807
+ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
808
+ if (!textNodes.length) return undefined;
809
+
801
810
  const startOffset = textNodes[0] === range.startContainer ?
802
811
  range.startOffset :
803
812
  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;
813
+ const normalizedWholePageString = normalize(range.toString());
814
+ const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
815
+ const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
816
+
817
+ const matches = Array.from(normalizedWholePageString.matchAll(matchRegex));
818
+ const resultRanges = matches.map((match) => {
819
+ const start = getBoundaryPointAtIndex(
820
+ normalizedStartOffset + match.index,
821
+ textNodes,
822
+ false,
823
+ { normalize },
824
+ );
825
+ const end = getBoundaryPointAtIndex(
826
+ normalizedStartOffset + match.index + match[0].length,
827
+ textNodes,
828
+ true,
829
+ { normalize },
830
+ );
831
+
832
+ if (!start || !end) {
833
+ console.warn("Could not find boundary points for regex match, skipping this match", {match, start, end});
834
+ return undefined;
824
835
  }
825
- searchStart = matchedIndex + 1;
826
- }
827
- return undefined;
828
- }
829
836
 
837
+ const foundRange = new Range();
838
+ foundRange.setStart(start.node, 0);
839
+ foundRange.setEnd(end.node, 1);
840
+ return foundRange;
841
+ });
842
+
843
+ const definedRanges = resultRanges.filter((matchRange) => !!matchRange);
844
+ if (!definedRanges?.length) return undefined;
845
+ return definedRanges;
846
+ }
830
847
 
831
848
  /**
832
849
  * Uses the index that matches the quote string and normalizes the string contents to find the correct node
833
850
  * @param {Number} index
834
851
  * @param {Node[]} nodes
835
852
  * @param {boolean} isEnd
853
+ * @param {{ normalize?: function(string): string }} [options]
836
854
  */
837
- export function getBoundaryPointAtIndex(index, nodes, isEnd) {
855
+ export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
838
856
  let counted = 0;
839
857
  let normalizedData;
840
858
  for (let i = 0; i < nodes.length; i++) {
@@ -843,7 +861,7 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
843
861
  // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
844
862
  normalizedData = ' ';
845
863
  } else {
846
- if (!normalizedData) normalizedData = replaceWhitespace(node.textContent);
864
+ if (!normalizedData) normalizedData = normalize(node.textContent);
847
865
  }
848
866
  let nodeEnd = counted + normalizedData.length;
849
867
  if (isEnd) nodeEnd += 1;
@@ -856,8 +874,8 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
856
874
  normalizedData.substring(normalizedOffset);
857
875
 
858
876
  let candidateSubstring = isEnd ?
859
- replaceWhitespace(node.textContent.substring(0, normalizedOffset)) :
860
- replaceWhitespace(node.textContent.substring(normalizedOffset));
877
+ normalize(node.textContent.substring(0, normalizedOffset)) :
878
+ normalize(node.textContent.substring(normalizedOffset));
861
879
 
862
880
  const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
863
881
  while (denormalizedOffset >= 0 &&
@@ -899,52 +917,54 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
899
917
  * https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
900
918
  *
901
919
  * @param {HTMLElement} textLayer
902
- * @param {BookReaderTextFragment} quote
920
+ * @param {BookReaderTextFragment} textFragment
903
921
  * @param {string | null} cssClassName optional css class to add to the highlight span element
922
+ * @return {Element[]} the elements that were created to highlight the text
904
923
  */
905
- export function renderHighlight(textLayer, quote, cssClassName = null) {
924
+ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
906
925
  // Create a range that encompasses the entire text content
907
926
  const firstPageNode = getFirstMostNode(textLayer);
908
927
  const lastPageNode = getLastMostNode(textLayer);
909
928
  const wholePageRange = new Range();
910
929
  wholePageRange.setStart(firstPageNode, 0);
911
930
  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);
931
+ const normalize = replaceWhitespace;
923
932
 
924
933
  // Retrieve the text nodes and relevant whitespace elements
925
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
926
935
  const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
927
936
  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);
937
+
938
+ const broadRanges = findRangeForRegExp(
939
+ textFragment.toRegExp({ normalize, context: true }),
940
+ wholePageRange,
941
+ pageWordNodes,
942
+ { normalize },
943
+ );
944
+ if (!broadRanges) {
945
+ console.warn("Could not find quote with context in page");
931
946
  return;
932
947
  }
933
948
 
934
949
  const broadRangeWordNodes = [];
935
- for (const el of walkBetweenNodes(broadRange?.startContainer, broadRange?.endContainer)) {
950
+ for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
936
951
  if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
937
952
  broadRangeWordNodes.push(el);
938
953
  }
939
954
  }
940
955
 
941
956
  // At which point the quote should now be unambiguous!
942
- const exactRange = findRangeForQuote(quote.quote, broadRange, broadRangeWordNodes);
943
- if (!exactRange) {
957
+ const exactRanges = findRangeForRegExp(
958
+ textFragment.toRegExp({ normalize, context: false }),
959
+ broadRanges[0],
960
+ broadRangeWordNodes,
961
+ { normalize },
962
+ );
963
+ if (!exactRanges) {
944
964
  throw new Error("Could not find quote in page");
945
965
  }
946
- const startTextNode = getFirstMostNode(exactRange.startContainer);
947
- const endTextNode = getFirstMostNode(exactRange.endContainer);
966
+ const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
967
+ const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
948
968
 
949
969
  // markRange requires the range to start and end within text nodes
950
970
  const exactRangeTextNodes = new Range();
@@ -957,11 +977,11 @@ export function renderHighlight(textLayer, quote, cssClassName = null) {
957
977
  return;
958
978
  }
959
979
 
960
- markRange(exactRangeTextNodes, () => {
980
+ return markRange(exactRangeTextNodes, () => {
961
981
  const mark = document.createElement("mark");
962
982
  mark.classList.add("BRhighlight");
963
983
  if (cssClassName) mark.classList.add(cssClassName);
964
- if (quote.uuid) mark.classList.add(quote.uuid);
984
+ if (textFragment.uuid) mark.classList.add(textFragment.uuid);
965
985
  return mark;
966
986
  });
967
987
  }
@@ -1062,24 +1082,31 @@ function retrieveUUID(ele) {
1062
1082
  * See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
1063
1083
  *
1064
1084
  * Text fragment string format: `pageNumber:prefix-,quote,-suffix`
1085
+ * Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
1065
1086
  * Note the ':' and ',' separators must not be encoded, but
1066
- * the pageNumber, prefix, quote, and suffix text can be encoded.
1087
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
1067
1088
  */
1068
1089
  export class BookReaderTextFragment {
1069
1090
  /**
1070
1091
  * @param {object} params
1071
1092
  * @param {string | null}params.prefix
1072
- * @param {string} params.quote
1093
+ * @param {string | null} [params.quote]
1094
+ * @param {string | null} [params.quoteStart]
1095
+ * @param {string | null} [params.quoteEnd]
1073
1096
  * @param {string | null} params.suffix
1074
1097
  * @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
1075
1098
  * @param {number} params.pageIndex Page index; e.g. zero-based index of the page
1076
1099
  * @param {string | null} [params.uuid] UUID for the text fragment if it has one
1077
1100
  */
1078
- constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
1101
+ constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
1079
1102
  /** @type {string|null} */
1080
1103
  this.prefix = prefix;
1081
- /** @type {string} */
1082
- this.quote = quote;
1104
+ /** @type {string | null} */
1105
+ this.quote = quote ?? null;
1106
+ /** @type {string | null} */
1107
+ this.quoteStart = quoteStart ?? null;
1108
+ /** @type {string | null} */
1109
+ this.quoteEnd = quoteEnd ?? null;
1083
1110
  /** @type {string|null} */
1084
1111
  this.suffix = suffix;
1085
1112
  /** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
@@ -1108,14 +1135,40 @@ export class BookReaderTextFragment {
1108
1135
  }
1109
1136
  const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
1110
1137
  const prefix = match[2] ? decodeURIComponent(match[2]) : null;
1111
- const quote = decodeURIComponent(match[3]);
1138
+ const quoteRegion = match[3] || '';
1139
+ const quoteParts = quoteRegion.split(',');
1140
+ let quote = null;
1141
+ let quoteStart = null;
1142
+ let quoteEnd = null;
1143
+
1144
+ if (quoteParts.length === 1) {
1145
+ quote = decodeURIComponent(quoteParts[0]);
1146
+ } else if (quoteParts.length === 2) {
1147
+ quoteStart = decodeURIComponent(quoteParts[0]);
1148
+ quoteEnd = decodeURIComponent(quoteParts[1]);
1149
+ } else {
1150
+ throw new Error(`Invalid text fragment quote format: ${str}`);
1151
+ }
1152
+
1112
1153
  const suffix = match[4] ? decodeURIComponent(match[4]) : null;
1113
1154
  const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
1114
1155
  if (typeof pageIndex !== 'number') {
1115
1156
  throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
1116
1157
  }
1117
1158
 
1118
- return new BookReaderTextFragment({ prefix, quote, suffix, pageNumber, pageIndex });
1159
+ if (!quote && (!quoteStart || !quoteEnd)) {
1160
+ throw new Error(`Invalid text fragment quote format: ${str}`);
1161
+ }
1162
+
1163
+ return new BookReaderTextFragment({
1164
+ prefix,
1165
+ quote,
1166
+ quoteStart,
1167
+ quoteEnd,
1168
+ suffix,
1169
+ pageNumber,
1170
+ pageIndex,
1171
+ });
1119
1172
  }
1120
1173
 
1121
1174
  /**
@@ -1137,8 +1190,9 @@ export class BookReaderTextFragment {
1137
1190
  /**
1138
1191
  * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1139
1192
  * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1193
+ * If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
1140
1194
  * Note the ':' and ',' separators must not and are not encoded, but
1141
- * the pageNumber, prefix, quote, and suffix text are encoded.
1195
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1142
1196
  * @returns {string}
1143
1197
  */
1144
1198
  toUrlString() {
@@ -1148,17 +1202,66 @@ export class BookReaderTextFragment {
1148
1202
  if (this.prefix) {
1149
1203
  str += `${encodeURIComponent(this.prefix)}-,`;
1150
1204
  }
1151
- str += encodeURIComponent(this.quote);
1205
+
1206
+ const quote = this.quote?.trim() || null;
1207
+ let shortenedQuoteParts = null;
1208
+ if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
1209
+ const words = quote.match(/\S+/g) || [];
1210
+ if (words.length >= TRUNCATED_QUOTE_WORD_COUNT * 2) {
1211
+ const quoteStart = getFirstWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
1212
+ const quoteEnd = getLastWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
1213
+ if (quoteStart && quoteEnd) {
1214
+ shortenedQuoteParts = { quoteStart, quoteEnd };
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ if (quote && !shortenedQuoteParts) {
1220
+ str += encodeURIComponent(quote);
1221
+ } else if (shortenedQuoteParts) {
1222
+ str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
1223
+ } else if (this.quoteStart && this.quoteEnd) {
1224
+ str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
1225
+ } else {
1226
+ throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
1227
+ }
1228
+
1152
1229
  if (this.suffix) {
1153
1230
  str += `,-${encodeURIComponent(this.suffix)}`;
1154
1231
  }
1155
1232
  return str;
1156
1233
  }
1157
1234
 
1235
+ /**
1236
+ * Build a regex that matches this quote payload.
1237
+ * @param {{ normalize?: function(string): string, context?: boolean }} [options]
1238
+ * @returns {RegExp}
1239
+ */
1240
+ toRegExp({ normalize = (s) => s, context = false } = {}) {
1241
+ /** @type {[String] | [String, String]} */
1242
+ const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
1243
+
1244
+ if (context) {
1245
+ if (this.prefix) quotes[0] = this.prefix + ' ' + quotes[0];
1246
+ if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
1247
+ }
1248
+
1249
+ if (quotes.length === 1) {
1250
+ return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
1251
+ } else {
1252
+ return new RegExp(
1253
+ RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
1254
+ 'g',
1255
+ );
1256
+ }
1257
+ }
1258
+
1158
1259
  toJSON() {
1159
1260
  return {
1160
1261
  prefix: this.prefix,
1161
1262
  quote: this.quote,
1263
+ quoteStart: this.quoteStart,
1264
+ quoteEnd: this.quoteEnd,
1162
1265
  suffix: this.suffix,
1163
1266
  pageNumber: this.pageNumber,
1164
1267
  pageIndex: this.pageIndex,
@@ -1178,7 +1281,7 @@ export class BookReaderTextFragment {
1178
1281
  /**
1179
1282
  * Builds a TextFragment string from a given text selection.
1180
1283
  * @param {Selection} selection currently selected text, eg `document.getSelection()`
1181
- * @param {HTMLElement[]} contextElements elements providing context for the selection
1284
+ * @param {Element[]} contextElements elements providing context for the selection
1182
1285
  * @returns {BookReaderTextFragment}
1183
1286
  */
1184
1287
  static fromSelection(selection, contextElements) {
@@ -1200,18 +1303,30 @@ export class BookReaderTextFragment {
1200
1303
  const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
1201
1304
  postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
1202
1305
 
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, "");
1306
+ const CONTEXT_WORD_COUNT = 3;
1307
+
1308
+ const preStartText = replaceWhitespace(preStartRange.toString());
1309
+ let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
1310
+ let prefixWords = countWords(prefix);
1311
+
1312
+ const postEndText = replaceWhitespace(postEndRange.toString());
1313
+ let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
1314
+ let suffixWords = countWords(suffix);
1315
+
1316
+ if (prefixWords < CONTEXT_WORD_COUNT) {
1317
+ // Ran out of words! To reduce the risk of ambiguity, try extending suffix
1318
+ suffix = getFirstWords(2 * CONTEXT_WORD_COUNT - prefixWords, postEndText);
1319
+ suffixWords = countWords(suffix);
1320
+ }
1321
+
1322
+ if (suffixWords < CONTEXT_WORD_COUNT) {
1323
+ // Ran out of words on the suffix side as well, try extending prefix
1324
+ prefix = getLastWords(2 * CONTEXT_WORD_COUNT - suffixWords, preStartText);
1325
+ prefixWords = countWords(prefix);
1326
+ }
1212
1327
 
1213
1328
  // 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();
1329
+ const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
1215
1330
  const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1216
1331
 
1217
1332
  return new BookReaderTextFragment({
@@ -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