@internetarchive/bookreader 5.0.0-112 → 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.
Files changed (36) hide show
  1. package/BookReader/BookReader.css +20 -7
  2. package/BookReader/BookReader.js +43 -1
  3. package/BookReader/BookReader.js.map +1 -1
  4. package/BookReader/ia-bookreader-bundle.js +3 -45
  5. package/BookReader/ia-bookreader-bundle.js.map +1 -1
  6. package/BookReader/plugins/plugin.chapters.js +2 -2
  7. package/BookReader/plugins/plugin.chapters.js.map +1 -1
  8. package/BookReader/plugins/plugin.experiments.js +1 -1
  9. package/BookReader/plugins/plugin.experiments.js.map +1 -1
  10. package/BookReader/plugins/plugin.search.js +1 -1
  11. package/BookReader/plugins/plugin.search.js.map +1 -1
  12. package/BookReader/plugins/plugin.text_selection.js +1 -23
  13. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  14. package/BookReader/plugins/plugin.translate.js +3 -25
  15. package/BookReader/plugins/plugin.translate.js.map +1 -1
  16. package/BookReader/plugins/plugin.tts.js +1 -1
  17. package/BookReader/plugins/plugin.tts.js.map +1 -1
  18. package/BookReader/plugins/plugin.url.js +1 -2
  19. package/BookReader/plugins/plugin.url.js.map +1 -1
  20. package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -1
  21. package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/BookReader/utils/SelectionObserver.js +2 -2
  24. package/src/BookReader.js +5 -4
  25. package/src/css/_BRpages.scss +5 -2
  26. package/src/css/_TextSelection.scss +21 -9
  27. package/src/ia-bookreader/ia-bookreader.js +13 -2
  28. package/src/plugins/plugin.experiments.js +44 -8
  29. package/src/plugins/plugin.text_selection.js +26 -56
  30. package/src/plugins/tts/plugin.tts.js +3 -3
  31. package/src/plugins/tts/utils.js +0 -10
  32. package/src/plugins/url/UrlPlugin.js +2 -15
  33. package/src/plugins/url/plugin.url.js +7 -59
  34. package/src/util/TextSelectionManager.js +950 -138
  35. package/src/util/strings.js +10 -0
  36. package/BookReader/plugins/plugin.url.js.LICENSE.txt +0 -1
@@ -1,8 +1,15 @@
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
- import { customElement } from 'lit/decorators.js';
5
+ import { customElement, property, query } from 'lit/decorators.js';
6
+ import { ifDefined } from 'lit/directives/if-defined.js';
5
7
  import '@internetarchive/icon-share';
8
+ import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
9
+
10
+ const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
11
+ const MAX_FULL_QUOTE_URL_CHARS = 80;
12
+ const TRUNCATED_QUOTE_WORD_COUNT = 3;
6
13
 
