@internetarchive/bookreader 5.0.0-113 → 5.0.0-115
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 +34 -10
- package/BookReader/BookReader.js +1 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +20 -38
- 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 +1 -1
- package/BookReader/plugins/plugin.chapters.js.map +1 -1
- package/BookReader/plugins/plugin.experiments.js +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 +1 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +1 -1
- 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.map +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/package.json +37 -31
- package/src/BookReader/utils/SelectionObserver.js +4 -2
- package/src/BookReader.js +2 -1
- package/src/css/_TextSelection.scss +32 -11
- package/src/ia-bookreader/ia-bookreader.js +13 -2
- package/src/plugins/plugin.experiments.js +13 -3
- package/src/plugins/plugin.iiif.js +7 -1
- package/src/plugins/plugin.text_selection.js +8 -1
- package/src/plugins/tts/WebTTSEngine.js +2 -2
- package/src/plugins/tts/plugin.tts.js +3 -3
- package/src/plugins/tts/utils.js +0 -19
- package/src/util/TextSelectionManager.js +276 -110
- package/src/util/browserSniffing.js +9 -0
- package/src/util/strings.js +10 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { countWords } from './strings.js';
|
|
2
3
|
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
4
|
+
import { clamp } from "../BookReader/utils.js";
|
|
3
5
|
import { html, LitElement } from 'lit';
|
|
4
6
|
import { customElement, property, query } from 'lit/decorators.js';
|
|
5
7
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
6
8
|
import '@internetarchive/icon-share';
|
|
7
9
|
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
10
|
+
import { isIOS, isAndroid } from './browserSniffing.js';
|
|
8
11
|
|
|
9
12
|
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
13
|
+
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
14
|
+
const TRUNCATED_QUOTE_WORD_COUNT = 3;
|
|
10
15
|
|
|
11
16
|
export class TextSelectionManager {
|
|
12
17
|
options = {
|
|
@@ -52,15 +57,12 @@ export class TextSelectionManager {
|
|
|
52
57
|
// Set a class on the page to avoid hiding it when zooming/etc
|
|
53
58
|
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
54
59
|
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
// Don't show while still doing selection
|
|
61
|
+
if (!this.mouseIsDown) this.showSelectMenu();
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
if (selectEvent == 'changed') {
|
|
60
|
-
|
|
61
|
-
if (this.mouseIsDown) {
|
|
62
|
-
this.hideSelectMenu();
|
|
63
|
-
} else if (window.getSelection()?.toString()) {
|
|
65
|
+
if (!this.mouseIsDown && window.getSelection()?.toString()) {
|
|
64
66
|
this.showSelectMenu();
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -162,7 +164,6 @@ export class TextSelectionManager {
|
|
|
162
164
|
// blocking selection
|
|
163
165
|
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
164
166
|
this.mouseIsDown = true;
|
|
165
|
-
this.hideSelectMenu();
|
|
166
167
|
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
167
168
|
event.stopPropagation();
|
|
168
169
|
}
|
|
@@ -170,7 +171,6 @@ export class TextSelectionManager {
|
|
|
170
171
|
|
|
171
172
|
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
172
173
|
this.mouseIsDown = false;
|
|
173
|
-
this.hideSelectMenu();
|
|
174
174
|
textLayer.style.pointerEvents = "none";
|
|
175
175
|
if (skipNextMouseup) {
|
|
176
176
|
skipNextMouseup = false;
|
|
@@ -196,7 +196,6 @@ export class TextSelectionManager {
|
|
|
196
196
|
if (event.which != 1) return;
|
|
197
197
|
this.mouseIsDown = true;
|
|
198
198
|
event.stopPropagation();
|
|
199
|
-
this.hideSelectMenu();
|
|
200
199
|
});
|
|
201
200
|
|
|
202
201
|
// Prevent page flip on click
|
|
@@ -218,7 +217,11 @@ export class TextSelectionManager {
|
|
|
218
217
|
document.body.append(this.selectMenu);
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
this.selectMenu.
|
|
220
|
+
if (this.selectMenu.open) {
|
|
221
|
+
this.selectMenu.repositionToSelection();
|
|
222
|
+
} else {
|
|
223
|
+
this.selectMenu.show();
|
|
224
|
+
}
|
|
222
225
|
}
|
|
223
226
|
|
|
224
227
|
hideSelectMenu() {
|
|
@@ -456,6 +459,9 @@ class BRSelectMenu extends LitElement {
|
|
|
456
459
|
@query('#copy-link-option')
|
|
457
460
|
copyLinkOption;
|
|
458
461
|
|
|
462
|
+
@property({type: Boolean, reflect: true})
|
|
463
|
+
open = false;
|
|
464
|
+
|
|
459
465
|
@property({type: Boolean})
|
|
460
466
|
copyLinkToHighlightEnabled = true;
|
|
461
467
|
|
|
@@ -476,6 +482,19 @@ class BRSelectMenu extends LitElement {
|
|
|
476
482
|
return this;
|
|
477
483
|
}
|
|
478
484
|
|
|
485
|
+
/** @type {number | null} */
|
|
486
|
+
_scrollTimeoutId = null;
|
|
487
|
+
|
|
488
|
+
_onScroll = () => {
|
|
489
|
+
this.classList.add('br-select-menu__root--scrolling');
|
|
490
|
+
if (this._scrollTimeoutId != null) clearTimeout(this._scrollTimeoutId);
|
|
491
|
+
this._scrollTimeoutId = window.setTimeout(() => {
|
|
492
|
+
this._scrollTimeoutId = null;
|
|
493
|
+
this.repositionToSelection();
|
|
494
|
+
this.classList.remove('br-select-menu__root--scrolling');
|
|
495
|
+
}, 150);
|
|
496
|
+
};
|
|
497
|
+
|
|
479
498
|
/** @override */
|
|
480
499
|
connectedCallback() {
|
|
481
500
|
super.connectedCallback();
|
|
@@ -484,10 +503,22 @@ class BRSelectMenu extends LitElement {
|
|
|
484
503
|
this.setAttribute('aria-orientation', 'vertical');
|
|
485
504
|
}
|
|
486
505
|
|
|
506
|
+
/** @override */
|
|
507
|
+
disconnectedCallback() {
|
|
508
|
+
super.disconnectedCallback();
|
|
509
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
510
|
+
if (this._scrollTimeoutId != null) {
|
|
511
|
+
clearTimeout(this._scrollTimeoutId);
|
|
512
|
+
this._scrollTimeoutId = null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
487
516
|
renderCopyLinkToHighlightOption() {
|
|
517
|
+
// Mousedown needed to prevent selection from being cleared on iOS
|
|
488
518
|
return html`
|
|
489
519
|
<br-menu-option
|
|
490
520
|
id="copy-link-option"
|
|
521
|
+
@mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
|
|
491
522
|
@click=${this.handleCopyLinkToHighlight}
|
|
492
523
|
icon="share"
|
|
493
524
|
label="Copy Link to Highlight"
|
|
@@ -555,8 +586,14 @@ class BRSelectMenu extends LitElement {
|
|
|
555
586
|
async handleCopyLinkToHighlight(e) {
|
|
556
587
|
e.preventDefault();
|
|
557
588
|
const currentParams = this.br.readQueryString();
|
|
558
|
-
const currentSelection = window.getSelection();
|
|
559
|
-
const
|
|
589
|
+
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
590
|
+
const range = currentSelection.getRangeAt(0);
|
|
591
|
+
const textLayer = this.getNodeTextLayer(range.startContainer);
|
|
592
|
+
if (!textLayer) {
|
|
593
|
+
console.warn("No text layer found for selection");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
|
|
560
597
|
|
|
561
598
|
// Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
|
|
562
599
|
let linkToHighlightParams = currentParams;
|
|
@@ -579,11 +616,11 @@ class BRSelectMenu extends LitElement {
|
|
|
579
616
|
/**
|
|
580
617
|
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
581
618
|
* @param {Node} node
|
|
582
|
-
* @returns {
|
|
619
|
+
* @returns {Element | null}
|
|
583
620
|
*/
|
|
584
621
|
getNodeTextLayer(node) {
|
|
585
|
-
if (!node) return;
|
|
586
|
-
const element =
|
|
622
|
+
if (!node) return null;
|
|
623
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
587
624
|
return element?.closest('.BRtextLayer') ?? null;
|
|
588
625
|
}
|
|
589
626
|
|
|
@@ -694,31 +731,54 @@ class BRSelectMenu extends LitElement {
|
|
|
694
731
|
this.show();
|
|
695
732
|
}
|
|
696
733
|
|
|
697
|
-
|
|
734
|
+
repositionToSelection() {
|
|
735
|
+
const SCREEN_MARGIN = 10;
|
|
736
|
+
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
737
|
+
const range = currentSelection.getRangeAt(0);
|
|
738
|
+
const selectionRect = range.getBoundingClientRect();
|
|
739
|
+
|
|
740
|
+
const isMobile = isIOS() || isAndroid();
|
|
741
|
+
// Leave room for mobile teardrop selection handles
|
|
742
|
+
const SELECTION_PADDING = isAndroid() ? 25 : isIOS() ? 15 : 10;
|
|
743
|
+
// On mobile, avoid the native OS text-selection bar which appears near the top of the screen
|
|
744
|
+
const OS_BAR_H = isMobile ? 60 : 0;
|
|
745
|
+
const idealTop = (selectionRect.top < OS_BAR_H
|
|
746
|
+
? selectionRect.bottom + OS_BAR_H
|
|
747
|
+
: selectionRect.bottom) + SELECTION_PADDING;
|
|
748
|
+
const top = Math.max(SCREEN_MARGIN, idealTop) + window.scrollY;
|
|
749
|
+
|
|
750
|
+
const menuWidth = this.getBoundingClientRect().width;
|
|
751
|
+
// Excludes scrollbars
|
|
752
|
+
const windowWidth = document.documentElement.clientWidth;
|
|
753
|
+
const idealLeft = menuWidth > selectionRect.width
|
|
754
|
+
? selectionRect.left + selectionRect.width / 2 - menuWidth / 2
|
|
755
|
+
: selectionRect.left;
|
|
756
|
+
const left = clamp(idealLeft, SCREEN_MARGIN, windowWidth - menuWidth - SCREEN_MARGIN) + window.scrollX;
|
|
757
|
+
|
|
758
|
+
this.style.top = `${top}px`;
|
|
759
|
+
this.style.left = `${left}px`;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async show() {
|
|
698
763
|
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
699
|
-
|
|
700
|
-
const start = currentSelection.anchorNode.parentElement;
|
|
701
|
-
const end = currentSelection.focusNode.parentElement; // will always be a text node
|
|
702
|
-
const height = 30;
|
|
703
|
-
const width = 60;
|
|
704
|
-
const startBoundingRect = start.getBoundingClientRect();
|
|
705
|
-
const endBoundingRect = end.getBoundingClientRect();
|
|
706
|
-
let hlButtonTop = startBoundingRect.top - height;
|
|
707
|
-
let hlButtonLeft = startBoundingRect.left - width;
|
|
708
|
-
if (currentSelection.direction == 'backward') {
|
|
709
|
-
hlButtonTop = endBoundingRect.top - height;
|
|
710
|
-
hlButtonLeft = endBoundingRect.left - width;
|
|
711
|
-
}
|
|
712
|
-
this.style.top = `${hlButtonTop}px`;
|
|
713
|
-
this.style.left = `${hlButtonLeft}px`;
|
|
764
|
+
|
|
714
765
|
this.style.zIndex = '1';
|
|
715
766
|
this.style.position = 'absolute';
|
|
716
767
|
this.style.display = 'block';
|
|
768
|
+
this.open = true;
|
|
769
|
+
this.classList.remove('br-select-menu__root--scrolling');
|
|
770
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
771
|
+
window.addEventListener('scroll', this._onScroll, { capture: true, passive: true });
|
|
772
|
+
await this.updateComplete; // ensure Lit has rendered so getBoundingClientRect() returns real width
|
|
773
|
+
this.repositionToSelection();
|
|
717
774
|
this.clearNodesForRemoval();
|
|
718
775
|
}
|
|
719
776
|
|
|
720
|
-
hide
|
|
777
|
+
hide() {
|
|
778
|
+
if (!this.open) return;
|
|
721
779
|
this.style.display = 'none';
|
|
780
|
+
this.open = false;
|
|
781
|
+
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
722
782
|
this.clearNodesForRemoval();
|
|
723
783
|
return;
|
|
724
784
|
}
|
|
@@ -788,53 +848,62 @@ function replaceWhitespace(string) {
|
|
|
788
848
|
}
|
|
789
849
|
|
|
790
850
|
/**
|
|
791
|
-
*
|
|
792
|
-
* @param {
|
|
851
|
+
* Finds all regex matches within a range and returns their ranges.
|
|
852
|
+
* @param {RegExp} regex
|
|
793
853
|
* @param {Range} range - the range to search in
|
|
794
854
|
* @param {Node[]} textNodes - visible text nodes within the range
|
|
795
|
-
* @
|
|
796
|
-
*
|
|
797
|
-
* Lightly adapted from
|
|
798
|
-
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L765
|
|
855
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
856
|
+
* @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
|
|
799
857
|
*/
|
|
800
|
-
export function
|
|
858
|
+
export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
|
|
859
|
+
if (!textNodes.length) return undefined;
|
|
860
|
+
|
|
801
861
|
const startOffset = textNodes[0] === range.startContainer ?
|
|
802
862
|
range.startOffset :
|
|
803
863
|
0;
|
|
804
|
-
const normalizedWholePageString =
|
|
805
|
-
const
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
end = getBoundaryPointAtIndex(
|
|
817
|
-
normalizedStartOffset +
|
|
818
|
-
textNodes,
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
864
|
+
const normalizedWholePageString = normalize(range.toString());
|
|
865
|
+
const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
866
|
+
const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
867
|
+
|
|
868
|
+
const matches = Array.from(normalizedWholePageString.matchAll(matchRegex));
|
|
869
|
+
const resultRanges = matches.map((match) => {
|
|
870
|
+
const start = getBoundaryPointAtIndex(
|
|
871
|
+
normalizedStartOffset + match.index,
|
|
872
|
+
textNodes,
|
|
873
|
+
false,
|
|
874
|
+
{ normalize },
|
|
875
|
+
);
|
|
876
|
+
const end = getBoundaryPointAtIndex(
|
|
877
|
+
normalizedStartOffset + match.index + match[0].length,
|
|
878
|
+
textNodes,
|
|
879
|
+
true,
|
|
880
|
+
{ normalize },
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
if (!start || !end) {
|
|
884
|
+
console.warn("Could not find boundary points for regex match, skipping this match", {match, start, end});
|
|
885
|
+
return undefined;
|
|
824
886
|
}
|
|
825
|
-
searchStart = matchedIndex + 1;
|
|
826
|
-
}
|
|
827
|
-
return undefined;
|
|
828
|
-
}
|
|
829
887
|
|
|
888
|
+
const foundRange = new Range();
|
|
889
|
+
foundRange.setStart(start.node, 0);
|
|
890
|
+
foundRange.setEnd(end.node, 1);
|
|
891
|
+
return foundRange;
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
const definedRanges = resultRanges.filter((matchRange) => !!matchRange);
|
|
895
|
+
if (!definedRanges?.length) return undefined;
|
|
896
|
+
return definedRanges;
|
|
897
|
+
}
|
|
830
898
|
|
|
831
899
|
/**
|
|
832
900
|
* Uses the index that matches the quote string and normalizes the string contents to find the correct node
|
|
833
901
|
* @param {Number} index
|
|
834
902
|
* @param {Node[]} nodes
|
|
835
903
|
* @param {boolean} isEnd
|
|
904
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
836
905
|
*/
|
|
837
|
-
export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
906
|
+
export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
|
|
838
907
|
let counted = 0;
|
|
839
908
|
let normalizedData;
|
|
840
909
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -843,7 +912,7 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
843
912
|
// Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
|
|
844
913
|
normalizedData = ' ';
|
|
845
914
|
} else {
|
|
846
|
-
if (!normalizedData) normalizedData =
|
|
915
|
+
if (!normalizedData) normalizedData = normalize(node.textContent);
|
|
847
916
|
}
|
|
848
917
|
let nodeEnd = counted + normalizedData.length;
|
|
849
918
|
if (isEnd) nodeEnd += 1;
|
|
@@ -856,8 +925,8 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
856
925
|
normalizedData.substring(normalizedOffset);
|
|
857
926
|
|
|
858
927
|
let candidateSubstring = isEnd ?
|
|
859
|
-
|
|
860
|
-
|
|
928
|
+
normalize(node.textContent.substring(0, normalizedOffset)) :
|
|
929
|
+
normalize(node.textContent.substring(normalizedOffset));
|
|
861
930
|
|
|
862
931
|
const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
|
|
863
932
|
while (denormalizedOffset >= 0 &&
|
|
@@ -899,52 +968,54 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
899
968
|
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
|
|
900
969
|
*
|
|
901
970
|
* @param {HTMLElement} textLayer
|
|
902
|
-
* @param {BookReaderTextFragment}
|
|
971
|
+
* @param {BookReaderTextFragment} textFragment
|
|
903
972
|
* @param {string | null} cssClassName optional css class to add to the highlight span element
|
|
973
|
+
* @return {Element[]} the elements that were created to highlight the text
|
|
904
974
|
*/
|
|
905
|
-
export function renderHighlight(textLayer,
|
|
975
|
+
export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
906
976
|
// Create a range that encompasses the entire text content
|
|
907
977
|
const firstPageNode = getFirstMostNode(textLayer);
|
|
908
978
|
const lastPageNode = getLastMostNode(textLayer);
|
|
909
979
|
const wholePageRange = new Range();
|
|
910
980
|
wholePageRange.setStart(firstPageNode, 0);
|
|
911
981
|
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);
|
|
982
|
+
const normalize = replaceWhitespace;
|
|
923
983
|
|
|
924
984
|
// Retrieve the text nodes and relevant whitespace elements
|
|
925
985
|
// Need to keep the BRlineElement nodes in between to keep the index count consistent, remove first BRlineElement since text starts from the first real text node
|
|
926
986
|
const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
|
|
927
987
|
pageWordNodes.splice(0, 1);
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
988
|
+
|
|
989
|
+
const broadRanges = findRangeForRegExp(
|
|
990
|
+
textFragment.toRegExp({ normalize, context: true }),
|
|
991
|
+
wholePageRange,
|
|
992
|
+
pageWordNodes,
|
|
993
|
+
{ normalize },
|
|
994
|
+
);
|
|
995
|
+
if (!broadRanges) {
|
|
996
|
+
console.warn("Could not find quote with context in page");
|
|
931
997
|
return;
|
|
932
998
|
}
|
|
933
999
|
|
|
934
1000
|
const broadRangeWordNodes = [];
|
|
935
|
-
for (const el of walkBetweenNodes(
|
|
1001
|
+
for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
|
|
936
1002
|
if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
|
|
937
1003
|
broadRangeWordNodes.push(el);
|
|
938
1004
|
}
|
|
939
1005
|
}
|
|
940
1006
|
|
|
941
1007
|
// At which point the quote should now be unambiguous!
|
|
942
|
-
const
|
|
943
|
-
|
|
1008
|
+
const exactRanges = findRangeForRegExp(
|
|
1009
|
+
textFragment.toRegExp({ normalize, context: false }),
|
|
1010
|
+
broadRanges[0],
|
|
1011
|
+
broadRangeWordNodes,
|
|
1012
|
+
{ normalize },
|
|
1013
|
+
);
|
|
1014
|
+
if (!exactRanges) {
|
|
944
1015
|
throw new Error("Could not find quote in page");
|
|
945
1016
|
}
|
|
946
|
-
const startTextNode = getFirstMostNode(
|
|
947
|
-
const endTextNode = getFirstMostNode(
|
|
1017
|
+
const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
|
|
1018
|
+
const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
|
|
948
1019
|
|
|
949
1020
|
// markRange requires the range to start and end within text nodes
|
|
950
1021
|
const exactRangeTextNodes = new Range();
|
|
@@ -957,11 +1028,11 @@ export function renderHighlight(textLayer, quote, cssClassName = null) {
|
|
|
957
1028
|
return;
|
|
958
1029
|
}
|
|
959
1030
|
|
|
960
|
-
markRange(exactRangeTextNodes, () => {
|
|
1031
|
+
return markRange(exactRangeTextNodes, () => {
|
|
961
1032
|
const mark = document.createElement("mark");
|
|
962
1033
|
mark.classList.add("BRhighlight");
|
|
963
1034
|
if (cssClassName) mark.classList.add(cssClassName);
|
|
964
|
-
if (
|
|
1035
|
+
if (textFragment.uuid) mark.classList.add(textFragment.uuid);
|
|
965
1036
|
return mark;
|
|
966
1037
|
});
|
|
967
1038
|
}
|
|
@@ -1062,24 +1133,31 @@ function retrieveUUID(ele) {
|
|
|
1062
1133
|
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
1063
1134
|
*
|
|
1064
1135
|
* Text fragment string format: `pageNumber:prefix-,quote,-suffix`
|
|
1136
|
+
* Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
|
|
1065
1137
|
* Note the ':' and ',' separators must not be encoded, but
|
|
1066
|
-
* the pageNumber, prefix, quote, and suffix text can be encoded.
|
|
1138
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
|
|
1067
1139
|
*/
|
|
1068
1140
|
export class BookReaderTextFragment {
|
|
1069
1141
|
/**
|
|
1070
1142
|
* @param {object} params
|
|
1071
1143
|
* @param {string | null}params.prefix
|
|
1072
|
-
* @param {string} params.quote
|
|
1144
|
+
* @param {string | null} [params.quote]
|
|
1145
|
+
* @param {string | null} [params.quoteStart]
|
|
1146
|
+
* @param {string | null} [params.quoteEnd]
|
|
1073
1147
|
* @param {string | null} params.suffix
|
|
1074
1148
|
* @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
|
|
1075
1149
|
* @param {number} params.pageIndex Page index; e.g. zero-based index of the page
|
|
1076
1150
|
* @param {string | null} [params.uuid] UUID for the text fragment if it has one
|
|
1077
1151
|
*/
|
|
1078
|
-
constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
|
|
1152
|
+
constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
|
|
1079
1153
|
/** @type {string|null} */
|
|
1080
1154
|
this.prefix = prefix;
|
|
1081
|
-
/** @type {string} */
|
|
1082
|
-
this.quote = quote;
|
|
1155
|
+
/** @type {string | null} */
|
|
1156
|
+
this.quote = quote ?? null;
|
|
1157
|
+
/** @type {string | null} */
|
|
1158
|
+
this.quoteStart = quoteStart ?? null;
|
|
1159
|
+
/** @type {string | null} */
|
|
1160
|
+
this.quoteEnd = quoteEnd ?? null;
|
|
1083
1161
|
/** @type {string|null} */
|
|
1084
1162
|
this.suffix = suffix;
|
|
1085
1163
|
/** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
|
|
@@ -1108,14 +1186,40 @@ export class BookReaderTextFragment {
|
|
|
1108
1186
|
}
|
|
1109
1187
|
const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
|
|
1110
1188
|
const prefix = match[2] ? decodeURIComponent(match[2]) : null;
|
|
1111
|
-
const
|
|
1189
|
+
const quoteRegion = match[3] || '';
|
|
1190
|
+
const quoteParts = quoteRegion.split(',');
|
|
1191
|
+
let quote = null;
|
|
1192
|
+
let quoteStart = null;
|
|
1193
|
+
let quoteEnd = null;
|
|
1194
|
+
|
|
1195
|
+
if (quoteParts.length === 1) {
|
|
1196
|
+
quote = decodeURIComponent(quoteParts[0]);
|
|
1197
|
+
} else if (quoteParts.length === 2) {
|
|
1198
|
+
quoteStart = decodeURIComponent(quoteParts[0]);
|
|
1199
|
+
quoteEnd = decodeURIComponent(quoteParts[1]);
|
|
1200
|
+
} else {
|
|
1201
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1112
1204
|
const suffix = match[4] ? decodeURIComponent(match[4]) : null;
|
|
1113
1205
|
const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
|
|
1114
1206
|
if (typeof pageIndex !== 'number') {
|
|
1115
1207
|
throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
|
|
1116
1208
|
}
|
|
1117
1209
|
|
|
1118
|
-
|
|
1210
|
+
if (!quote && (!quoteStart || !quoteEnd)) {
|
|
1211
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return new BookReaderTextFragment({
|
|
1215
|
+
prefix,
|
|
1216
|
+
quote,
|
|
1217
|
+
quoteStart,
|
|
1218
|
+
quoteEnd,
|
|
1219
|
+
suffix,
|
|
1220
|
+
pageNumber,
|
|
1221
|
+
pageIndex,
|
|
1222
|
+
});
|
|
1119
1223
|
}
|
|
1120
1224
|
|
|
1121
1225
|
/**
|
|
@@ -1137,8 +1241,9 @@ export class BookReaderTextFragment {
|
|
|
1137
1241
|
/**
|
|
1138
1242
|
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
1139
1243
|
* browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
|
|
1244
|
+
* If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
|
|
1140
1245
|
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1141
|
-
|
|
1246
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
|
|
1142
1247
|
* @returns {string}
|
|
1143
1248
|
*/
|
|
1144
1249
|
toUrlString() {
|
|
@@ -1148,17 +1253,66 @@ export class BookReaderTextFragment {
|
|
|
1148
1253
|
if (this.prefix) {
|
|
1149
1254
|
str += `${encodeURIComponent(this.prefix)}-,`;
|
|
1150
1255
|
}
|
|
1151
|
-
|
|
1256
|
+
|
|
1257
|
+
const quote = this.quote?.trim() || null;
|
|
1258
|
+
let shortenedQuoteParts = null;
|
|
1259
|
+
if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
|
|
1260
|
+
const words = quote.match(/\S+/g) || [];
|
|
1261
|
+
if (words.length >= TRUNCATED_QUOTE_WORD_COUNT * 2) {
|
|
1262
|
+
const quoteStart = getFirstWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1263
|
+
const quoteEnd = getLastWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1264
|
+
if (quoteStart && quoteEnd) {
|
|
1265
|
+
shortenedQuoteParts = { quoteStart, quoteEnd };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (quote && !shortenedQuoteParts) {
|
|
1271
|
+
str += encodeURIComponent(quote);
|
|
1272
|
+
} else if (shortenedQuoteParts) {
|
|
1273
|
+
str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
|
|
1274
|
+
} else if (this.quoteStart && this.quoteEnd) {
|
|
1275
|
+
str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
|
|
1276
|
+
} else {
|
|
1277
|
+
throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1152
1280
|
if (this.suffix) {
|
|
1153
1281
|
str += `,-${encodeURIComponent(this.suffix)}`;
|
|
1154
1282
|
}
|
|
1155
1283
|
return str;
|
|
1156
1284
|
}
|
|
1157
1285
|
|
|
1286
|
+
/**
|
|
1287
|
+
* Build a regex that matches this quote payload.
|
|
1288
|
+
* @param {{ normalize?: function(string): string, context?: boolean }} [options]
|
|
1289
|
+
* @returns {RegExp}
|
|
1290
|
+
*/
|
|
1291
|
+
toRegExp({ normalize = (s) => s, context = false } = {}) {
|
|
1292
|
+
/** @type {[String] | [String, String]} */
|
|
1293
|
+
const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
|
|
1294
|
+
|
|
1295
|
+
if (context) {
|
|
1296
|
+
if (this.prefix) quotes[0] = this.prefix + ' ' + quotes[0];
|
|
1297
|
+
if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
if (quotes.length === 1) {
|
|
1301
|
+
return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
|
|
1302
|
+
} else {
|
|
1303
|
+
return new RegExp(
|
|
1304
|
+
RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
|
|
1305
|
+
'g',
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1158
1310
|
toJSON() {
|
|
1159
1311
|
return {
|
|
1160
1312
|
prefix: this.prefix,
|
|
1161
1313
|
quote: this.quote,
|
|
1314
|
+
quoteStart: this.quoteStart,
|
|
1315
|
+
quoteEnd: this.quoteEnd,
|
|
1162
1316
|
suffix: this.suffix,
|
|
1163
1317
|
pageNumber: this.pageNumber,
|
|
1164
1318
|
pageIndex: this.pageIndex,
|
|
@@ -1178,7 +1332,7 @@ export class BookReaderTextFragment {
|
|
|
1178
1332
|
/**
|
|
1179
1333
|
* Builds a TextFragment string from a given text selection.
|
|
1180
1334
|
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
1181
|
-
* @param {
|
|
1335
|
+
* @param {Element[]} contextElements elements providing context for the selection
|
|
1182
1336
|
* @returns {BookReaderTextFragment}
|
|
1183
1337
|
*/
|
|
1184
1338
|
static fromSelection(selection, contextElements) {
|
|
@@ -1200,18 +1354,30 @@ export class BookReaderTextFragment {
|
|
|
1200
1354
|
const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
|
|
1201
1355
|
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
1202
1356
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1357
|
+
const CONTEXT_WORD_COUNT = 3;
|
|
1358
|
+
|
|
1359
|
+
const preStartText = replaceWhitespace(preStartRange.toString());
|
|
1360
|
+
let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
|
|
1361
|
+
let prefixWords = countWords(prefix);
|
|
1362
|
+
|
|
1363
|
+
const postEndText = replaceWhitespace(postEndRange.toString());
|
|
1364
|
+
let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
|
|
1365
|
+
let suffixWords = countWords(suffix);
|
|
1366
|
+
|
|
1367
|
+
if (prefixWords < CONTEXT_WORD_COUNT) {
|
|
1368
|
+
// Ran out of words! To reduce the risk of ambiguity, try extending suffix
|
|
1369
|
+
suffix = getFirstWords(2 * CONTEXT_WORD_COUNT - prefixWords, postEndText);
|
|
1370
|
+
suffixWords = countWords(suffix);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (suffixWords < CONTEXT_WORD_COUNT) {
|
|
1374
|
+
// Ran out of words on the suffix side as well, try extending prefix
|
|
1375
|
+
prefix = getLastWords(2 * CONTEXT_WORD_COUNT - suffixWords, preStartText);
|
|
1376
|
+
prefixWords = countWords(prefix);
|
|
1377
|
+
}
|
|
1212
1378
|
|
|
1213
1379
|
// 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()
|
|
1380
|
+
const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
|
|
1215
1381
|
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1216
1382
|
|
|
1217
1383
|
return new BookReaderTextFragment({
|
|
@@ -51,6 +51,15 @@ export function isIOS() {
|
|
|
51
51
|
return 'ongesturestart' in window && navigator.maxTouchPoints > 0;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Checks whether the current browser is on android
|
|
56
|
+
* @param {string} [userAgent]
|
|
57
|
+
* @return {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isAndroid(userAgent = navigator.userAgent) {
|
|
60
|
+
return /android/i.test(userAgent);
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
/**
|
|
55
64
|
* Checks whether the current browser is Samsung Internet
|
|
56
65
|
* https://stackoverflow.com/a/40684162/2317712
|
package/src/util/strings.js
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* supported list of filters)
|
|
6
6
|
**/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Count whitespace-delimited words in a string.
|
|
10
|
+
* @param {string} text
|
|
11
|
+
* @return {number}
|
|
12
|
+
*/
|
|
13
|
+
export function countWords(text) {
|
|
14
|
+
const matches = text.match(/\S+/g);
|
|
15
|
+
return matches ? matches.length : 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
/**
|
|
9
19
|
* @param {StringWithVars|String} template
|
|
10
20
|
* @param { {[varName: string]: { toString: () => string} } } vars
|