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