@internetarchive/bookreader 5.0.0-115 → 5.0.0-117
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/BookReader.css +44 -18
- package/BookReader/BookReader.js +1 -43
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +109 -67
- package/BookReader/ia-bookreader-bundle.js.map +1 -1
- package/BookReader/plugins/plugin.archive_analytics.js +1 -1
- package/BookReader/plugins/plugin.autoplay.js +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 +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 +1 -1
- package/BookReader/plugins/plugin.resume.js +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 +63 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +65 -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/README.md +3 -1
- package/jsconfig.json +2 -3
- package/package.json +38 -31
- package/src/BookReader.js +24 -20
- package/src/css/_TextSelection.scss +51 -18
- package/src/plugins/plugin.chapters.js +8 -4
- package/src/plugins/plugin.iframe.js +36 -37
- package/src/plugins/plugin.text_selection.js +40 -101
- package/src/plugins/search/plugin.search.js +4 -8
- package/src/plugins/tts/plugin.tts.js +3 -8
- package/src/plugins/url/UrlPlugin.js +12 -0
- package/src/util/TextSelectionManager.js +221 -154
- package/src/util/dom.js +88 -0
- package/src/util/generators.js +89 -0
|
@@ -7,7 +7,9 @@ import { customElement, property, query } from 'lit/decorators.js';
|
|
|
7
7
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
8
8
|
import '@internetarchive/icon-share';
|
|
9
9
|
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
10
|
+
import '@internetarchive/icon-ellipses';
|
|
10
11
|
import { isIOS, isAndroid } from './browserSniffing.js';
|
|
12
|
+
import { genAt, genFilter } from './generators.js';
|
|
11
13
|
|
|
12
14
|
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
13
15
|
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
@@ -247,8 +249,8 @@ export class TextSelectionManager {
|
|
|
247
249
|
// Find the last allowed word in the selection
|
|
248
250
|
const lastAllowedWord = genAt(
|
|
249
251
|
genFilter(
|
|
250
|
-
|
|
251
|
-
(node) => node.classList
|
|
252
|
+
treeWalkRange(range, 'element'),
|
|
253
|
+
(node) => node.classList.contains(
|
|
252
254
|
this.selectionElement[0].replace(".", ""),
|
|
253
255
|
),
|
|
254
256
|
),
|
|
@@ -267,55 +269,58 @@ export class TextSelectionManager {
|
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
/**
|
|
270
|
-
* @
|
|
271
|
-
*
|
|
272
|
-
* @param {
|
|
273
|
-
* @
|
|
272
|
+
* @overload
|
|
273
|
+
* @param {Range} range
|
|
274
|
+
* @param {'text'} filter
|
|
275
|
+
* @returns {Generator<Text>}
|
|
274
276
|
*/
|
|
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
277
|
/**
|
|
287
|
-
* @
|
|
288
|
-
*
|
|
289
|
-
* @param {
|
|
290
|
-
* @
|
|
278
|
+
* @overload
|
|
279
|
+
* @param {Range} range
|
|
280
|
+
* @param {'element'} filter
|
|
281
|
+
* @returns {Generator<Element>}
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* @overload
|
|
285
|
+
* @param {Range} range
|
|
286
|
+
* @param {'text+element'} [filter]
|
|
287
|
+
* @returns {Generator<Text | Element>}
|
|
291
288
|
*/
|
|
292
|
-
export function* genFilter(iterable, fn) {
|
|
293
|
-
for (const x of iterable) {
|
|
294
|
-
if (fn(x)) yield x;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
289
|
/**
|
|
299
290
|
* Depth traverse the DOM tree starting at `start`, and ending at `end`.
|
|
300
|
-
* @param {
|
|
301
|
-
* @param {
|
|
291
|
+
* @param {Range} range
|
|
292
|
+
* @param {'text' | 'element' | 'text+element'} [filter]
|
|
302
293
|
* @returns {Generator<Node>}
|
|
303
294
|
*/
|
|
304
|
-
export function*
|
|
295
|
+
export function* treeWalkRange(range, filter = 'text+element') {
|
|
296
|
+
const start = range.startContainer;
|
|
297
|
+
const end = range.endContainer;
|
|
305
298
|
let done = false;
|
|
306
299
|
|
|
307
300
|
/**
|
|
308
301
|
* @param {Node} node
|
|
309
302
|
*/
|
|
303
|
+
const passesFilter = node => {
|
|
304
|
+
return (
|
|
305
|
+
(filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
|
|
306
|
+
(filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
|
|
307
|
+
(filter === 'text+element')
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {Node} node
|
|
313
|
+
* @returns {Generator<Node>}
|
|
314
|
+
*/
|
|
310
315
|
function* walk(node, {children = true, parents = true, siblings = true} = {}) {
|
|
311
316
|
if (node === end) {
|
|
312
317
|
done = true;
|
|
313
|
-
yield node;
|
|
318
|
+
if (passesFilter(node)) yield node;
|
|
314
319
|
return;
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
// yield self
|
|
318
|
-
yield node;
|
|
323
|
+
if (passesFilter(node)) yield node;
|
|
319
324
|
|
|
320
325
|
// First iterate children (depth-first traversal)
|
|
321
326
|
if (children && node.firstChild) {
|
|
@@ -408,6 +413,9 @@ export class BRSelectMenuOption extends LitElement {
|
|
|
408
413
|
if (this.icon === 'edit-pencil') {
|
|
409
414
|
return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
|
|
410
415
|
}
|
|
416
|
+
if (this.icon === 'ellipses') {
|
|
417
|
+
return html`<ia-icon-ellipses class="br-select-menu__icon" aria-hidden="true"></ia-icon-ellipses>`;
|
|
418
|
+
}
|
|
411
419
|
return '';
|
|
412
420
|
}
|
|
413
421
|
|
|
@@ -429,7 +437,9 @@ export class BRSelectMenuOption extends LitElement {
|
|
|
429
437
|
aria-label=${ifDefined(accessibleLabel)}
|
|
430
438
|
>
|
|
431
439
|
${this.renderIcon()}
|
|
432
|
-
${
|
|
440
|
+
${
|
|
441
|
+
!hasTemporaryText && !baseLabel ? '' :
|
|
442
|
+
hasTemporaryText ? html`
|
|
433
443
|
<span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
|
|
434
444
|
<span
|
|
435
445
|
class="br-select-menu__label"
|
|
@@ -455,8 +465,12 @@ class BRSelectMenu extends LitElement {
|
|
|
455
465
|
/** @type {import('../BookReader.js').default} */
|
|
456
466
|
br;
|
|
457
467
|
|
|
468
|
+
/** @type {boolean} */
|
|
469
|
+
@property({type: Boolean, reflect: true})
|
|
470
|
+
showExtended = false;
|
|
471
|
+
|
|
458
472
|
/** @type {BRSelectMenuOption | null} */
|
|
459
|
-
@query('#copy-link-option')
|
|
473
|
+
@query('#br-select-copy-link-option')
|
|
460
474
|
copyLinkOption;
|
|
461
475
|
|
|
462
476
|
@property({type: Boolean, reflect: true})
|
|
@@ -517,7 +531,7 @@ class BRSelectMenu extends LitElement {
|
|
|
517
531
|
// Mousedown needed to prevent selection from being cleared on iOS
|
|
518
532
|
return html`
|
|
519
533
|
<br-menu-option
|
|
520
|
-
id="copy-link-option"
|
|
534
|
+
id="br-select-copy-link-option"
|
|
521
535
|
@mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
|
|
522
536
|
@click=${this.handleCopyLinkToHighlight}
|
|
523
537
|
icon="share"
|
|
@@ -556,6 +570,19 @@ class BRSelectMenu extends LitElement {
|
|
|
556
570
|
></br-menu-option>
|
|
557
571
|
`;
|
|
558
572
|
}
|
|
573
|
+
|
|
574
|
+
renderShowMoreOption() {
|
|
575
|
+
return html`
|
|
576
|
+
<br-menu-option
|
|
577
|
+
id="br-select-more"
|
|
578
|
+
@mousedown=${/** @param {MouseEvent} e */ (e) => e.preventDefault()}
|
|
579
|
+
@click=${this.toggleExtendedMenu}
|
|
580
|
+
icon="ellipses"
|
|
581
|
+
aria-label="Show more options"
|
|
582
|
+
></br-menu-option>
|
|
583
|
+
`;
|
|
584
|
+
}
|
|
585
|
+
|
|
559
586
|
renderLocalStorageOptions() {
|
|
560
587
|
return html`
|
|
561
588
|
<br-menu-option
|
|
@@ -570,22 +597,35 @@ class BRSelectMenu extends LitElement {
|
|
|
570
597
|
></br-menu-option>`;
|
|
571
598
|
}
|
|
572
599
|
|
|
573
|
-
|
|
574
|
-
// TODO change the second button to use a different icon
|
|
600
|
+
renderDefaultOptions() {
|
|
575
601
|
return html`
|
|
576
602
|
${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
|
|
577
603
|
${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
|
|
578
|
-
${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
|
|
579
604
|
${this.nodesForRemoval ? this.renderRemoveOption() : ''}
|
|
580
605
|
`;
|
|
581
606
|
}
|
|
582
607
|
|
|
608
|
+
renderExtendedOptions() {
|
|
609
|
+
return html`
|
|
610
|
+
${this.renderDefaultOptions()}
|
|
611
|
+
${this.renderLocalStorageOptions()}
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
render() {
|
|
616
|
+
const hasMoreOptions = this.br.plugins.experiments?.isEnabled('annotateHighlight');
|
|
617
|
+
// TODO change the second button to use a different icon
|
|
618
|
+
return html`
|
|
619
|
+
${this.showExtended ? this.renderExtendedOptions() : this.renderDefaultOptions()}
|
|
620
|
+
${!this.showExtended && hasMoreOptions ? this.renderShowMoreOption() : ""}
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
583
624
|
/**
|
|
584
625
|
* @param {MouseEvent} e
|
|
585
626
|
*/
|
|
586
627
|
async handleCopyLinkToHighlight(e) {
|
|
587
628
|
e.preventDefault();
|
|
588
|
-
const currentParams = this.br.readQueryString();
|
|
589
629
|
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
590
630
|
const range = currentSelection.getRangeAt(0);
|
|
591
631
|
const textLayer = this.getNodeTextLayer(range.startContainer);
|
|
@@ -595,24 +635,31 @@ class BRSelectMenu extends LitElement {
|
|
|
595
635
|
}
|
|
596
636
|
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
|
|
597
637
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
638
|
+
const currentParams = new URLSearchParams(this.br.readQueryString());
|
|
639
|
+
if (currentParams.has('text')) currentParams.delete('text');
|
|
640
|
+
|
|
641
|
+
let linkToHighlightParams = currentParams.toString();
|
|
642
|
+
// Use custom toUrlString, which decodes the delimiters for better readability
|
|
643
|
+
linkToHighlightParams += (linkToHighlightParams ? '&' : '') + `text=${textFragment.toUrlString()}`;
|
|
644
|
+
|
|
606
645
|
const currentUrl = window.location;
|
|
607
646
|
const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
|
|
608
647
|
const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
|
|
609
648
|
const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
|
|
610
|
-
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}
|
|
649
|
+
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}?${linkToHighlightParams}${hash}`;
|
|
611
650
|
|
|
612
651
|
await navigator.clipboard.writeText(linkToHighlight);
|
|
613
652
|
this.copyLinkOption?.showTemporaryText('Copied!');
|
|
614
653
|
}
|
|
615
654
|
|
|
655
|
+
/**
|
|
656
|
+
* @param {MouseEvent} e
|
|
657
|
+
*/
|
|
658
|
+
toggleExtendedMenu(e) {
|
|
659
|
+
e.preventDefault();
|
|
660
|
+
this.showExtended = !this.showExtended;
|
|
661
|
+
}
|
|
662
|
+
|
|
616
663
|
/**
|
|
617
664
|
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
618
665
|
* @param {Node} node
|
|
@@ -759,12 +806,12 @@ class BRSelectMenu extends LitElement {
|
|
|
759
806
|
this.style.left = `${left}px`;
|
|
760
807
|
}
|
|
761
808
|
|
|
809
|
+
// Will always show the simplified menu when rendered after hiding
|
|
762
810
|
async show() {
|
|
763
811
|
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
764
812
|
|
|
765
813
|
this.style.zIndex = '1';
|
|
766
814
|
this.style.position = 'absolute';
|
|
767
|
-
this.style.display = 'block';
|
|
768
815
|
this.open = true;
|
|
769
816
|
this.classList.remove('br-select-menu__root--scrolling');
|
|
770
817
|
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
@@ -776,7 +823,7 @@ class BRSelectMenu extends LitElement {
|
|
|
776
823
|
|
|
777
824
|
hide() {
|
|
778
825
|
if (!this.open) return;
|
|
779
|
-
this.
|
|
826
|
+
this.showExtended = false;
|
|
780
827
|
this.open = false;
|
|
781
828
|
window.removeEventListener('scroll', this._onScroll, { capture: true });
|
|
782
829
|
this.clearNodesForRemoval();
|
|
@@ -839,12 +886,23 @@ export function getLastMostNode(parent) {
|
|
|
839
886
|
}
|
|
840
887
|
|
|
841
888
|
/**
|
|
842
|
-
*
|
|
889
|
+
* Normalizes text fragment whitespace and removes special delimiter characters
|
|
890
|
+
* to allow for reliable url encoding/decoding
|
|
891
|
+
*
|
|
892
|
+
* Note: It's very important this *does not* change the length of the string to
|
|
893
|
+
* ensure matching works smoothly later.
|
|
894
|
+
*
|
|
843
895
|
* @param {String} string
|
|
844
|
-
* @returns
|
|
845
896
|
*/
|
|
846
|
-
function
|
|
847
|
-
return string.replace(
|
|
897
|
+
export function replaceTextFragmentDelimiters(string) {
|
|
898
|
+
return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* @param {String} s
|
|
903
|
+
*/
|
|
904
|
+
export function collapseWhitespace(s) {
|
|
905
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
848
906
|
}
|
|
849
907
|
|
|
850
908
|
/**
|
|
@@ -861,7 +919,7 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
861
919
|
const startOffset = textNodes[0] === range.startContainer ?
|
|
862
920
|
range.startOffset :
|
|
863
921
|
0;
|
|
864
|
-
const normalizedWholePageString = normalize(range
|
|
922
|
+
const normalizedWholePageString = normalize(textLayerRangeToString(range));
|
|
865
923
|
const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
866
924
|
const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
867
925
|
|
|
@@ -886,8 +944,8 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
886
944
|
}
|
|
887
945
|
|
|
888
946
|
const foundRange = new Range();
|
|
889
|
-
foundRange.setStart(start.node,
|
|
890
|
-
foundRange.setEnd(end.node,
|
|
947
|
+
foundRange.setStart(start.node, start.offset);
|
|
948
|
+
foundRange.setEnd(end.node, end.offset);
|
|
891
949
|
return foundRange;
|
|
892
950
|
});
|
|
893
951
|
|
|
@@ -897,68 +955,74 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
897
955
|
}
|
|
898
956
|
|
|
899
957
|
/**
|
|
900
|
-
*
|
|
958
|
+
* Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
|
|
901
959
|
* @param {Number} index
|
|
902
960
|
* @param {Node[]} nodes
|
|
903
961
|
* @param {boolean} isEnd
|
|
904
962
|
* @param {{ normalize?: function(string): string }} [options]
|
|
905
963
|
*/
|
|
906
964
|
export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
if (
|
|
919
|
-
|
|
920
|
-
const normalizedOffset =
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
965
|
+
// Rename to have consistent wording
|
|
966
|
+
const normalizedTargetIndex = index;
|
|
967
|
+
/** Characters in node-space counted so far */
|
|
968
|
+
let normalizedI = 0;
|
|
969
|
+
|
|
970
|
+
for (const node of nodes) {
|
|
971
|
+
const nodeText = node.textContent || '';
|
|
972
|
+
const normalizedText = normalize(nodeText);
|
|
973
|
+
const nodeNormalizedStart = normalizedI;
|
|
974
|
+
const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
|
|
975
|
+
|
|
976
|
+
if (nodeNormalizedEnd > normalizedTargetIndex) {
|
|
977
|
+
// We have found the node that contains the normalizedTargetIndex we are looking for!
|
|
978
|
+
const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
|
|
979
|
+
|
|
980
|
+
// But the normalized text could be a shorter length than the node text,
|
|
981
|
+
// so we try to continually increase the nodeOffset
|
|
982
|
+
let nodeOffset = Math.min(normalizedOffset, nodeText.length);
|
|
983
|
+
|
|
984
|
+
const normalizedSubstring = isEnd ?
|
|
985
|
+
normalizedText.substring(0, normalizedOffset) :
|
|
986
|
+
normalizedText.substring(normalizedOffset);
|
|
987
|
+
|
|
988
|
+
let nodeSubstring = isEnd ?
|
|
989
|
+
normalize(nodeText.substring(0, normalizedOffset)) :
|
|
990
|
+
normalize(nodeText.substring(normalizedOffset));
|
|
991
|
+
|
|
992
|
+
const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
|
|
993
|
+
while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
|
|
994
|
+
if (nodeSubstring.length === normalizedSubstring.length) {
|
|
995
|
+
return {node, offset: nodeOffset};
|
|
936
996
|
}
|
|
937
|
-
|
|
997
|
+
nodeOffset += direction;
|
|
938
998
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
999
|
+
nodeSubstring = isEnd ?
|
|
1000
|
+
normalize(nodeText.substring(0, nodeOffset)) :
|
|
1001
|
+
normalize(nodeText.substring(nodeOffset));
|
|
942
1002
|
}
|
|
943
1003
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (i + 1 < nodes.length) {
|
|
947
|
-
const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
|
|
948
|
-
// Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
|
|
949
|
-
// 1st case explicitly check the node class to prevent double counted spaces
|
|
950
|
-
// 2nd case can happen from node traversal when loading from localStorage
|
|
951
|
-
if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
|
|
952
|
-
counted -= 1;
|
|
953
|
-
} else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
|
|
954
|
-
counted -= 1;
|
|
955
|
-
}
|
|
956
|
-
normalizedData = nextNormalizedData;
|
|
957
|
-
}
|
|
1004
|
+
normalizedI += normalizedText.length;
|
|
958
1005
|
}
|
|
959
1006
|
return undefined;
|
|
960
1007
|
}
|
|
961
1008
|
|
|
1009
|
+
/**
|
|
1010
|
+
* @param {Range} range
|
|
1011
|
+
*/
|
|
1012
|
+
export function textLayerRangeToString(range) {
|
|
1013
|
+
const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
|
|
1014
|
+
let result = textNodes.map(node => node.textContent).join('');
|
|
1015
|
+
const firstTextNode = textNodes[0];
|
|
1016
|
+
const lastTextNode = textNodes[textNodes.length - 1];
|
|
1017
|
+
if (range.startContainer === firstTextNode) {
|
|
1018
|
+
result = result.substring(range.startOffset);
|
|
1019
|
+
}
|
|
1020
|
+
if (range.endContainer === lastTextNode) {
|
|
1021
|
+
result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
|
|
1022
|
+
}
|
|
1023
|
+
return result;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
962
1026
|
/**
|
|
963
1027
|
* Takes a text quote object and a container element, and wraps the quote
|
|
964
1028
|
* within the container element in a span to apply highlight-like styling
|
|
@@ -979,37 +1043,29 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
|
979
1043
|
const wholePageRange = new Range();
|
|
980
1044
|
wholePageRange.setStart(firstPageNode, 0);
|
|
981
1045
|
wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
|
|
982
|
-
const normalize = replaceWhitespace;
|
|
983
1046
|
|
|
984
|
-
|
|
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
|
|
986
|
-
const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
|
|
987
|
-
pageWordNodes.splice(0, 1);
|
|
1047
|
+
const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
|
|
988
1048
|
|
|
989
1049
|
const broadRanges = findRangeForRegExp(
|
|
990
|
-
textFragment.toRegExp({
|
|
1050
|
+
textFragment.toRegExp({ context: true }),
|
|
991
1051
|
wholePageRange,
|
|
992
1052
|
pageWordNodes,
|
|
993
|
-
{ normalize },
|
|
1053
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
994
1054
|
);
|
|
995
1055
|
if (!broadRanges) {
|
|
996
1056
|
console.warn("Could not find quote with context in page");
|
|
997
1057
|
return;
|
|
998
1058
|
}
|
|
999
1059
|
|
|
1000
|
-
const broadRangeWordNodes = [];
|
|
1001
|
-
for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
|
|
1002
|
-
if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
|
|
1003
|
-
broadRangeWordNodes.push(el);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1060
|
+
const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
|
|
1006
1061
|
|
|
1007
1062
|
// At which point the quote should now be unambiguous!
|
|
1063
|
+
// FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
|
|
1008
1064
|
const exactRanges = findRangeForRegExp(
|
|
1009
|
-
textFragment.toRegExp({
|
|
1065
|
+
textFragment.toRegExp({ context: false }),
|
|
1010
1066
|
broadRanges[0],
|
|
1011
1067
|
broadRangeWordNodes,
|
|
1012
|
-
{ normalize },
|
|
1068
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
1013
1069
|
);
|
|
1014
1070
|
if (!exactRanges) {
|
|
1015
1071
|
throw new Error("Could not find quote in page");
|
|
@@ -1037,6 +1093,14 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
|
1037
1093
|
});
|
|
1038
1094
|
}
|
|
1039
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* @param {Text} node
|
|
1098
|
+
*/
|
|
1099
|
+
export function isBRVisibleTextNode(node) {
|
|
1100
|
+
// Exclude the duplicated spaces between words for MS Edge
|
|
1101
|
+
return !node.previousElementSibling?.classList.contains("BRspace");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1040
1104
|
/**
|
|
1041
1105
|
* Given a Range, wraps its text contents in one or more <mark> elements.
|
|
1042
1106
|
* <mark> elements can't cross block boundaries, so this function walks the
|
|
@@ -1222,39 +1286,33 @@ export class BookReaderTextFragment {
|
|
|
1222
1286
|
});
|
|
1223
1287
|
}
|
|
1224
1288
|
|
|
1225
|
-
/**
|
|
1226
|
-
* Extract and parse a text fragment from a URL string containing a `text=` parameter.
|
|
1227
|
-
* @param {string} urlString
|
|
1228
|
-
* @param {import('@/src/BookReader/BookModel.js').BookModel} book
|
|
1229
|
-
* @param {number} fallbackPageIndex A fallback page index to use if the text
|
|
1230
|
-
* fragment does not specify a page number or page index.
|
|
1231
|
-
* @returns {BookReaderTextFragment|null}
|
|
1232
|
-
*/
|
|
1233
|
-
static fromUrl(urlString, book, fallbackPageIndex) {
|
|
1234
|
-
// Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
|
|
1235
|
-
// commas and colons, so need to do a regex match to extract the text fragment string
|
|
1236
|
-
const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
|
|
1237
|
-
if (!textMatch) return null;
|
|
1238
|
-
return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
|
|
1239
|
-
}
|
|
1240
1289
|
|
|
1241
1290
|
/**
|
|
1242
1291
|
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
1243
1292
|
* browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
|
|
1244
|
-
|
|
1245
|
-
|
|
1293
|
+
*
|
|
1294
|
+
* If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
|
|
1295
|
+
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1246
1296
|
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
|
|
1297
|
+
*
|
|
1247
1298
|
* @returns {string}
|
|
1248
1299
|
*/
|
|
1249
1300
|
toUrlString() {
|
|
1301
|
+
// When constructing to url, we can collapse the whitespace since the corresponding
|
|
1302
|
+
// toRegExp method is resilient to multiple whitespace characters.
|
|
1303
|
+
/** @param {string} s */
|
|
1304
|
+
const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
|
|
1305
|
+
|
|
1250
1306
|
// First the page number or index
|
|
1251
|
-
let str = this.pageNumber ?
|
|
1307
|
+
let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
|
|
1308
|
+
str += ':';
|
|
1252
1309
|
|
|
1253
1310
|
if (this.prefix) {
|
|
1254
|
-
str +=
|
|
1311
|
+
str += normalize(this.prefix);
|
|
1312
|
+
str += '-,';
|
|
1255
1313
|
}
|
|
1256
1314
|
|
|
1257
|
-
const quote = this.quote
|
|
1315
|
+
const quote = this.quote ? normalize(this.quote) : null;
|
|
1258
1316
|
let shortenedQuoteParts = null;
|
|
1259
1317
|
if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
|
|
1260
1318
|
const words = quote.match(/\S+/g) || [];
|
|
@@ -1268,27 +1326,33 @@ export class BookReaderTextFragment {
|
|
|
1268
1326
|
}
|
|
1269
1327
|
|
|
1270
1328
|
if (quote && !shortenedQuoteParts) {
|
|
1271
|
-
str +=
|
|
1329
|
+
str += quote;
|
|
1272
1330
|
} else if (shortenedQuoteParts) {
|
|
1273
|
-
str += `${
|
|
1331
|
+
str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
|
|
1274
1332
|
} else if (this.quoteStart && this.quoteEnd) {
|
|
1275
|
-
str += `${
|
|
1333
|
+
str += `${this.quoteStart},${this.quoteEnd}`;
|
|
1276
1334
|
} else {
|
|
1277
1335
|
throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
|
|
1278
1336
|
}
|
|
1279
1337
|
|
|
1280
1338
|
if (this.suffix) {
|
|
1281
|
-
str +=
|
|
1339
|
+
str += ',-';
|
|
1340
|
+
str += normalize(this.suffix);
|
|
1282
1341
|
}
|
|
1283
|
-
|
|
1342
|
+
|
|
1343
|
+
return encodeURIComponent(str)
|
|
1344
|
+
// Bring back the delimiters so it's easier to read, but note not necessary.
|
|
1345
|
+
// Note hyphens are not encoded by encodeURIComponent, so no need to replace them
|
|
1346
|
+
.replace(/%2C/g, ',')
|
|
1347
|
+
.replace(/%3A/g, ':');
|
|
1284
1348
|
}
|
|
1285
1349
|
|
|
1286
1350
|
/**
|
|
1287
1351
|
* Build a regex that matches this quote payload.
|
|
1288
|
-
* @param {{
|
|
1352
|
+
* @param {{ context?: boolean }} [options]
|
|
1289
1353
|
* @returns {RegExp}
|
|
1290
1354
|
*/
|
|
1291
|
-
toRegExp({
|
|
1355
|
+
toRegExp({ context = false } = {}) {
|
|
1292
1356
|
/** @type {[String] | [String, String]} */
|
|
1293
1357
|
const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
|
|
1294
1358
|
|
|
@@ -1297,12 +1361,16 @@ export class BookReaderTextFragment {
|
|
|
1297
1361
|
if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
|
|
1298
1362
|
}
|
|
1299
1363
|
|
|
1364
|
+
// Make it resilient to extra whitespace
|
|
1365
|
+
/** @param {String} quote */
|
|
1366
|
+
const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
|
|
1367
|
+
|
|
1300
1368
|
if (quotes.length === 1) {
|
|
1301
|
-
return new RegExp(
|
|
1369
|
+
return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
|
|
1302
1370
|
} else {
|
|
1303
1371
|
return new RegExp(
|
|
1304
|
-
|
|
1305
|
-
'
|
|
1372
|
+
quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
|
|
1373
|
+
'gi',
|
|
1306
1374
|
);
|
|
1307
1375
|
}
|
|
1308
1376
|
}
|
|
@@ -1356,11 +1424,11 @@ export class BookReaderTextFragment {
|
|
|
1356
1424
|
|
|
1357
1425
|
const CONTEXT_WORD_COUNT = 3;
|
|
1358
1426
|
|
|
1359
|
-
const preStartText =
|
|
1427
|
+
const preStartText = textLayerRangeToString(preStartRange);
|
|
1360
1428
|
let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
|
|
1361
1429
|
let prefixWords = countWords(prefix);
|
|
1362
1430
|
|
|
1363
|
-
const postEndText =
|
|
1431
|
+
const postEndText = textLayerRangeToString(postEndRange);
|
|
1364
1432
|
let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
|
|
1365
1433
|
let suffixWords = countWords(suffix);
|
|
1366
1434
|
|
|
@@ -1376,8 +1444,7 @@ export class BookReaderTextFragment {
|
|
|
1376
1444
|
prefixWords = countWords(prefix);
|
|
1377
1445
|
}
|
|
1378
1446
|
|
|
1379
|
-
|
|
1380
|
-
const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
|
|
1447
|
+
const quote = textLayerRangeToString(fullQuoteRange);
|
|
1381
1448
|
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1382
1449
|
|
|
1383
1450
|
return new BookReaderTextFragment({
|