7
14
  export class TextSelectionManager {
8
15
  options = {
@@ -12,8 +19,10 @@ export class TextSelectionManager {
12
19
 
13
20
  /** @type {BRSelectMenu} */
14
21
  selectMenu;
15
- /** @type {boolean} */
16
- selectionMenuEnabled = false;
22
+
23
+ get selectMenuEnabled() {
24
+ return this.br.plugins.experiments?.isEnabled('copyLinkToHighlight') || this.br.plugins.experiments?.isEnabled('annotateHighlight');
25
+ }
17
26
 
18
27
  /**
19
28
  * @param {string} layer Selector for the text layer to manage
@@ -46,21 +55,21 @@ export class TextSelectionManager {
46
55
  // Set a class on the page to avoid hiding it when zooming/etc
47
56
  this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
48
57
  $(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
49
- this.selectMenu.showMenu();
58
+ this.showSelectMenu();
50
59
 
51
60
  }
52
61
 
53
- if (selectEvent == 'focusChanged') {
62
+ if (selectEvent == 'changed') {
54
63
  // hide the button as user changes their selection
55
64
  if (this.mouseIsDown) {
56
- this.selectMenu.hideMenu();
57
- } else if (window.getSelection().toString()) {
58
- this.selectMenu.showMenu();
65
+ this.hideSelectMenu();
66
+ } else if (window.getSelection()?.toString()) {
67
+ this.showSelectMenu();
59
68
  }
60
69
  }
61
70
 
62
71
  if (selectEvent == 'cleared') {
63
- this.selectMenu.hideMenu();
72
+ this.hideSelectMenu();
64
73
  }
65
74
  }).attach();
66
75
  }
@@ -68,9 +77,7 @@ export class TextSelectionManager {
68
77
  // Need attach + detach methods to toggle w/ Translation plugin
69
78
  attach() {
70
79
  this.selectionObserver.attach();
71
- if (this.selectionMenuEnabled) {
72
- this.renderSelectionMenu();
73
- }
80
+
74
81
  if (this.br.protected) {
75
82
  document.addEventListener('selectionchange', this._limitSelection);
76
83
  // Prevent right clicking when selected text
@@ -96,12 +103,8 @@ export class TextSelectionManager {
96
103
  }
97
104
  }
98
105
 
99
- renderSelectionMenu() {
100
- if (document.querySelector('.br-select-menu__option')) return;
101
- document.body.append(this.selectMenu);
102
- }
103
106
  /**
104
- * @param {'started' | 'cleared' | 'focusChanged'} type
107
+ * @param {'started' | 'cleared' | 'changed'} type
105
108
  * @param {HTMLElement} target
106
109
  */
107
110
  _onSelectionChange = (type, target) => {
@@ -109,7 +112,7 @@ export class TextSelectionManager {
109
112
  this.textSelectingMode(target);
110
113
  } else if (type === 'cleared') {
111
114
  this.defaultMode(target);
112
- } else if (type === 'focusChanged') {
115
+ } else if (type === 'changed') {
113
116
  // do nothing, just wait for the mouseup to trigger the styling change
114
117
  } else {
115
118
  throw new Error(`Unknown type ${type}`);
@@ -162,7 +165,7 @@ export class TextSelectionManager {
162
165
  // blocking selection
163
166
  $(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
164
167
  this.mouseIsDown = true;
165
- this.selectMenu.hideMenu();
168
+ this.hideSelectMenu();
166
169
  if ($(event.target).is(this.selectionElement.join(", "))) {
167
170
  event.stopPropagation();
168
171
  }
@@ -170,7 +173,7 @@ export class TextSelectionManager {
170
173
 
171
174
  $(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
172
175
  this.mouseIsDown = false;
173
- this.selectMenu.hideMenu();
176
+ this.hideSelectMenu();
174
177
  textLayer.style.pointerEvents = "none";
175
178
  if (skipNextMouseup) {
176
179
  skipNextMouseup = false;
@@ -196,7 +199,7 @@ export class TextSelectionManager {
196
199
  if (event.which != 1) return;
197
200
  this.mouseIsDown = true;
198
201
  event.stopPropagation();
199
- this.selectMenu.hideMenu();
202
+ this.hideSelectMenu();
200
203
  });
201
204
 
202
205
  // Prevent page flip on click
@@ -204,10 +207,27 @@ export class TextSelectionManager {
204
207
  this.mouseIsDown = false;
205
208
  if (event.which != 1) return;
206
209
  event.stopPropagation();
207
- this.selectMenu.showMenu();
210
+ this.showSelectMenu();
208
211
  });
209
212
  }
210
213
 
214
+ showSelectMenu() {
215
+ if (!this.selectMenuEnabled) return;
216
+
217
+ this.selectMenu.copyLinkToHighlightEnabled = this.br.plugins.experiments.isEnabled('copyLinkToHighlight');
218
+ this.selectMenu.highlightAnnotationEnabled = this.br.plugins.experiments.isEnabled('annotateHighlight');
219
+
220
+ if (!this.selectMenu.isConnected) {
221
+ document.body.append(this.selectMenu);
222
+ }
223
+
224
+ this.selectMenu.show();
225
+ }
226
+
227
+ hideSelectMenu() {
228
+ this.selectMenu.hide();
229
+ }
230
+
211
231
  _limitSelection = () => {
212
232
  const selection = window.getSelection();
213
233
  if (!selection.rangeCount) return;
@@ -246,98 +266,6 @@ export class TextSelectionManager {
246
266
  };
247
267
  }
248
268
 
249
- /**
250
- * Builds a TextFragment string from a given text selection.
251
- * Note does not include the fragment directive `:~:` or # symbol
252
- * See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
253
- * @param {Selection} selection currently selected text, eg `document.getSelection()`
254
- * @param {HTMLElement[]} contextElements elements providing context for the selection
255
- * @returns {string}
256
- */
257
- export function createTextFragmentUrlParam(selection, contextElements) {
258
- // TODO: Can import something that handles this more gracefully? see -
259
- // https://web.dev/articles/text-fragments#:~:text=In%20its%20simplest%20form%2C%20the%20syntax%20of,percent%2Dencoded%20text%20I%20want%20to%20link%20to.
260
-
261
- // :~:text=[prefix-,]textStart[,textEnd][,-suffix]
262
- const highlightedText = selection.toString().replace(/[\s]+/g, " ").trim().split(" ");
263
- const direction = selection.direction;
264
- const startNode = direction == 'backward' ? selection.focusNode : selection.anchorNode;
265
- const endNode = direction == 'backward' ? selection.anchorNode : selection.focusNode;
266
- // If text selection begins or ends with a space, we look for the next eligible word to serve as the start or end word
267
- const startWord = startNode.textContent.replace(/[\s]+/g, "") ? startNode.textContent : highlightedText[0];
268
- const endWord = endNode.textContent.replace(/[\s]+/g, "") ? endNode.textContent : highlightedText[highlightedText.length - 1];
269
-
270
- const textStartRe = RegExp.escape(startWord);
271
- const textEndRe = RegExp.escape(endWord);
272
-
273
- // 's' regex modifier ensures the `.` also captures newline characters
274
- // Need to use lookahead/lookbehind assertions to allow for overlapping quotes (i.e. multiple "Holmes" on the same page)
275
- const startPhraseMatchRe = new RegExp(String.raw`(?<=(${textStartRe}).*?)(${textEndRe})`, "gis");
276
- const endPhraseMatchRe = new RegExp(String.raw`(${textStartRe})(?=.*?(${textEndRe}))`, "gis");
277
-
278
- // Duplicated spaces in pageLayer.textContent for some reason
279
- const selectionContext = contextElements
280
- .map((el) => el.textContent)
281
- .join(' ')
282
- .replace(/\s+/g, " ");
283
- const startPhraseFoundMatches = selectionContext.matchAll(startPhraseMatchRe).toArray();
284
- const endPhraseFoundMatches = selectionContext.matchAll(endPhraseMatchRe).toArray();
285
- if (startPhraseFoundMatches.length == 1 && endPhraseFoundMatches.length == 1) {
286
- // If `startWord...endWord` quote is unambiguous and only occurs once, no prefix-/-suffix is needed for the URL param
287
- return `text=${encodeURIComponent(startWord)},${encodeURIComponent(endWord)}`;
288
- }
289
-
290
- // Need to add some additional context to `startWord...endWord` by including surrounding words before and after the keywords
291
- const preStartRange = document.createRange();
292
- preStartRange.setStart(contextElements[0].firstElementChild, 0);
293
- preStartRange.setEnd(startNode, 0);
294
-
295
- const postEndRange = document.createRange();
296
- postEndRange.setStart(endNode, endNode.textContent.length);
297
- const lastWordOfPageEl = getLastMostElement(contextElements[contextElements.length - 1]);
298
- postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
299
-
300
- // prefixes/suffixes cannot contain paragraph breaks, words that are from more than one line break away should not be included
301
- const prefix = getLastWords(3, preStartRange.toString())
302
- .replace(/[ ]+/g, " ")
303
- .trim()
304
- .replace(/^[^\n]*\n/gm, "");
305
- const suffix = getFirstWords(3, postEndRange.toString())
306
- .replace(/[ ]+/g, " ")
307
- .trim()
308
- .replace(/\n[^\n]*$/gm, "");
309
-
310
- // Partially selected words need to be captured completely
311
- // Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
312
- const fullHighlight = selection.toString().replace(/\s+/g, " ").trim().split(/\s/g);
313
- // Capture start/end words that may be partially highlighted
314
- if (startNode.textContent.trim().length != 0) {
315
- if (!startNode.textContent.includes(fullHighlight[0])) {
316
- fullHighlight.unshift(startNode.textContent);
317
- } else {
318
- fullHighlight[0] = startNode.textContent;
319
- }
320
- }
321
- if (endNode.textContent.trim().length != 0) {
322
- if (!endNode.textContent.includes(fullHighlight[fullHighlight.length - 1])) {
323
- fullHighlight.push(endNode.textContent);
324
- }
325
- fullHighlight[fullHighlight.length - 1] = endNode.textContent;
326
- }
327
-
328
- let quote = [fullHighlight.join(" ")];
329
- if (fullHighlight.length > 6) {
330
- quote = [fullHighlight.slice(0, 3).join(" "), fullHighlight.slice(-3).join(" ")];
331
- }
332
-
333
- const textFragmentArr = [];
334
- if (prefix) textFragmentArr.push(`${prefix}-`);
335
- textFragmentArr.push(...quote);
336
- if (suffix) textFragmentArr.push(`-${suffix}`);
337
-
338
- return `text=${textFragmentArr.map(encodeURIComponent).join(',')}`;
339
- }
340
-
341
269
  /**
342
270
  * @template T
343
271
  * Get the i-th element of an iterable
@@ -413,11 +341,133 @@ export function* walkBetweenNodes(start, end) {
413
341
  yield* walk(start);
414
342
  }
415
343
 
344
+ @customElement('br-menu-option')
345
+ export class BRSelectMenuOption extends LitElement {
346
+ @property({type: String})
347
+ icon = '';
348
+
349
+ @property({type: String})
350
+ label = '';
351
+
352
+ /** @type {string | null} */
353
+ temporaryText = null;
354
+
355
+ @property({type: Number, attribute: 'temporary-text-duration'})
356
+ temporaryTextDuration = 1200;
357
+
358
+ @property({type: String, attribute: 'aria-label'})
359
+ ariaLabel = '';
360
+
361
+ @property({type: Boolean, attribute: 'live-label'})
362
+ liveLabel = false;
363
+
364
+ @property({type: Boolean})
365
+ temporaryTextVisible = false;
366
+
367
+ /** @type {number | null} */
368
+ temporaryTextTimeoutId = null;
369
+
370
+ /** @override */
371
+ createRenderRoot() {
372
+ // Keep styles from existing stylesheet by opting out of shadow DOM
373
+ return this;
374
+ }
375
+
376
+ /** @override */
377
+ disconnectedCallback() {
378
+ if (this.temporaryTextTimeoutId != null) {
379
+ window.clearTimeout(this.temporaryTextTimeoutId);
380
+ this.temporaryTextTimeoutId = null;
381
+ }
382
+ super.disconnectedCallback();
383
+ }
384
+
385
+ /**
386
+ * @param {string} text
387
+ */
388
+ showTemporaryText(text) {
389
+ if (!text?.trim()) return;
390
+
391
+ this.temporaryText = text;
392
+
393
+ this.temporaryTextVisible = true;
394
+ if (this.temporaryTextTimeoutId != null) {
395
+ window.clearTimeout(this.temporaryTextTimeoutId);
396
+ }
397
+ this.temporaryTextTimeoutId = window.setTimeout(() => {
398
+ this.temporaryTextVisible = false;
399
+ this.temporaryText = null;
400
+ this.temporaryTextTimeoutId = null;
401
+ }, this.temporaryTextDuration);
402
+ }
403
+
404
+ renderIcon() {
405
+ if (this.icon === 'share') {
406
+ return html`<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>`;
407
+ }
408
+ if (this.icon === 'edit-pencil') {
409
+ return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
410
+ }
411
+ return '';
412
+ }
413
+
414
+ render() {
415
+ const baseLabel = this.label ?? '';
416
+ const hasTemporaryText = !!this.temporaryText;
417
+ const temporaryLabel = hasTemporaryText ? this.temporaryText : baseLabel;
418
+ const showTemporaryLabel = hasTemporaryText && this.temporaryTextVisible;
419
+ const visibleLabel = showTemporaryLabel ? temporaryLabel : baseLabel;
420
+ const providedAriaLabel = this.ariaLabel?.trim();
421
+ const accessibleLabel = providedAriaLabel && providedAriaLabel !== visibleLabel.trim() ? providedAriaLabel : undefined;
422
+ const sizingLabel = baseLabel.length >= temporaryLabel.length ? baseLabel : temporaryLabel;
423
+
424
+ return html`
425
+ <button
426
+ type="button"
427
+ class="br-select-menu__option"
428
+ role="menuitem"
429
+ aria-label=${ifDefined(accessibleLabel)}
430
+ >
431
+ ${this.renderIcon()}
432
+ ${hasTemporaryText ? html`
433
+ <span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
434
+ <span
435
+ class="br-select-menu__label"
436
+ style="visibility: hidden;"
437
+ aria-hidden="true"
438
+ >${sizingLabel}</span>
439
+ <span
440
+ class="br-select-menu__label"
441
+ style="position: absolute; inset: 0; display: inline-flex; align-items: center;"
442
+ aria-live=${this.liveLabel ? 'polite' : 'off'}
443
+ >${showTemporaryLabel ? temporaryLabel : baseLabel}</span>
444
+ </span>
445
+ ` : html`
446
+ <span class="br-select-menu__label" aria-live=${this.liveLabel ? 'polite' : 'off'}>${baseLabel}</span>
447
+ `}
448
+ </button>
449
+ `;
450
+ }
451
+ }
452
+
416
453
  @customElement('br-select-menu')
417
454
  class BRSelectMenu extends LitElement {
418
455
  /** @type {import('../BookReader.js').default} */
419
456
  br;
420
457
 
458
+ /** @type {BRSelectMenuOption | null} */
459
+ @query('#copy-link-option')
460
+ copyLinkOption;
461
+
462
+ @property({type: Boolean})
463
+ copyLinkToHighlightEnabled = true;
464
+
465
+ @property({type: Boolean})
466
+ highlightAnnotationEnabled = true;
467
+
468
+ /**
469
+ * @param {import('../BookReader.js').default} br
470
+ */
421
471
  constructor(br) {
422
472
  super();
423
473
  this.br = br;
@@ -429,46 +479,231 @@ class BRSelectMenu extends LitElement {
429
479
  return this;
430
480
  }
431
481
 
482
+ /** @override */
483
+ connectedCallback() {
484
+ super.connectedCallback();
485
+ this.setAttribute('role', 'menu');
486
+ this.setAttribute('aria-label', 'Text selection actions');
487
+ this.setAttribute('aria-orientation', 'vertical');
488
+ }
489
+
490
+ renderCopyLinkToHighlightOption() {
491
+ return html`
492
+ <br-menu-option
493
+ id="copy-link-option"
494
+ @click=${this.handleCopyLinkToHighlight}
495
+ icon="share"
496
+ label="Copy Link to Highlight"
497
+ live-label
498
+ ></br-menu-option>
499
+ `;
500
+ }
501
+
502
+ renderRemoveOption() {
503
+ return html`
504
+ <br-menu-option
505
+ @click=${this.handleDeleteHighlight}
506
+ icon="share"
507
+ label="Delete Highlight and Annotation"
508
+ ></br-menu-option>
509
+ `;
510
+ }
511
+
512
+ renderAddAnnotationOption() {
513
+ return html`
514
+ <br-menu-option
515
+ @click=${this.handleAddAnnotation}
516
+ icon="edit-pencil"
517
+ label="Annotate"
518
+ ></br-menu-option>
519
+ `;
520
+ }
521
+
522
+ renderHighlightOption() {
523
+ return html`
524
+ <br-menu-option
525
+ @click=${this.handleHighlightSave}
526
+ icon="edit-pencil"
527
+ label="Highlight Selection"
528
+ ></br-menu-option>
529
+ `;
530
+ }
531
+ renderLocalStorageOptions() {
532
+ return html`
533
+ <br-menu-option
534
+ @click=${this.renderSavedHighlights}
535
+ icon="share"
536
+ label="Load Highlights"
537
+ ></br-menu-option>
538
+ <br-menu-option
539
+ @click=${() => {window.localStorage.removeItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY);}}
540
+ icon="share"
541
+ label="Remove Stored Highlights"
542
+ ></br-menu-option>`;
543
+ }
544
+
432
545
  render() {
546
+ // TODO change the second button to use a different icon
433
547
  return html`
434
- <button @click=${this.handleCopyLinkToHighlight} class="br-select-menu__option">
435
- <ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>
436
- <span class="br-select-menu__label">Copy Link to Highlight</span>
437
- </button>
548
+ ${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
549
+ ${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
550
+ ${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
551
+ ${this.nodesForRemoval ? this.renderRemoveOption() : ''}
438
552
  `;
439
553
  }
440
554
 
441
555
  /**
442
556
  * @param {MouseEvent} e
443
557
  */
444
- handleCopyLinkToHighlight(e) {
558
+ async handleCopyLinkToHighlight(e) {
445
559
  e.preventDefault();
446
-
447
560
  const currentParams = this.br.readQueryString();
448
- const currentSelection = window.getSelection();
449
- /** @type {HTMLElement} */
450
- const textLayer = currentSelection.anchorNode.parentElement.closest('.BRtextLayer');
451
- const textFragmentUrlParam = createTextFragmentUrlParam(currentSelection, Array.from(document.querySelectorAll('.BRpage-visible')));
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]);
452
569
 
453
570
  // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
454
571
  let linkToHighlightParams = currentParams;
455
572
  if (currentParams.includes('text=')) {
456
- linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, textFragmentUrlParam);
573
+ linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
457
574
  } else {
458
575
  const sep = linkToHighlightParams ? '&' : '?';
459
- linkToHighlightParams += `${sep}${textFragmentUrlParam}`;
576
+ linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
460
577
  }
461
-
462
578
  const currentUrl = window.location;
463
- // TODO - updateResumeValue + getCookiePath in plugin.resume.js overrides the adjustedUrlPageNumPath, check how to workaround this
464
- // TODO - won't work with hash mode
465
- const adjustedUrlPageNumPath = currentUrl.pathname.toString().replace(/(?<=\/page\/)\d+(?=\/)/, textLayer.parentElement.getAttribute('data-page-num'));
579
+ const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
580
+ const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
581
+ const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
582
+ const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
583
+
584
+ await navigator.clipboard.writeText(linkToHighlight);
585
+ this.copyLinkOption?.showTemporaryText('Copied!');
586
+ }
587
+
588
+ /**
589
+ * Returns the closest BRtextLayer element on the page that contains the target node
590
+ * @param {Node} node
591
+ * @returns {Element | null}
592
+ */
593
+ getNodeTextLayer(node) {
594
+ if (!node) return null;
595
+ const element = node instanceof Element ? node : node.parentElement;
596
+ return element?.closest('.BRtextLayer') ?? null;
597
+ }
598
+
599
+ /**
600
+ * Retrieves the current selected text on the page and serializes the quote contents + context
601
+ * The selection is also changed in the DOM to highlight the words
602
+ */
603
+ handleHighlightSave() {
604
+ const currentSelection = window.getSelection();
605
+ const start = currentSelection.direction === 'backward' ? currentSelection.focusNode.parentElement : currentSelection.anchorNode.parentElement;
606
+ const textLayer = this.getNodeTextLayer(start);
607
+ const highlight = BookReaderTextFragment.fromSelection(currentSelection, [textLayer.parentElement]);
608
+ highlight.uuid = `id-${crypto.randomUUID().split("-")[4]}`;
609
+ const highlights = this.loadHighlightsFromLocalStorage();
610
+ highlights.push(highlight);
611
+ this.saveToLocalStorage(highlights);
612
+ this.renderSavedHighlights();
613
+ }
614
+
615
+ handleAddAnnotation(e) {
616
+ // TODO
617
+ }
618
+
619
+ handleDeleteHighlight() {
620
+ if (this.nodesForRemoval) {
621
+ const uuid = retrieveUUID(this.nodesForRemoval[0]);
622
+ for (const ele of this.nodesForRemoval) {
623
+ const tempText = ele.textContent;
624
+ const parent = ele.parentElement;
625
+ if (parent.classList.contains('BRwordElement') || parent.classList.contains('BRspace')) {
626
+ ele.remove();
627
+ parent.textContent = tempText;
628
+ } else {
629
+ console.log("This element did not match removal criteria:", parent, ele);
630
+ }
631
+ }
632
+ this.deleteHighlight(uuid);
633
+ this.clearNodesForRemoval();
634
+ } else {
635
+ console.log("there is nothing to remove");
636
+ }
637
+ }
638
+
639
+ /**
640
+ * @param {string} uuid
641
+ */
642
+ deleteHighlight(uuid) {
643
+ const highlights = this.loadHighlightsFromLocalStorage();
644
+ for (let idx = 0; idx < highlights.length; idx++) {
645
+ if (highlights[idx].uuid === uuid) {
646
+ highlights.splice(idx, 1);
647
+ this.saveToLocalStorage(highlights);
648
+ return;
649
+ }
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Saves all the highlights in an array to localStorage
655
+ * @param {BookReaderSavedHighlight[]} highlights
656
+ */
657
+ saveToLocalStorage(highlights) {
658
+ window.localStorage.setItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY, JSON.stringify(highlights.map(hl => hl.toJSON())));
659
+ }
660
+
661
+ /**
662
+ * @returns {BookReaderSavedHighlight[]}
663
+ */
664
+ loadHighlightsFromLocalStorage() {
665
+ return JSON.parse(window.localStorage.getItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY) || "[]")
666
+ .map(item => BookReaderTextFragment.fromJSON(item));
667
+ }
668
+
669
+ renderSavedHighlights() {
670
+ for (const hl of this.loadHighlightsFromLocalStorage()) {
671
+ const textLayer = /** @type {HTMLElement} */ (this.br.$(`.pagediv${hl.pageIndex} .BRtextLayer`)[0]);
672
+ if (!textLayer) continue;
673
+ renderHighlight(textLayer, hl);
674
+ // Attach click behaviour here? Only need one handler per text layer
675
+ $(textLayer)
676
+ .off('mouseup.BRHighlightClick')
677
+ .on('mouseup.BRHighlightClick', (e) => {
678
+ if (!e.target.classList.contains("BRhighlight")) return;
679
+ e.stopPropagation();
680
+ this.handleHighlightClick(e.target);
681
+ });
682
+ }
683
+ }
684
+
685
+ /**
686
+ * @param {HTMLElement} target
687
+ */
688
+ handleHighlightClick(target) {
689
+ const textLayer = this.getNodeTextLayer(target);
690
+ const identifier = retrieveUUID(target);
691
+ const selectedQuoteNodes = textLayer.querySelectorAll(`.${identifier}`);
692
+
693
+ const firstNode = selectedQuoteNodes[0];
694
+ const lastNode = selectedQuoteNodes[selectedQuoteNodes.length - 1];
695
+
696
+ const highlightRange = document.createRange();
697
+ highlightRange.setStart(firstNode, 0);
698
+ highlightRange.setEnd(lastNode, 1);
466
699
 
467
- const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${currentUrl?.hash || ''}`;
468
- navigator.clipboard.writeText(linkToHighlight);
700
+ const currentSelection = window.getSelection();
701
+ currentSelection.removeAllRanges();
702
+ currentSelection.addRange(highlightRange);
703
+ this.show();
469
704
  }
470
705
 
471
- showMenu() {
706
+ show() {
472
707
  if (this.br.plugins.translate?.userToggleTranslate) return;
473
708
  const currentSelection = window.getSelection();
474
709
  const start = currentSelection.anchorNode.parentElement;
@@ -488,12 +723,22 @@ class BRSelectMenu extends LitElement {
488
723
  this.style.zIndex = '1';
489
724
  this.style.position = 'absolute';
490
725
  this.style.display = 'block';
726
+ this.clearNodesForRemoval();
491
727
  }
492
728
 
493
- hideMenu = () => {
729
+ hide = () => {
494
730
  this.style.display = 'none';
731
+ this.clearNodesForRemoval();
495
732
  return;
496
733
  }
734
+
735
+ /**
736
+ * Remove temporary storage for the currently selected highlight and updates selection menu options
737
+ */
738
+ clearNodesForRemoval = () => {
739
+ this.nodesForRemoval = null;
740
+ this.requestUpdate();
741
+ }
497
742
  }
498
743
 
499
744
  /**
@@ -521,12 +766,579 @@ export function getLastWords(numWords, text) {
521
766
  }
522
767
 
523
768
  /**
524
- * @param {HTMLElement | Element} parent
769
+ * @param {Node} parent
525
770
  * @returns {Node}
526
771
  */
527
- export function getLastMostElement(parent) {
528
- while (parent.lastElementChild) {
529
- parent = parent.lastElementChild;
772
+ export function getFirstMostNode(parent) {
773
+ while (parent.firstChild) {
774
+ parent = parent.firstChild;
530
775
  }
531
776
  return parent;
532
777
  }
778
+
779
+ /**
780
+ * @param {Node} parent
781
+ * @returns {Node}
782
+ */
783
+ export function getLastMostNode(parent) {
784
+ while (parent.lastChild) {
785
+ parent = parent.lastChild;
786
+ }
787
+ return parent;
788
+ }
789
+
790
+ /**
791
+ * Strips the whitespace to normalize text
792
+ * @param {String} string
793
+ * @returns
794
+ */
795
+ function replaceWhitespace(string) {
796
+ return string.replace(/\s+/g, " ");
797
+ }
798
+
799
+ /**
800
+ * Finds all regex matches within a range and returns their ranges.
801
+ * @param {RegExp} regex
802
+ * @param {Range} range - the range to search in
803
+ * @param {Node[]} textNodes - visible text nodes within the range
804
+ * @param {{ normalize?: function(string): string }} [options]
805
+ * @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
806
+ */
807
+ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
808
+ if (!textNodes.length) return undefined;
809
+
810
+ const startOffset = textNodes[0] === range.startContainer ?
811
+ range.startOffset :
812
+ 0;
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;
835
+ }
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
+ }
847
+
848
+ /**
849
+ * Uses the index that matches the quote string and normalizes the string contents to find the correct node
850
+ * @param {Number} index
851
+ * @param {Node[]} nodes
852
+ * @param {boolean} isEnd
853
+ * @param {{ normalize?: function(string): string }} [options]
854
+ */
855
+ 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};
885
+ }
886
+ denormalizedOffset += direction;
887
+
888
+ candidateSubstring = isEnd ?
889
+ node.textContent.substring(0, denormalizedOffset) :
890
+ node.textContent.substring(denormalizedOffset);
891
+ }
892
+ }
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
+ }
907
+ }
908
+ return undefined;
909
+ }
910
+
911
+ /**
912
+ * Takes a text quote object and a container element, and wraps the quote
913
+ * within the container element in a span to apply highlight-like styling
914
+ *
915
+ * Iterate through the range and determine the "start" and "end" nodes
916
+ * Example of how this is done via polyfill
917
+ * https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
918
+ *
919
+ * @param {HTMLElement} textLayer
920
+ * @param {BookReaderTextFragment} textFragment
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
923
+ */
924
+ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
925
+ // Create a range that encompasses the entire text content
926
+ const firstPageNode = getFirstMostNode(textLayer);
927
+ const lastPageNode = getLastMostNode(textLayer);
928
+ const wholePageRange = new Range();
929
+ wholePageRange.setStart(firstPageNode, 0);
930
+ wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
931
+ const normalize = replaceWhitespace;
932
+
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);
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");
946
+ return;
947
+ }
948
+
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
+ }
955
+
956
+ // At which point the quote should now be unambiguous!
957
+ const exactRanges = findRangeForRegExp(
958
+ textFragment.toRegExp({ normalize, context: false }),
959
+ broadRanges[0],
960
+ broadRangeWordNodes,
961
+ { normalize },
962
+ );
963
+ if (!exactRanges) {
964
+ throw new Error("Could not find quote in page");
965
+ }
966
+ const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
967
+ const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
968
+
969
+ // markRange requires the range to start and end within text nodes
970
+ const exactRangeTextNodes = new Range();
971
+ exactRangeTextNodes.setStart(startTextNode, 0);
972
+ exactRangeTextNodes.setEnd(endTextNode, endTextNode.textContent.length);
973
+
974
+ // Don't mark if already marked
975
+ if (startTextNode.parentElement.classList.contains("BRhighlight") ||
976
+ endTextNode.parentElement.classList.contains("BRhighlight")) {
977
+ return;
978
+ }
979
+
980
+ return markRange(exactRangeTextNodes, () => {
981
+ const mark = document.createElement("mark");
982
+ mark.classList.add("BRhighlight");
983
+ if (cssClassName) mark.classList.add(cssClassName);
984
+ if (textFragment.uuid) mark.classList.add(textFragment.uuid);
985
+ return mark;
986
+ });
987
+ }
988
+
989
+ /**
990
+ * Given a Range, wraps its text contents in one or more <mark> elements.
991
+ * <mark> elements can't cross block boundaries, so this function walks the
992
+ * tree to find all the relevant text nodes and wraps them.
993
+ * @param {Range} range - the range to mark. Must start and end inside of
994
+ * text nodes.
995
+ * @param {function(): Element} createWrappingElement - a function that creates
996
+ * the element to wrap text nodes in. This is called once per text node.
997
+ * @return {Element[]} The <mark> nodes that were created.
998
+ *
999
+ * Lightly adapted from https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L456
1000
+ */
1001
+ function markRange(
1002
+ range,
1003
+ createWrappingElement = () => document.createElement("mark"),
1004
+ ) {
1005
+ if (range.startContainer.nodeType != Node.TEXT_NODE ||
1006
+ range.endContainer.nodeType != Node.TEXT_NODE)
1007
+ return [];
1008
+
1009
+ // If the range is entirely within a single node, just surround it.
1010
+ if (range.startContainer === range.endContainer) {
1011
+ const trivialMark = createWrappingElement();
1012
+ range.surroundContents(trivialMark);
1013
+ return [trivialMark];
1014
+ }
1015
+
1016
+ // Start node -- special case
1017
+ const startNode = range.startContainer;
1018
+ const startNodeSubrange = range.cloneRange();
1019
+ startNodeSubrange.setEndAfter(startNode);
1020
+
1021
+ // End node -- special case
1022
+ const endNode = range.endContainer;
1023
+ const endNodeSubrange = range.cloneRange();
1024
+ endNodeSubrange.setStartBefore(endNode);
1025
+
1026
+ // In between nodes
1027
+ const marks = [];
1028
+ range.setStartAfter(startNode);
1029
+ range.setEndBefore(endNode);
1030
+ const walker = document.createTreeWalker(
1031
+ range.commonAncestorContainer,
1032
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
1033
+ {
1034
+ acceptNode: function(node) {
1035
+ if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT;
1036
+
1037
+ if (node.nodeType === Node.TEXT_NODE)
1038
+ return NodeFilter.FILTER_ACCEPT;
1039
+ return NodeFilter.FILTER_SKIP;
1040
+ },
1041
+ },
1042
+ );
1043
+ let node = walker.nextNode();
1044
+ while (node) {
1045
+ if (node.nodeType === Node.TEXT_NODE) {
1046
+ const mark = createWrappingElement();
1047
+ node.parentNode.insertBefore(mark, node);
1048
+ mark.appendChild(node);
1049
+ marks.push(mark);
1050
+ }
1051
+ node = walker.nextNode();
1052
+ }
1053
+
1054
+ const startMark = createWrappingElement();
1055
+ startNodeSubrange.surroundContents(startMark);
1056
+ const endMark = createWrappingElement();
1057
+ endNodeSubrange.surroundContents(endMark);
1058
+
1059
+ return [startMark, ...marks, endMark];
1060
+ }
1061
+
1062
+ /**
1063
+ * Get UUID assigned to the highlight element from class list
1064
+ * @param {HTMLElement} ele
1065
+ * @returns
1066
+ */
1067
+ function retrieveUUID(ele) {
1068
+ if (!ele) return null;
1069
+ const findUUID = Array.from(ele?.classList).filter((name) => {
1070
+ if (name.slice(0, 2).includes('id')) {
1071
+ return name;
1072
+ }
1073
+ });
1074
+ if (findUUID.length) {
1075
+ return findUUID[0];
1076
+ }
1077
+ return null;
1078
+ }
1079
+
1080
+ /**
1081
+ * An extension of the fields defined by the browser-native TextFragment;
1082
+ * See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
1083
+ *
1084
+ * Text fragment string format: `pageNumber:prefix-,quote,-suffix`
1085
+ * Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
1086
+ * Note the ':' and ',' separators must not be encoded, but
1087
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
1088
+ */
1089
+ export class BookReaderTextFragment {
1090
+ /**
1091
+ * @param {object} params
1092
+ * @param {string | null}params.prefix
1093
+ * @param {string | null} [params.quote]
1094
+ * @param {string | null} [params.quoteStart]
1095
+ * @param {string | null} [params.quoteEnd]
1096
+ * @param {string | null} params.suffix
1097
+ * @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
1098
+ * @param {number} params.pageIndex Page index; e.g. zero-based index of the page
1099
+ * @param {string | null} [params.uuid] UUID for the text fragment if it has one
1100
+ */
1101
+ constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
1102
+ /** @type {string|null} */
1103
+ this.prefix = prefix;
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;
1110
+ /** @type {string|null} */
1111
+ this.suffix = suffix;
1112
+ /** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
1113
+ this.pageNumber = pageNumber;
1114
+ /** @type {number} Page index; e.g. zero-based index of the page */
1115
+ this.pageIndex = pageIndex;
1116
+ /** @type {string | null} UUID for the text fragment if it has one */
1117
+ this.uuid = uuid ?? null;
1118
+ }
1119
+
1120
+ /**
1121
+ * Parse a text fragment from its string value (the part after `text=`).
1122
+ * Expected format: `pageNumber:prefix-,quote,-suffix`
1123
+ * @param {string} str
1124
+ * @param {import('@/src/BookReader/BookModel.js').BookModel} book
1125
+ * @param {number} fallbackPageIndex A fallback page index to use if the text
1126
+ * fragment does not specify a page number or page index.
1127
+ * @returns {BookReaderTextFragment}
1128
+ */
1129
+ static fromString(str, book, fallbackPageIndex) {
1130
+ // Basically matches:
1131
+ // (stuff):(stuff)-,(stuff),-(stuff)
1132
+ const match = str.match(/^(?:(.*?):)?(?:(.*?)-,)?(.*?)(?:,-(.*))?$/);
1133
+ if (!match) {
1134
+ throw new Error(`Invalid text fragment format: ${str}`);
1135
+ }
1136
+ const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
1137
+ const prefix = match[2] ? decodeURIComponent(match[2]) : null;
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
+
1153
+ const suffix = match[4] ? decodeURIComponent(match[4]) : null;
1154
+ const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
1155
+ if (typeof pageIndex !== 'number') {
1156
+ throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
1157
+ }
1158
+
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
+ });
1172
+ }
1173
+
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
+
1190
+ /**
1191
+ * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
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`.
1194
+ * Note the ':' and ',' separators must not and are not encoded, but
1195
+ * the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
1196
+ * @returns {string}
1197
+ */
1198
+ toUrlString() {
1199
+ // First the page number or index
1200
+ let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
1201
+
1202
+ if (this.prefix) {
1203
+ str += `${encodeURIComponent(this.prefix)}-,`;
1204
+ }
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
+
1229
+ if (this.suffix) {
1230
+ str += `,-${encodeURIComponent(this.suffix)}`;
1231
+ }
1232
+ return str;
1233
+ }
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
+
1259
+ toJSON() {
1260
+ return {
1261
+ prefix: this.prefix,
1262
+ quote: this.quote,
1263
+ quoteStart: this.quoteStart,
1264
+ quoteEnd: this.quoteEnd,
1265
+ suffix: this.suffix,
1266
+ pageNumber: this.pageNumber,
1267
+ pageIndex: this.pageIndex,
1268
+ uuid: this.uuid,
1269
+ };
1270
+ }
1271
+
1272
+ /**
1273
+ * Create a BookReaderTextFragment instance from a JSON object.
1274
+ * @param {object} json
1275
+ * @returns {BookReaderTextFragment}
1276
+ */
1277
+ static fromJSON(json) {
1278
+ return new BookReaderTextFragment(json);
1279
+ }
1280
+
1281
+ /**
1282
+ * Builds a TextFragment string from a given text selection.
1283
+ * @param {Selection} selection currently selected text, eg `document.getSelection()`
1284
+ * @param {Element[]} contextElements elements providing context for the selection
1285
+ * @returns {BookReaderTextFragment}
1286
+ */
1287
+ static fromSelection(selection, contextElements) {
1288
+ const range = selection.getRangeAt(0);
1289
+
1290
+ // Partially selected words need to be captured completely
1291
+ const fullQuoteRange = new Range();
1292
+ const startTextNode = getLastMostNode(range.startContainer);
1293
+ const endTextNode = getFirstMostNode(range.endContainer);
1294
+ fullQuoteRange.setStart(startTextNode, 0);
1295
+ fullQuoteRange.setEnd(endTextNode, range.endOffset == 0 ? 0 : (endTextNode.textContent?.length ?? 0));
1296
+
1297
+ const preStartRange = document.createRange();
1298
+ preStartRange.setStart(getFirstMostNode(contextElements[0]), 0);
1299
+ preStartRange.setEnd(startTextNode, 0);
1300
+
1301
+ const postEndRange = document.createRange();
1302
+ postEndRange.setStart(endTextNode, fullQuoteRange.endOffset);
1303
+ const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
1304
+ postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
1305
+
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
+ }
1327
+
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();
1330
+ const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1331
+
1332
+ return new BookReaderTextFragment({
1333
+ quote,
1334
+ prefix,
1335
+ suffix,
1336
+ pageNumber: pageContainerEl.getAttribute("data-page-num"),
1337
+ pageIndex: parseFloat(pageContainerEl.getAttribute("data-index")),
1338
+ });
1339
+ }
1340
+ }
1341
+
1342
+ /**
1343
+ * @typedef {BookReaderTextFragment & { uuid: string }} BookReaderSavedHighlight
1344
+ */