@internetarchive/bookreader 5.0.0-111 → 5.0.0-113

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 (35) hide show
  1. package/BookReader/BookReader.css +13 -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/Navbar/Navbar.js +9 -10
  24. package/src/BookReader/utils/SelectionObserver.js +2 -2
  25. package/src/BookReader.js +5 -4
  26. package/src/css/_BRpages.scss +5 -2
  27. package/src/css/_TextSelection.scss +15 -9
  28. package/src/plugins/plugin.experiments.js +31 -5
  29. package/src/plugins/plugin.text_selection.js +19 -56
  30. package/src/plugins/translate/plugin.translate.js +68 -22
  31. package/src/plugins/tts/utils.js +1 -1
  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 +834 -137
  35. package/BookReader/plugins/plugin.url.js.LICENSE.txt +0 -1
@@ -1,8 +1,12 @@
1
1
  // @ts-check
2
2
  import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
3
3
  import { html, LitElement } from 'lit';
4
- import { customElement } from 'lit/decorators.js';
4
+ import { customElement, property, query } from 'lit/decorators.js';
5
+ import { ifDefined } from 'lit/directives/if-defined.js';
5
6
  import '@internetarchive/icon-share';
7
+ import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
8
+
9
+ const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
6
10
 
7
11
  export class TextSelectionManager {
8
12
  options = {
@@ -12,8 +16,10 @@ export class TextSelectionManager {
12
16
 
13
17
  /** @type {BRSelectMenu} */
14
18
  selectMenu;
15
- /** @type {boolean} */
16
- selectionMenuEnabled = false;
19
+
20
+ get selectMenuEnabled() {
21
+ return this.br.plugins.experiments?.isEnabled('copyLinkToHighlight') || this.br.plugins.experiments?.isEnabled('annotateHighlight');
22
+ }
17
23
 
18
24
  /**
19
25
  * @param {string} layer Selector for the text layer to manage
@@ -46,21 +52,21 @@ export class TextSelectionManager {
46
52
  // Set a class on the page to avoid hiding it when zooming/etc
47
53
  this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
48
54
  $(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
49
- this.selectMenu.showMenu();
55
+ this.showSelectMenu();
50
56
 
51
57
  }
52
58
 
53
- if (selectEvent == 'focusChanged') {
59
+ if (selectEvent == 'changed') {
54
60
  // hide the button as user changes their selection
55
61
  if (this.mouseIsDown) {
56
- this.selectMenu.hideMenu();
57
- } else if (window.getSelection().toString()) {
58
- this.selectMenu.showMenu();
62
+ this.hideSelectMenu();
63
+ } else if (window.getSelection()?.toString()) {
64
+ this.showSelectMenu();
59
65
  }
60
66
  }
61
67
 
62
68
  if (selectEvent == 'cleared') {
63
- this.selectMenu.hideMenu();
69
+ this.hideSelectMenu();
64
70
  }
65
71
  }).attach();
66
72
  }
@@ -68,9 +74,7 @@ export class TextSelectionManager {
68
74
  // Need attach + detach methods to toggle w/ Translation plugin
69
75
  attach() {
70
76
  this.selectionObserver.attach();
71
- if (this.selectionMenuEnabled) {
72
- this.renderSelectionMenu();
73
- }
77
+
74
78
  if (this.br.protected) {
75
79
  document.addEventListener('selectionchange', this._limitSelection);
76
80
  // Prevent right clicking when selected text
@@ -96,12 +100,8 @@ export class TextSelectionManager {
96
100
  }
97
101
  }
98
102
 
99
- renderSelectionMenu() {
100
- if (document.querySelector('.br-select-menu__option')) return;
101
- document.body.append(this.selectMenu);
102
- }
103
103
  /**
104
- * @param {'started' | 'cleared' | 'focusChanged'} type
104
+ * @param {'started' | 'cleared' | 'changed'} type
105
105
  * @param {HTMLElement} target
106
106
  */
107
107
  _onSelectionChange = (type, target) => {
@@ -109,7 +109,7 @@ export class TextSelectionManager {
109
109
  this.textSelectingMode(target);
110
110
  } else if (type === 'cleared') {
111
111
  this.defaultMode(target);
112
- } else if (type === 'focusChanged') {
112
+ } else if (type === 'changed') {
113
113
  // do nothing, just wait for the mouseup to trigger the styling change
114
114
  } else {
115
115
  throw new Error(`Unknown type ${type}`);
@@ -162,7 +162,7 @@ export class TextSelectionManager {
162
162
  // blocking selection
163
163
  $(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
164
164
  this.mouseIsDown = true;
165
- this.selectMenu.hideMenu();
165
+ this.hideSelectMenu();
166
166
  if ($(event.target).is(this.selectionElement.join(", "))) {
167
167
  event.stopPropagation();
168
168
  }
@@ -170,7 +170,7 @@ export class TextSelectionManager {
170
170
 
171
171
  $(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
172
172
  this.mouseIsDown = false;
173
- this.selectMenu.hideMenu();
173
+ this.hideSelectMenu();
174
174
  textLayer.style.pointerEvents = "none";
175
175
  if (skipNextMouseup) {
176
176
  skipNextMouseup = false;
@@ -196,7 +196,7 @@ export class TextSelectionManager {
196
196
  if (event.which != 1) return;
197
197
  this.mouseIsDown = true;
198
198
  event.stopPropagation();
199
- this.selectMenu.hideMenu();
199
+ this.hideSelectMenu();
200
200
  });
201
201
 
202
202
  // Prevent page flip on click
@@ -204,10 +204,27 @@ export class TextSelectionManager {
204
204
  this.mouseIsDown = false;
205
205
  if (event.which != 1) return;
206
206
  event.stopPropagation();
207
- this.selectMenu.showMenu();
207
+ this.showSelectMenu();
208
208
  });
209
209
  }
210
210
 
211
+ showSelectMenu() {
212
+ if (!this.selectMenuEnabled) return;
213
+
214
+ this.selectMenu.copyLinkToHighlightEnabled = this.br.plugins.experiments.isEnabled('copyLinkToHighlight');
215
+ this.selectMenu.highlightAnnotationEnabled = this.br.plugins.experiments.isEnabled('annotateHighlight');
216
+
217
+ if (!this.selectMenu.isConnected) {
218
+ document.body.append(this.selectMenu);
219
+ }
220
+
221
+ this.selectMenu.show();
222
+ }
223
+
224
+ hideSelectMenu() {
225
+ this.selectMenu.hide();
226
+ }
227
+
211
228
  _limitSelection = () => {
212
229
  const selection = window.getSelection();
213
230
  if (!selection.rangeCount) return;
@@ -246,98 +263,6 @@ export class TextSelectionManager {
246
263
  };
247
264
  }
248
265
 
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
266
  /**
342
267
  * @template T
343
268
  * Get the i-th element of an iterable
@@ -413,11 +338,133 @@ export function* walkBetweenNodes(start, end) {
413
338
  yield* walk(start);
414
339
  }
415
340
 
341
+ @customElement('br-menu-option')
342
+ export class BRSelectMenuOption extends LitElement {
343
+ @property({type: String})
344
+ icon = '';
345
+
346
+ @property({type: String})
347
+ label = '';
348
+
349
+ /** @type {string | null} */
350
+ temporaryText = null;
351
+
352
+ @property({type: Number, attribute: 'temporary-text-duration'})
353
+ temporaryTextDuration = 1200;
354
+
355
+ @property({type: String, attribute: 'aria-label'})
356
+ ariaLabel = '';
357
+
358
+ @property({type: Boolean, attribute: 'live-label'})
359
+ liveLabel = false;
360
+
361
+ @property({type: Boolean})
362
+ temporaryTextVisible = false;
363
+
364
+ /** @type {number | null} */
365
+ temporaryTextTimeoutId = null;
366
+
367
+ /** @override */
368
+ createRenderRoot() {
369
+ // Keep styles from existing stylesheet by opting out of shadow DOM
370
+ return this;
371
+ }
372
+
373
+ /** @override */
374
+ disconnectedCallback() {
375
+ if (this.temporaryTextTimeoutId != null) {
376
+ window.clearTimeout(this.temporaryTextTimeoutId);
377
+ this.temporaryTextTimeoutId = null;
378
+ }
379
+ super.disconnectedCallback();
380
+ }
381
+
382
+ /**
383
+ * @param {string} text
384
+ */
385
+ showTemporaryText(text) {
386
+ if (!text?.trim()) return;
387
+
388
+ this.temporaryText = text;
389
+
390
+ this.temporaryTextVisible = true;
391
+ if (this.temporaryTextTimeoutId != null) {
392
+ window.clearTimeout(this.temporaryTextTimeoutId);
393
+ }
394
+ this.temporaryTextTimeoutId = window.setTimeout(() => {
395
+ this.temporaryTextVisible = false;
396
+ this.temporaryText = null;
397
+ this.temporaryTextTimeoutId = null;
398
+ }, this.temporaryTextDuration);
399
+ }
400
+
401
+ renderIcon() {
402
+ if (this.icon === 'share') {
403
+ return html`<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>`;
404
+ }
405
+ if (this.icon === 'edit-pencil') {
406
+ return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
407
+ }
408
+ return '';
409
+ }
410
+
411
+ render() {
412
+ const baseLabel = this.label ?? '';
413
+ const hasTemporaryText = !!this.temporaryText;
414
+ const temporaryLabel = hasTemporaryText ? this.temporaryText : baseLabel;
415
+ const showTemporaryLabel = hasTemporaryText && this.temporaryTextVisible;
416
+ const visibleLabel = showTemporaryLabel ? temporaryLabel : baseLabel;
417
+ const providedAriaLabel = this.ariaLabel?.trim();
418
+ const accessibleLabel = providedAriaLabel && providedAriaLabel !== visibleLabel.trim() ? providedAriaLabel : undefined;
419
+ const sizingLabel = baseLabel.length >= temporaryLabel.length ? baseLabel : temporaryLabel;
420
+
421
+ return html`
422
+ <button
423
+ type="button"
424
+ class="br-select-menu__option"
425
+ role="menuitem"
426
+ aria-label=${ifDefined(accessibleLabel)}
427
+ >
428
+ ${this.renderIcon()}
429
+ ${hasTemporaryText ? html`
430
+ <span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
431
+ <span
432
+ class="br-select-menu__label"
433
+ style="visibility: hidden;"
434
+ aria-hidden="true"
435
+ >${sizingLabel}</span>
436
+ <span
437
+ class="br-select-menu__label"
438
+ style="position: absolute; inset: 0; display: inline-flex; align-items: center;"
439
+ aria-live=${this.liveLabel ? 'polite' : 'off'}
440
+ >${showTemporaryLabel ? temporaryLabel : baseLabel}</span>
441
+ </span>
442
+ ` : html`
443
+ <span class="br-select-menu__label" aria-live=${this.liveLabel ? 'polite' : 'off'}>${baseLabel}</span>
444
+ `}
445
+ </button>
446
+ `;
447
+ }
448
+ }
449
+
416
450
  @customElement('br-select-menu')
417
451
  class BRSelectMenu extends LitElement {
418
452
  /** @type {import('../BookReader.js').default} */
419
453
  br;
420
454
 
455
+ /** @type {BRSelectMenuOption | null} */
456
+ @query('#copy-link-option')
457
+ copyLinkOption;
458
+
459
+ @property({type: Boolean})
460
+ copyLinkToHighlightEnabled = true;
461
+
462
+ @property({type: Boolean})
463
+ highlightAnnotationEnabled = true;
464
+
465
+ /**
466
+ * @param {import('../BookReader.js').default} br
467
+ */
421
468
  constructor(br) {
422
469
  super();
423
470
  this.br = br;
@@ -429,46 +476,225 @@ class BRSelectMenu extends LitElement {
429
476
  return this;
430
477
  }
431
478
 
479
+ /** @override */
480
+ connectedCallback() {
481
+ super.connectedCallback();
482
+ this.setAttribute('role', 'menu');
483
+ this.setAttribute('aria-label', 'Text selection actions');
484
+ this.setAttribute('aria-orientation', 'vertical');
485
+ }
486
+
487
+ renderCopyLinkToHighlightOption() {
488
+ return html`
489
+ <br-menu-option
490
+ id="copy-link-option"
491
+ @click=${this.handleCopyLinkToHighlight}
492
+ icon="share"
493
+ label="Copy Link to Highlight"
494
+ live-label
495
+ ></br-menu-option>
496
+ `;
497
+ }
498
+
499
+ renderRemoveOption() {
500
+ return html`
501
+ <br-menu-option
502
+ @click=${this.handleDeleteHighlight}
503
+ icon="share"
504
+ label="Delete Highlight and Annotation"
505
+ ></br-menu-option>
506
+ `;
507
+ }
508
+
509
+ renderAddAnnotationOption() {
510
+ return html`
511
+ <br-menu-option
512
+ @click=${this.handleAddAnnotation}
513
+ icon="edit-pencil"
514
+ label="Annotate"
515
+ ></br-menu-option>
516
+ `;
517
+ }
518
+
519
+ renderHighlightOption() {
520
+ return html`
521
+ <br-menu-option
522
+ @click=${this.handleHighlightSave}
523
+ icon="edit-pencil"
524
+ label="Highlight Selection"
525
+ ></br-menu-option>
526
+ `;
527
+ }
528
+ renderLocalStorageOptions() {
529
+ return html`
530
+ <br-menu-option
531
+ @click=${this.renderSavedHighlights}
532
+ icon="share"
533
+ label="Load Highlights"
534
+ ></br-menu-option>
535
+ <br-menu-option
536
+ @click=${() => {window.localStorage.removeItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY);}}
537
+ icon="share"
538
+ label="Remove Stored Highlights"
539
+ ></br-menu-option>`;
540
+ }
541
+
432
542
  render() {
543
+ // TODO change the second button to use a different icon
433
544
  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>
545
+ ${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
546
+ ${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
547
+ ${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
548
+ ${this.nodesForRemoval ? this.renderRemoveOption() : ''}
438
549
  `;
439
550
  }
440
551
 
441
552
  /**
442
553
  * @param {MouseEvent} e
443
554
  */
444
- handleCopyLinkToHighlight(e) {
555
+ async handleCopyLinkToHighlight(e) {
445
556
  e.preventDefault();
446
-
447
557
  const currentParams = this.br.readQueryString();
448
558
  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')));
559
+ const textFragment = BookReaderTextFragment.fromSelection(currentSelection, this.br.$('.BRpage-visible').toArray());
452
560
 
453
561
  // Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
454
562
  let linkToHighlightParams = currentParams;
455
563
  if (currentParams.includes('text=')) {
456
- linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, textFragmentUrlParam);
564
+ linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
457
565
  } else {
458
566
  const sep = linkToHighlightParams ? '&' : '?';
459
- linkToHighlightParams += `${sep}${textFragmentUrlParam}`;
567
+ linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
460
568
  }
461
-
462
569
  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'));
570
+ const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
571
+ const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
572
+ const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
573
+ const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
574
+
575
+ await navigator.clipboard.writeText(linkToHighlight);
576
+ this.copyLinkOption?.showTemporaryText('Copied!');
577
+ }
578
+
579
+ /**
580
+ * Returns the closest BRtextLayer element on the page that contains the target node
581
+ * @param {Node} node
582
+ * @returns {HTMLElement | null}
583
+ */
584
+ getNodeTextLayer(node) {
585
+ if (!node) return;
586
+ const element = 'closest' in node ? node : node.parentElement;
587
+ return element?.closest('.BRtextLayer') ?? null;
588
+ }
589
+
590
+ /**
591
+ * Retrieves the current selected text on the page and serializes the quote contents + context
592
+ * The selection is also changed in the DOM to highlight the words
593
+ */
594
+ handleHighlightSave() {
595
+ const currentSelection = window.getSelection();
596
+ const start = currentSelection.direction === 'backward' ? currentSelection.focusNode.parentElement : currentSelection.anchorNode.parentElement;
597
+ const textLayer = this.getNodeTextLayer(start);
598
+ const highlight = BookReaderTextFragment.fromSelection(currentSelection, [textLayer.parentElement]);
599
+ highlight.uuid = `id-${crypto.randomUUID().split("-")[4]}`;
600
+ const highlights = this.loadHighlightsFromLocalStorage();
601
+ highlights.push(highlight);
602
+ this.saveToLocalStorage(highlights);
603
+ this.renderSavedHighlights();
604
+ }
605
+
606
+ handleAddAnnotation(e) {
607
+ // TODO
608
+ }
609
+
610
+ handleDeleteHighlight() {
611
+ if (this.nodesForRemoval) {
612
+ const uuid = retrieveUUID(this.nodesForRemoval[0]);
613
+ for (const ele of this.nodesForRemoval) {
614
+ const tempText = ele.textContent;
615
+ const parent = ele.parentElement;
616
+ if (parent.classList.contains('BRwordElement') || parent.classList.contains('BRspace')) {
617
+ ele.remove();
618
+ parent.textContent = tempText;
619
+ } else {
620
+ console.log("This element did not match removal criteria:", parent, ele);
621
+ }
622
+ }
623
+ this.deleteHighlight(uuid);
624
+ this.clearNodesForRemoval();
625
+ } else {
626
+ console.log("there is nothing to remove");
627
+ }
628
+ }
629
+
630
+ /**
631
+ * @param {string} uuid
632
+ */
633
+ deleteHighlight(uuid) {
634
+ const highlights = this.loadHighlightsFromLocalStorage();
635
+ for (let idx = 0; idx < highlights.length; idx++) {
636
+ if (highlights[idx].uuid === uuid) {
637
+ highlights.splice(idx, 1);
638
+ this.saveToLocalStorage(highlights);
639
+ return;
640
+ }
641
+ }
642
+ }
643
+
644
+ /**
645
+ * Saves all the highlights in an array to localStorage
646
+ * @param {BookReaderSavedHighlight[]} highlights
647
+ */
648
+ saveToLocalStorage(highlights) {
649
+ window.localStorage.setItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY, JSON.stringify(highlights.map(hl => hl.toJSON())));
650
+ }
651
+
652
+ /**
653
+ * @returns {BookReaderSavedHighlight[]}
654
+ */
655
+ loadHighlightsFromLocalStorage() {
656
+ return JSON.parse(window.localStorage.getItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY) || "[]")
657
+ .map(item => BookReaderTextFragment.fromJSON(item));
658
+ }
659
+
660
+ renderSavedHighlights() {
661
+ for (const hl of this.loadHighlightsFromLocalStorage()) {
662
+ const textLayer = /** @type {HTMLElement} */ (this.br.$(`.pagediv${hl.pageIndex} .BRtextLayer`)[0]);
663
+ if (!textLayer) continue;
664
+ renderHighlight(textLayer, hl);
665
+ // Attach click behaviour here? Only need one handler per text layer
666
+ $(textLayer)
667
+ .off('mouseup.BRHighlightClick')
668
+ .on('mouseup.BRHighlightClick', (e) => {
669
+ if (!e.target.classList.contains("BRhighlight")) return;
670
+ e.stopPropagation();
671
+ this.handleHighlightClick(e.target);
672
+ });
673
+ }
674
+ }
675
+
676
+ /**
677
+ * @param {HTMLElement} target
678
+ */
679
+ handleHighlightClick(target) {
680
+ const textLayer = this.getNodeTextLayer(target);
681
+ const identifier = retrieveUUID(target);
682
+ const selectedQuoteNodes = textLayer.querySelectorAll(`.${identifier}`);
683
+
684
+ const firstNode = selectedQuoteNodes[0];
685
+ const lastNode = selectedQuoteNodes[selectedQuoteNodes.length - 1];
466
686
 
467
- const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${currentUrl?.hash || ''}`;
468
- navigator.clipboard.writeText(linkToHighlight);
687
+ const highlightRange = document.createRange();
688
+ highlightRange.setStart(firstNode, 0);
689
+ highlightRange.setEnd(lastNode, 1);
690
+
691
+ const currentSelection = window.getSelection();
692
+ currentSelection.removeAllRanges();
693
+ currentSelection.addRange(highlightRange);
694
+ this.show();
469
695
  }
470
696
 
471
- showMenu() {
697
+ show() {
472
698
  if (this.br.plugins.translate?.userToggleTranslate) return;
473
699
  const currentSelection = window.getSelection();
474
700
  const start = currentSelection.anchorNode.parentElement;
@@ -488,12 +714,22 @@ class BRSelectMenu extends LitElement {
488
714
  this.style.zIndex = '1';
489
715
  this.style.position = 'absolute';
490
716
  this.style.display = 'block';
717
+ this.clearNodesForRemoval();
491
718
  }
492
719
 
493
- hideMenu = () => {
720
+ hide = () => {
494
721
  this.style.display = 'none';
722
+ this.clearNodesForRemoval();
495
723
  return;
496
724
  }
725
+
726
+ /**
727
+ * Remove temporary storage for the currently selected highlight and updates selection menu options
728
+ */
729
+ clearNodesForRemoval = () => {
730
+ this.nodesForRemoval = null;
731
+ this.requestUpdate();
732
+ }
497
733
  }
498
734
 
499
735
  /**
@@ -521,12 +757,473 @@ export function getLastWords(numWords, text) {
521
757
  }
522
758
 
523
759
  /**
524
- * @param {HTMLElement | Element} parent
760
+ * @param {Node} parent
525
761
  * @returns {Node}
526
762
  */
527
- export function getLastMostElement(parent) {
528
- while (parent.lastElementChild) {
529
- parent = parent.lastElementChild;
763
+ export function getFirstMostNode(parent) {
764
+ while (parent.firstChild) {
765
+ parent = parent.firstChild;
530
766
  }
531
767
  return parent;
532
768
  }
769
+
770
+ /**
771
+ * @param {Node} parent
772
+ * @returns {Node}
773
+ */
774
+ export function getLastMostNode(parent) {
775
+ while (parent.lastChild) {
776
+ parent = parent.lastChild;
777
+ }
778
+ return parent;
779
+ }
780
+
781
+ /**
782
+ * Strips the whitespace to normalize text
783
+ * @param {String} string
784
+ * @returns
785
+ */
786
+ function replaceWhitespace(string) {
787
+ return string.replace(/\s+/g, " ");
788
+ }
789
+
790
+ /**
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
793
+ * @param {Range} range - the range to search in
794
+ * @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
799
+ */
800
+ export function findRangeForQuote(quote, range, textNodes) {
801
+ const startOffset = textNodes[0] === range.startContainer ?
802
+ range.startOffset :
803
+ 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;
824
+ }
825
+ searchStart = matchedIndex + 1;
826
+ }
827
+ return undefined;
828
+ }
829
+
830
+
831
+ /**
832
+ * Uses the index that matches the quote string and normalizes the string contents to find the correct node
833
+ * @param {Number} index
834
+ * @param {Node[]} nodes
835
+ * @param {boolean} isEnd
836
+ */
837
+ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
838
+ let counted = 0;
839
+ let normalizedData;
840
+ for (let i = 0; i < nodes.length; i++) {
841
+ const node = nodes[i];
842
+ if (node.className === 'BRlineElement') {
843
+ // Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
844
+ normalizedData = ' ';
845
+ } else {
846
+ if (!normalizedData) normalizedData = replaceWhitespace(node.textContent);
847
+ }
848
+ let nodeEnd = counted + normalizedData.length;
849
+ if (isEnd) nodeEnd += 1;
850
+ if (nodeEnd > index) {
851
+ const normalizedOffset = index - counted;
852
+ let denormalizedOffset = Math.min(index - counted, node.textContent.length);
853
+
854
+ const targetSubstring = isEnd ?
855
+ normalizedData.substring(0, normalizedOffset) :
856
+ normalizedData.substring(normalizedOffset);
857
+
858
+ let candidateSubstring = isEnd ?
859
+ replaceWhitespace(node.textContent.substring(0, normalizedOffset)) :
860
+ replaceWhitespace(node.textContent.substring(normalizedOffset));
861
+
862
+ const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
863
+ while (denormalizedOffset >= 0 &&
864
+ denormalizedOffset <= node.textContent.length) {
865
+ if (candidateSubstring.length === targetSubstring.length) {
866
+ return {node : node, offset: denormalizedOffset};
867
+ }
868
+ denormalizedOffset += direction;
869
+
870
+ candidateSubstring = isEnd ?
871
+ node.textContent.substring(0, denormalizedOffset) :
872
+ node.textContent.substring(denormalizedOffset);
873
+ }
874
+ }
875
+ counted += normalizedData.length;
876
+
877
+ if (i + 1 < nodes.length) {
878
+ const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
879
+ // Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
880
+ // 1st case explicitly check the node class to prevent double counted spaces
881
+ // 2nd case can happen from node traversal when loading from localStorage
882
+ if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
883
+ counted -= 1;
884
+ } else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
885
+ counted -= 1;
886
+ }
887
+ normalizedData = nextNormalizedData;
888
+ }
889
+ }
890
+ return undefined;
891
+ }
892
+
893
+ /**
894
+ * Takes a text quote object and a container element, and wraps the quote
895
+ * within the container element in a span to apply highlight-like styling
896
+ *
897
+ * Iterate through the range and determine the "start" and "end" nodes
898
+ * Example of how this is done via polyfill
899
+ * https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
900
+ *
901
+ * @param {HTMLElement} textLayer
902
+ * @param {BookReaderTextFragment} quote
903
+ * @param {string | null} cssClassName optional css class to add to the highlight span element
904
+ */
905
+ export function renderHighlight(textLayer, quote, cssClassName = null) {
906
+ // Create a range that encompasses the entire text content
907
+ const firstPageNode = getFirstMostNode(textLayer);
908
+ const lastPageNode = getLastMostNode(textLayer);
909
+ const wholePageRange = new Range();
910
+ wholePageRange.setStart(firstPageNode, 0);
911
+ 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);
923
+
924
+ // Retrieve the text nodes and relevant whitespace elements
925
+ // 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
+ const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
927
+ 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);
931
+ return;
932
+ }
933
+
934
+ const broadRangeWordNodes = [];
935
+ for (const el of walkBetweenNodes(broadRange?.startContainer, broadRange?.endContainer)) {
936
+ if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
937
+ broadRangeWordNodes.push(el);
938
+ }
939
+ }
940
+
941
+ // At which point the quote should now be unambiguous!
942
+ const exactRange = findRangeForQuote(quote.quote, broadRange, broadRangeWordNodes);
943
+ if (!exactRange) {
944
+ throw new Error("Could not find quote in page");
945
+ }
946
+ const startTextNode = getFirstMostNode(exactRange.startContainer);
947
+ const endTextNode = getFirstMostNode(exactRange.endContainer);
948
+
949
+ // markRange requires the range to start and end within text nodes
950
+ const exactRangeTextNodes = new Range();
951
+ exactRangeTextNodes.setStart(startTextNode, 0);
952
+ exactRangeTextNodes.setEnd(endTextNode, endTextNode.textContent.length);
953
+
954
+ // Don't mark if already marked
955
+ if (startTextNode.parentElement.classList.contains("BRhighlight") ||
956
+ endTextNode.parentElement.classList.contains("BRhighlight")) {
957
+ return;
958
+ }
959
+
960
+ markRange(exactRangeTextNodes, () => {
961
+ const mark = document.createElement("mark");
962
+ mark.classList.add("BRhighlight");
963
+ if (cssClassName) mark.classList.add(cssClassName);
964
+ if (quote.uuid) mark.classList.add(quote.uuid);
965
+ return mark;
966
+ });
967
+ }
968
+
969
+ /**
970
+ * Given a Range, wraps its text contents in one or more <mark> elements.
971
+ * <mark> elements can't cross block boundaries, so this function walks the
972
+ * tree to find all the relevant text nodes and wraps them.
973
+ * @param {Range} range - the range to mark. Must start and end inside of
974
+ * text nodes.
975
+ * @param {function(): Element} createWrappingElement - a function that creates
976
+ * the element to wrap text nodes in. This is called once per text node.
977
+ * @return {Element[]} The <mark> nodes that were created.
978
+ *
979
+ * Lightly adapted from https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L456
980
+ */
981
+ function markRange(
982
+ range,
983
+ createWrappingElement = () => document.createElement("mark"),
984
+ ) {
985
+ if (range.startContainer.nodeType != Node.TEXT_NODE ||
986
+ range.endContainer.nodeType != Node.TEXT_NODE)
987
+ return [];
988
+
989
+ // If the range is entirely within a single node, just surround it.
990
+ if (range.startContainer === range.endContainer) {
991
+ const trivialMark = createWrappingElement();
992
+ range.surroundContents(trivialMark);
993
+ return [trivialMark];
994
+ }
995
+
996
+ // Start node -- special case
997
+ const startNode = range.startContainer;
998
+ const startNodeSubrange = range.cloneRange();
999
+ startNodeSubrange.setEndAfter(startNode);
1000
+
1001
+ // End node -- special case
1002
+ const endNode = range.endContainer;
1003
+ const endNodeSubrange = range.cloneRange();
1004
+ endNodeSubrange.setStartBefore(endNode);
1005
+
1006
+ // In between nodes
1007
+ const marks = [];
1008
+ range.setStartAfter(startNode);
1009
+ range.setEndBefore(endNode);
1010
+ const walker = document.createTreeWalker(
1011
+ range.commonAncestorContainer,
1012
+ NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
1013
+ {
1014
+ acceptNode: function(node) {
1015
+ if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT;
1016
+
1017
+ if (node.nodeType === Node.TEXT_NODE)
1018
+ return NodeFilter.FILTER_ACCEPT;
1019
+ return NodeFilter.FILTER_SKIP;
1020
+ },
1021
+ },
1022
+ );
1023
+ let node = walker.nextNode();
1024
+ while (node) {
1025
+ if (node.nodeType === Node.TEXT_NODE) {
1026
+ const mark = createWrappingElement();
1027
+ node.parentNode.insertBefore(mark, node);
1028
+ mark.appendChild(node);
1029
+ marks.push(mark);
1030
+ }
1031
+ node = walker.nextNode();
1032
+ }
1033
+
1034
+ const startMark = createWrappingElement();
1035
+ startNodeSubrange.surroundContents(startMark);
1036
+ const endMark = createWrappingElement();
1037
+ endNodeSubrange.surroundContents(endMark);
1038
+
1039
+ return [startMark, ...marks, endMark];
1040
+ }
1041
+
1042
+ /**
1043
+ * Get UUID assigned to the highlight element from class list
1044
+ * @param {HTMLElement} ele
1045
+ * @returns
1046
+ */
1047
+ function retrieveUUID(ele) {
1048
+ if (!ele) return null;
1049
+ const findUUID = Array.from(ele?.classList).filter((name) => {
1050
+ if (name.slice(0, 2).includes('id')) {
1051
+ return name;
1052
+ }
1053
+ });
1054
+ if (findUUID.length) {
1055
+ return findUUID[0];
1056
+ }
1057
+ return null;
1058
+ }
1059
+
1060
+ /**
1061
+ * An extension of the fields defined by the browser-native TextFragment;
1062
+ * See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
1063
+ *
1064
+ * Text fragment string format: `pageNumber:prefix-,quote,-suffix`
1065
+ * Note the ':' and ',' separators must not be encoded, but
1066
+ * the pageNumber, prefix, quote, and suffix text can be encoded.
1067
+ */
1068
+ export class BookReaderTextFragment {
1069
+ /**
1070
+ * @param {object} params
1071
+ * @param {string | null}params.prefix
1072
+ * @param {string} params.quote
1073
+ * @param {string | null} params.suffix
1074
+ * @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
1075
+ * @param {number} params.pageIndex Page index; e.g. zero-based index of the page
1076
+ * @param {string | null} [params.uuid] UUID for the text fragment if it has one
1077
+ */
1078
+ constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
1079
+ /** @type {string|null} */
1080
+ this.prefix = prefix;
1081
+ /** @type {string} */
1082
+ this.quote = quote;
1083
+ /** @type {string|null} */
1084
+ this.suffix = suffix;
1085
+ /** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
1086
+ this.pageNumber = pageNumber;
1087
+ /** @type {number} Page index; e.g. zero-based index of the page */
1088
+ this.pageIndex = pageIndex;
1089
+ /** @type {string | null} UUID for the text fragment if it has one */
1090
+ this.uuid = uuid ?? null;
1091
+ }
1092
+
1093
+ /**
1094
+ * Parse a text fragment from its string value (the part after `text=`).
1095
+ * Expected format: `pageNumber:prefix-,quote,-suffix`
1096
+ * @param {string} str
1097
+ * @param {import('@/src/BookReader/BookModel.js').BookModel} book
1098
+ * @param {number} fallbackPageIndex A fallback page index to use if the text
1099
+ * fragment does not specify a page number or page index.
1100
+ * @returns {BookReaderTextFragment}
1101
+ */
1102
+ static fromString(str, book, fallbackPageIndex) {
1103
+ // Basically matches:
1104
+ // (stuff):(stuff)-,(stuff),-(stuff)
1105
+ const match = str.match(/^(?:(.*?):)?(?:(.*?)-,)?(.*?)(?:,-(.*))?$/);
1106
+ if (!match) {
1107
+ throw new Error(`Invalid text fragment format: ${str}`);
1108
+ }
1109
+ const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
1110
+ const prefix = match[2] ? decodeURIComponent(match[2]) : null;
1111
+ const quote = decodeURIComponent(match[3]);
1112
+ const suffix = match[4] ? decodeURIComponent(match[4]) : null;
1113
+ const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
1114
+ if (typeof pageIndex !== 'number') {
1115
+ throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
1116
+ }
1117
+
1118
+ return new BookReaderTextFragment({ prefix, quote, suffix, pageNumber, pageIndex });
1119
+ }
1120
+
1121
+ /**
1122
+ * Extract and parse a text fragment from a URL string containing a `text=` parameter.
1123
+ * @param {string} urlString
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|null}
1128
+ */
1129
+ static fromUrl(urlString, book, fallbackPageIndex) {
1130
+ // Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
1131
+ // commas and colons, so need to do a regex match to extract the text fragment string
1132
+ const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
1133
+ if (!textMatch) return null;
1134
+ return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
1135
+ }
1136
+
1137
+ /**
1138
+ * Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
1139
+ * browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
1140
+ * Note the ':' and ',' separators must not and are not encoded, but
1141
+ * the pageNumber, prefix, quote, and suffix text are encoded.
1142
+ * @returns {string}
1143
+ */
1144
+ toUrlString() {
1145
+ // First the page number or index
1146
+ let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
1147
+
1148
+ if (this.prefix) {
1149
+ str += `${encodeURIComponent(this.prefix)}-,`;
1150
+ }
1151
+ str += encodeURIComponent(this.quote);
1152
+ if (this.suffix) {
1153
+ str += `,-${encodeURIComponent(this.suffix)}`;
1154
+ }
1155
+ return str;
1156
+ }
1157
+
1158
+ toJSON() {
1159
+ return {
1160
+ prefix: this.prefix,
1161
+ quote: this.quote,
1162
+ suffix: this.suffix,
1163
+ pageNumber: this.pageNumber,
1164
+ pageIndex: this.pageIndex,
1165
+ uuid: this.uuid,
1166
+ };
1167
+ }
1168
+
1169
+ /**
1170
+ * Create a BookReaderTextFragment instance from a JSON object.
1171
+ * @param {object} json
1172
+ * @returns {BookReaderTextFragment}
1173
+ */
1174
+ static fromJSON(json) {
1175
+ return new BookReaderTextFragment(json);
1176
+ }
1177
+
1178
+ /**
1179
+ * Builds a TextFragment string from a given text selection.
1180
+ * @param {Selection} selection currently selected text, eg `document.getSelection()`
1181
+ * @param {HTMLElement[]} contextElements elements providing context for the selection
1182
+ * @returns {BookReaderTextFragment}
1183
+ */
1184
+ static fromSelection(selection, contextElements) {
1185
+ const range = selection.getRangeAt(0);
1186
+
1187
+ // Partially selected words need to be captured completely
1188
+ const fullQuoteRange = new Range();
1189
+ const startTextNode = getLastMostNode(range.startContainer);
1190
+ const endTextNode = getFirstMostNode(range.endContainer);
1191
+ fullQuoteRange.setStart(startTextNode, 0);
1192
+ fullQuoteRange.setEnd(endTextNode, range.endOffset == 0 ? 0 : (endTextNode.textContent?.length ?? 0));
1193
+
1194
+ const preStartRange = document.createRange();
1195
+ preStartRange.setStart(getFirstMostNode(contextElements[0]), 0);
1196
+ preStartRange.setEnd(startTextNode, 0);
1197
+
1198
+ const postEndRange = document.createRange();
1199
+ postEndRange.setStart(endTextNode, fullQuoteRange.endOffset);
1200
+ const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
1201
+ postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
1202
+
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, "");
1212
+
1213
+ // 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();
1215
+ const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
1216
+
1217
+ return new BookReaderTextFragment({
1218
+ quote,
1219
+ prefix,
1220
+ suffix,
1221
+ pageNumber: pageContainerEl.getAttribute("data-page-num"),
1222
+ pageIndex: parseFloat(pageContainerEl.getAttribute("data-index")),
1223
+ });
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * @typedef {BookReaderTextFragment & { uuid: string }} BookReaderSavedHighlight
1229
+ */