@internetarchive/bookreader 5.0.0-113 → 5.0.0-114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BookReader/BookReader.css +7 -0
- package/BookReader/BookReader.js +1 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +1 -1
- package/BookReader/ia-bookreader-bundle.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.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/package.json +1 -1
- package/src/css/_TextSelection.scss +7 -1
- package/src/ia-bookreader/ia-bookreader.js +13 -2
- package/src/plugins/plugin.experiments.js +13 -3
- package/src/plugins/plugin.text_selection.js +8 -1
- package/src/plugins/tts/plugin.tts.js +3 -3
- package/src/plugins/tts/utils.js +0 -10
- package/src/util/TextSelectionManager.js +198 -83
- package/src/util/strings.js +10 -0
package/package.json
CHANGED
|
@@ -149,6 +149,7 @@
|
|
|
149
149
|
display: none;
|
|
150
150
|
border-radius: 20px;
|
|
151
151
|
background-color: #333;
|
|
152
|
+
color: white;
|
|
152
153
|
color-scheme: dark;
|
|
153
154
|
padding: 2px;
|
|
154
155
|
overflow: hidden;
|
|
@@ -210,9 +211,14 @@
|
|
|
210
211
|
color: transparent;
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
|
|
214
|
+
@keyframes highlightTargetFlash {
|
|
215
|
+
to { background-color: hsla(45, 80%, 66%, 1); }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Style URI TextFragments, eg ?text=example
|
|
214
219
|
.BRtextLayer .BRhighlight--target-text, .BRtextLayer ::target-text {
|
|
215
220
|
// Similar colour to the default one used in Safari, Firefox. Note Chrome uses a purple colour
|
|
216
221
|
background-color: hsla(45, 80%, 66%, 0.6);
|
|
217
222
|
color: transparent;
|
|
223
|
+
animation: highlightTargetFlash 600ms 1 reverse;
|
|
218
224
|
}
|
|
@@ -302,8 +302,11 @@ export class IaBookReader extends LitElement {
|
|
|
302
302
|
providers.bookmarks = new BookmarksProvider({
|
|
303
303
|
...this.baseProviderConfig,
|
|
304
304
|
onProviderChange: (bookmarks) => {
|
|
305
|
-
|
|
306
|
-
|
|
305
|
+
if (Object.keys(bookmarks).length) {
|
|
306
|
+
this.addMenuShortcut('bookmarks');
|
|
307
|
+
} else {
|
|
308
|
+
this.removeMenuShortcut('bookmarks');
|
|
309
|
+
}
|
|
307
310
|
this.updateMenuContents();
|
|
308
311
|
},
|
|
309
312
|
});
|
|
@@ -463,6 +466,14 @@ export class IaBookReader extends LitElement {
|
|
|
463
466
|
this._sortMenuShortcuts();
|
|
464
467
|
}
|
|
465
468
|
|
|
469
|
+
/**
|
|
470
|
+
* @param {string} menuId
|
|
471
|
+
*/
|
|
472
|
+
removeMenuShortcut(menuId) {
|
|
473
|
+
this.menuShortcuts = this.menuShortcuts.filter((m) => m.id !== menuId);
|
|
474
|
+
this._sortMenuShortcuts();
|
|
475
|
+
}
|
|
476
|
+
|
|
466
477
|
/**
|
|
467
478
|
* Sorts the menuShortcuts property by comparing each provider's id to
|
|
468
479
|
* the id in each iteration over the shortcutOrder array.
|
|
@@ -52,8 +52,11 @@ export class ExperimentsPlugin extends BookReaderPlugin {
|
|
|
52
52
|
/** Where the state of this plugin is saved in localStorage */
|
|
53
53
|
localStorageKey: 'BrExperiments',
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
55
|
+
/** @type {ExperimentName[]} Experiments shown in the experiments panel */
|
|
56
|
+
availableExperiments: ['translate', 'copyLinkToHighlight'],
|
|
57
|
+
|
|
58
|
+
/** @type {ExperimentName[]} Experiments enabled by default */
|
|
59
|
+
autoEnabledExperiments: [],
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** @type {ExperimentModel[]} */
|
|
@@ -160,7 +163,14 @@ export class ExperimentsPlugin extends BookReaderPlugin {
|
|
|
160
163
|
if (experiment.icon) {
|
|
161
164
|
experiment.icon = experiment.buildAssetPath(experiment.icon);
|
|
162
165
|
}
|
|
166
|
+
|
|
163
167
|
experiment.br = this.br;
|
|
168
|
+
|
|
169
|
+
// Enable any experiments that should be automatically enabled
|
|
170
|
+
if (!experiment.enabled && this.options.autoEnabledExperiments.includes(experiment.name)) {
|
|
171
|
+
experiment.enabled = true;
|
|
172
|
+
await experiment.enable({ manual: false });
|
|
173
|
+
}
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
this._loadExperimentStates();
|
|
@@ -222,7 +232,7 @@ export class ExperimentsPlugin extends BookReaderPlugin {
|
|
|
222
232
|
`,
|
|
223
233
|
label: 'Experiments',
|
|
224
234
|
component: html`<br-experiments-panel
|
|
225
|
-
.experiments="${this.allExperiments.filter(experiment => this.options.
|
|
235
|
+
.experiments="${this.allExperiments.filter(experiment => this.options.availableExperiments.includes(experiment.name))}"
|
|
226
236
|
@connected="${e => this._panel = e.target}"
|
|
227
237
|
@toggle="${async e => {
|
|
228
238
|
await this._toggleExperiment(e.detail.experiment, e.detail.enabled);
|
|
@@ -36,6 +36,8 @@ export class TextSelectionPlugin extends BookReaderPlugin {
|
|
|
36
36
|
*/
|
|
37
37
|
maxWordRendered = 2500;
|
|
38
38
|
|
|
39
|
+
_jumpedToHighlight = false;
|
|
40
|
+
|
|
39
41
|
/**
|
|
40
42
|
* @param {import('../BookReader.js').default} br
|
|
41
43
|
*/
|
|
@@ -71,7 +73,12 @@ export class TextSelectionPlugin extends BookReaderPlugin {
|
|
|
71
73
|
const pageIndex = targetTextFragment.pageIndex;
|
|
72
74
|
const hasTargetText = pageIndex === parseFloat(pageContainerEl.getAttribute('data-index'));
|
|
73
75
|
if (hasTargetText) {
|
|
74
|
-
renderHighlight(textLayer, targetTextFragment, 'BRhighlight--target-text');
|
|
76
|
+
const markEls = renderHighlight(textLayer, targetTextFragment, 'BRhighlight--target-text');
|
|
77
|
+
// Only jump once; presumably on first page load.
|
|
78
|
+
if (!this._jumpedToHighlight) {
|
|
79
|
+
markEls[0].scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
80
|
+
this._jumpedToHighlight = true;
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
});
|
|
77
84
|
}
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import FestivalTTSEngine from './FestivalTTSEngine.js';
|
|
4
4
|
import WebTTSEngine from './WebTTSEngine.js';
|
|
5
|
-
import { toISO6391
|
|
5
|
+
import { toISO6391 } from './utils.js';
|
|
6
6
|
import { en as tooltips } from './tooltip_dict.js';
|
|
7
7
|
import { renderBoxesInPageContainerLayer } from '../../BookReader/PageContainer.js';
|
|
8
8
|
import { BookReaderPlugin } from '../../BookReaderPlugin.js';
|
|
9
|
-
import { applyVariables } from '../../util/strings.js';
|
|
9
|
+
import { applyVariables, countWords } from '../../util/strings.js';
|
|
10
10
|
/** @typedef {import('./PageChunk.js').default} PageChunk */
|
|
11
11
|
/** @typedef {import("./AbstractTTSEngine.js").default} AbstractTTSEngine */
|
|
12
12
|
|
|
@@ -291,7 +291,7 @@ export class TtsPlugin extends BookReaderPlugin {
|
|
|
291
291
|
* @param {PageChunk} chunk
|
|
292
292
|
*/
|
|
293
293
|
sendChunkFinishedAnalyticsEvent(chunk) {
|
|
294
|
-
this.sendAnalyticsEvent('ChunkFinished-Words',
|
|
294
|
+
this.sendAnalyticsEvent('ChunkFinished-Words', countWords(chunk.text));
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
package/src/plugins/tts/utils.js
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import langs from 'iso-language-codes';
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Use regex to approximate word count in a string
|
|
6
|
-
* @param {string} text
|
|
7
|
-
* @return {number}
|
|
8
|
-
*/
|
|
9
|
-
export function approximateWordCount(text) {
|
|
10
|
-
const m = text.match(/\S+/g);
|
|
11
|
-
return m ? m.length : 0;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
4
|
/**
|
|
15
5
|
* Checks whether the current browser is on android
|
|
16
6
|
* @param {string} [userAgent]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { countWords } from './strings.js';
|
|
2
3
|
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
3
4
|
import { html, LitElement } from 'lit';
|
|
4
5
|
import { customElement, property, query } from 'lit/decorators.js';
|
|
@@ -7,6 +8,8 @@ import '@internetarchive/icon-share';
|
|
|
7
8
|
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
8
9
|
|
|
9
10
|
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
11
|
+
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
12
|
+
const TRUNCATED_QUOTE_WORD_COUNT = 3;
|
|
10
13
|
|
|
11
14
|
export class TextSelectionManager {
|
|
12
15
|
options = {
|
|
@@ -555,8 +558,14 @@ class BRSelectMenu extends LitElement {
|
|
|
555
558
|
async handleCopyLinkToHighlight(e) {
|
|
556
559
|
e.preventDefault();
|
|
557
560
|
const currentParams = this.br.readQueryString();
|
|
558
|
-
const currentSelection = window.getSelection();
|
|
559
|
-
const
|
|
561
|
+
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
562
|
+
const range = currentSelection.getRangeAt(0);
|
|
563
|
+
const textLayer = this.getNodeTextLayer(range.startContainer);
|
|
564
|
+
if (!textLayer) {
|
|
565
|
+
console.warn("No text layer found for selection");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
|
|
560
569
|
|
|
561
570
|
// Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
|
|
562
571
|
let linkToHighlightParams = currentParams;
|
|
@@ -579,11 +588,11 @@ class BRSelectMenu extends LitElement {
|
|
|
579
588
|
/**
|
|
580
589
|
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
581
590
|
* @param {Node} node
|
|
582
|
-
* @returns {
|
|
591
|
+
* @returns {Element | null}
|
|
583
592
|
*/
|
|
584
593
|
getNodeTextLayer(node) {
|
|
585
|
-
if (!node) return;
|
|
586
|
-
const element =
|
|
594
|
+
if (!node) return null;
|
|
595
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
587
596
|
return element?.closest('.BRtextLayer') ?? null;
|
|
588
597
|
}
|
|
589
598
|
|
|
@@ -788,53 +797,62 @@ function replaceWhitespace(string) {
|
|
|
788
797
|
}
|
|
789
798
|
|
|
790
799
|
/**
|
|
791
|
-
*
|
|
792
|
-
* @param {
|
|
800
|
+
* Finds all regex matches within a range and returns their ranges.
|
|
801
|
+
* @param {RegExp} regex
|
|
793
802
|
* @param {Range} range - the range to search in
|
|
794
803
|
* @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
|
|
804
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
805
|
+
* @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
|
|
799
806
|
*/
|
|
800
|
-
export function
|
|
807
|
+
export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
|
|
808
|
+
if (!textNodes.length) return undefined;
|
|
809
|
+
|
|
801
810
|
const startOffset = textNodes[0] === range.startContainer ?
|
|
802
811
|
range.startOffset :
|
|
803
812
|
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
|
-
|
|
813
|
+
const normalizedWholePageString = normalize(range.toString());
|
|
814
|
+
const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
815
|
+
const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
816
|
+
|
|
817
|
+
const matches = Array.from(normalizedWholePageString.matchAll(matchRegex));
|
|
818
|
+
const resultRanges = matches.map((match) => {
|
|
819
|
+
const start = getBoundaryPointAtIndex(
|
|
820
|
+
normalizedStartOffset + match.index,
|
|
821
|
+
textNodes,
|
|
822
|
+
false,
|
|
823
|
+
{ normalize },
|
|
824
|
+
);
|
|
825
|
+
const end = getBoundaryPointAtIndex(
|
|
826
|
+
normalizedStartOffset + match.index + match[0].length,
|
|
827
|
+
textNodes,
|
|
828
|
+
true,
|
|
829
|
+
{ normalize },
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (!start || !end) {
|
|
833
|
+
console.warn("Could not find boundary points for regex match, skipping this match", {match, start, end});
|
|
834
|
+
return undefined;
|
|
824
835
|
}
|
|
825
|
-
searchStart = matchedIndex + 1;
|
|
826
|
-
}
|
|
827
|
-
return undefined;
|
|
828
|
-
}
|
|
829
836
|
|
|
837
|
+
const foundRange = new Range();
|
|
838
|
+
foundRange.setStart(start.node, 0);
|
|
839
|
+
foundRange.setEnd(end.node, 1);
|
|
840
|
+
return foundRange;
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const definedRanges = resultRanges.filter((matchRange) => !!matchRange);
|
|
844
|
+
if (!definedRanges?.length) return undefined;
|
|
845
|
+
return definedRanges;
|
|
846
|
+
}
|
|
830
847
|
|
|
831
848
|
/**
|
|
832
849
|
* Uses the index that matches the quote string and normalizes the string contents to find the correct node
|
|
833
850
|
* @param {Number} index
|
|
834
851
|
* @param {Node[]} nodes
|
|
835
852
|
* @param {boolean} isEnd
|
|
853
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
836
854
|
*/
|
|
837
|
-
export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
855
|
+
export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
|
|
838
856
|
let counted = 0;
|
|
839
857
|
let normalizedData;
|
|
840
858
|
for (let i = 0; i < nodes.length; i++) {
|
|
@@ -843,7 +861,7 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
843
861
|
// Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
|
|
844
862
|
normalizedData = ' ';
|
|
845
863
|
} else {
|
|
846
|
-
if (!normalizedData) normalizedData =
|
|
864
|
+
if (!normalizedData) normalizedData = normalize(node.textContent);
|
|
847
865
|
}
|
|
848
866
|
let nodeEnd = counted + normalizedData.length;
|
|
849
867
|
if (isEnd) nodeEnd += 1;
|
|
@@ -856,8 +874,8 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
856
874
|
normalizedData.substring(normalizedOffset);
|
|
857
875
|
|
|
858
876
|
let candidateSubstring = isEnd ?
|
|
859
|
-
|
|
860
|
-
|
|
877
|
+
normalize(node.textContent.substring(0, normalizedOffset)) :
|
|
878
|
+
normalize(node.textContent.substring(normalizedOffset));
|
|
861
879
|
|
|
862
880
|
const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
|
|
863
881
|
while (denormalizedOffset >= 0 &&
|
|
@@ -899,52 +917,54 @@ export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
|
899
917
|
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
|
|
900
918
|
*
|
|
901
919
|
* @param {HTMLElement} textLayer
|
|
902
|
-
* @param {BookReaderTextFragment}
|
|
920
|
+
* @param {BookReaderTextFragment} textFragment
|
|
903
921
|
* @param {string | null} cssClassName optional css class to add to the highlight span element
|
|
922
|
+
* @return {Element[]} the elements that were created to highlight the text
|
|
904
923
|
*/
|
|
905
|
-
export function renderHighlight(textLayer,
|
|
924
|
+
export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
906
925
|
// Create a range that encompasses the entire text content
|
|
907
926
|
const firstPageNode = getFirstMostNode(textLayer);
|
|
908
927
|
const lastPageNode = getLastMostNode(textLayer);
|
|
909
928
|
const wholePageRange = new Range();
|
|
910
929
|
wholePageRange.setStart(firstPageNode, 0);
|
|
911
930
|
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);
|
|
931
|
+
const normalize = replaceWhitespace;
|
|
923
932
|
|
|
924
933
|
// Retrieve the text nodes and relevant whitespace elements
|
|
925
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
|
|
926
935
|
const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
|
|
927
936
|
pageWordNodes.splice(0, 1);
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
937
|
+
|
|
938
|
+
const broadRanges = findRangeForRegExp(
|
|
939
|
+
textFragment.toRegExp({ normalize, context: true }),
|
|
940
|
+
wholePageRange,
|
|
941
|
+
pageWordNodes,
|
|
942
|
+
{ normalize },
|
|
943
|
+
);
|
|
944
|
+
if (!broadRanges) {
|
|
945
|
+
console.warn("Could not find quote with context in page");
|
|
931
946
|
return;
|
|
932
947
|
}
|
|
933
948
|
|
|
934
949
|
const broadRangeWordNodes = [];
|
|
935
|
-
for (const el of walkBetweenNodes(
|
|
950
|
+
for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
|
|
936
951
|
if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
|
|
937
952
|
broadRangeWordNodes.push(el);
|
|
938
953
|
}
|
|
939
954
|
}
|
|
940
955
|
|
|
941
956
|
// At which point the quote should now be unambiguous!
|
|
942
|
-
const
|
|
943
|
-
|
|
957
|
+
const exactRanges = findRangeForRegExp(
|
|
958
|
+
textFragment.toRegExp({ normalize, context: false }),
|
|
959
|
+
broadRanges[0],
|
|
960
|
+
broadRangeWordNodes,
|
|
961
|
+
{ normalize },
|
|
962
|
+
);
|
|
963
|
+
if (!exactRanges) {
|
|
944
964
|
throw new Error("Could not find quote in page");
|
|
945
965
|
}
|
|
946
|
-
const startTextNode = getFirstMostNode(
|
|
947
|
-
const endTextNode = getFirstMostNode(
|
|
966
|
+
const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
|
|
967
|
+
const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
|
|
948
968
|
|
|
949
969
|
// markRange requires the range to start and end within text nodes
|
|
950
970
|
const exactRangeTextNodes = new Range();
|
|
@@ -957,11 +977,11 @@ export function renderHighlight(textLayer, quote, cssClassName = null) {
|
|
|
957
977
|
return;
|
|
958
978
|
}
|
|
959
979
|
|
|
960
|
-
markRange(exactRangeTextNodes, () => {
|
|
980
|
+
return markRange(exactRangeTextNodes, () => {
|
|
961
981
|
const mark = document.createElement("mark");
|
|
962
982
|
mark.classList.add("BRhighlight");
|
|
963
983
|
if (cssClassName) mark.classList.add(cssClassName);
|
|
964
|
-
if (
|
|
984
|
+
if (textFragment.uuid) mark.classList.add(textFragment.uuid);
|
|
965
985
|
return mark;
|
|
966
986
|
});
|
|
967
987
|
}
|
|
@@ -1062,24 +1082,31 @@ function retrieveUUID(ele) {
|
|
|
1062
1082
|
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
1063
1083
|
*
|
|
1064
1084
|
* Text fragment string format: `pageNumber:prefix-,quote,-suffix`
|
|
1085
|
+
* Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
|
|
1065
1086
|
* Note the ':' and ',' separators must not be encoded, but
|
|
1066
|
-
* the pageNumber, prefix, quote, and suffix text can be encoded.
|
|
1087
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
|
|
1067
1088
|
*/
|
|
1068
1089
|
export class BookReaderTextFragment {
|
|
1069
1090
|
/**
|
|
1070
1091
|
* @param {object} params
|
|
1071
1092
|
* @param {string | null}params.prefix
|
|
1072
|
-
* @param {string} params.quote
|
|
1093
|
+
* @param {string | null} [params.quote]
|
|
1094
|
+
* @param {string | null} [params.quoteStart]
|
|
1095
|
+
* @param {string | null} [params.quoteEnd]
|
|
1073
1096
|
* @param {string | null} params.suffix
|
|
1074
1097
|
* @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
|
|
1075
1098
|
* @param {number} params.pageIndex Page index; e.g. zero-based index of the page
|
|
1076
1099
|
* @param {string | null} [params.uuid] UUID for the text fragment if it has one
|
|
1077
1100
|
*/
|
|
1078
|
-
constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
|
|
1101
|
+
constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
|
|
1079
1102
|
/** @type {string|null} */
|
|
1080
1103
|
this.prefix = prefix;
|
|
1081
|
-
/** @type {string} */
|
|
1082
|
-
this.quote = quote;
|
|
1104
|
+
/** @type {string | null} */
|
|
1105
|
+
this.quote = quote ?? null;
|
|
1106
|
+
/** @type {string | null} */
|
|
1107
|
+
this.quoteStart = quoteStart ?? null;
|
|
1108
|
+
/** @type {string | null} */
|
|
1109
|
+
this.quoteEnd = quoteEnd ?? null;
|
|
1083
1110
|
/** @type {string|null} */
|
|
1084
1111
|
this.suffix = suffix;
|
|
1085
1112
|
/** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
|
|
@@ -1108,14 +1135,40 @@ export class BookReaderTextFragment {
|
|
|
1108
1135
|
}
|
|
1109
1136
|
const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
|
|
1110
1137
|
const prefix = match[2] ? decodeURIComponent(match[2]) : null;
|
|
1111
|
-
const
|
|
1138
|
+
const quoteRegion = match[3] || '';
|
|
1139
|
+
const quoteParts = quoteRegion.split(',');
|
|
1140
|
+
let quote = null;
|
|
1141
|
+
let quoteStart = null;
|
|
1142
|
+
let quoteEnd = null;
|
|
1143
|
+
|
|
1144
|
+
if (quoteParts.length === 1) {
|
|
1145
|
+
quote = decodeURIComponent(quoteParts[0]);
|
|
1146
|
+
} else if (quoteParts.length === 2) {
|
|
1147
|
+
quoteStart = decodeURIComponent(quoteParts[0]);
|
|
1148
|
+
quoteEnd = decodeURIComponent(quoteParts[1]);
|
|
1149
|
+
} else {
|
|
1150
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1112
1153
|
const suffix = match[4] ? decodeURIComponent(match[4]) : null;
|
|
1113
1154
|
const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
|
|
1114
1155
|
if (typeof pageIndex !== 'number') {
|
|
1115
1156
|
throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
|
|
1116
1157
|
}
|
|
1117
1158
|
|
|
1118
|
-
|
|
1159
|
+
if (!quote && (!quoteStart || !quoteEnd)) {
|
|
1160
|
+
throw new Error(`Invalid text fragment quote format: ${str}`);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return new BookReaderTextFragment({
|
|
1164
|
+
prefix,
|
|
1165
|
+
quote,
|
|
1166
|
+
quoteStart,
|
|
1167
|
+
quoteEnd,
|
|
1168
|
+
suffix,
|
|
1169
|
+
pageNumber,
|
|
1170
|
+
pageIndex,
|
|
1171
|
+
});
|
|
1119
1172
|
}
|
|
1120
1173
|
|
|
1121
1174
|
/**
|
|
@@ -1137,8 +1190,9 @@ export class BookReaderTextFragment {
|
|
|
1137
1190
|
/**
|
|
1138
1191
|
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
1139
1192
|
* browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
|
|
1193
|
+
* If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
|
|
1140
1194
|
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1141
|
-
|
|
1195
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
|
|
1142
1196
|
* @returns {string}
|
|
1143
1197
|
*/
|
|
1144
1198
|
toUrlString() {
|
|
@@ -1148,17 +1202,66 @@ export class BookReaderTextFragment {
|
|
|
1148
1202
|
if (this.prefix) {
|
|
1149
1203
|
str += `${encodeURIComponent(this.prefix)}-,`;
|
|
1150
1204
|
}
|
|
1151
|
-
|
|
1205
|
+
|
|
1206
|
+
const quote = this.quote?.trim() || null;
|
|
1207
|
+
let shortenedQuoteParts = null;
|
|
1208
|
+
if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
|
|
1209
|
+
const words = quote.match(/\S+/g) || [];
|
|
1210
|
+
if (words.length >= TRUNCATED_QUOTE_WORD_COUNT * 2) {
|
|
1211
|
+
const quoteStart = getFirstWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1212
|
+
const quoteEnd = getLastWords(TRUNCATED_QUOTE_WORD_COUNT, quote);
|
|
1213
|
+
if (quoteStart && quoteEnd) {
|
|
1214
|
+
shortenedQuoteParts = { quoteStart, quoteEnd };
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (quote && !shortenedQuoteParts) {
|
|
1220
|
+
str += encodeURIComponent(quote);
|
|
1221
|
+
} else if (shortenedQuoteParts) {
|
|
1222
|
+
str += `${encodeURIComponent(shortenedQuoteParts.quoteStart)},${encodeURIComponent(shortenedQuoteParts.quoteEnd)}`;
|
|
1223
|
+
} else if (this.quoteStart && this.quoteEnd) {
|
|
1224
|
+
str += `${encodeURIComponent(this.quoteStart)},${encodeURIComponent(this.quoteEnd)}`;
|
|
1225
|
+
} else {
|
|
1226
|
+
throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1152
1229
|
if (this.suffix) {
|
|
1153
1230
|
str += `,-${encodeURIComponent(this.suffix)}`;
|
|
1154
1231
|
}
|
|
1155
1232
|
return str;
|
|
1156
1233
|
}
|
|
1157
1234
|
|
|
1235
|
+
/**
|
|
1236
|
+
* Build a regex that matches this quote payload.
|
|
1237
|
+
* @param {{ normalize?: function(string): string, context?: boolean }} [options]
|
|
1238
|
+
* @returns {RegExp}
|
|
1239
|
+
*/
|
|
1240
|
+
toRegExp({ normalize = (s) => s, context = false } = {}) {
|
|
1241
|
+
/** @type {[String] | [String, String]} */
|
|
1242
|
+
const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
|
|
1243
|
+
|
|
1244
|
+
if (context) {
|
|
1245
|
+
if (this.prefix) quotes[0] = this.prefix + ' ' + quotes[0];
|
|
1246
|
+
if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (quotes.length === 1) {
|
|
1250
|
+
return new RegExp(RegExp.escape(normalize(quotes[0])), 'g');
|
|
1251
|
+
} else {
|
|
1252
|
+
return new RegExp(
|
|
1253
|
+
RegExp.escape(normalize(quotes[0])) + '[\\s\\S]*?' + RegExp.escape(normalize(quotes[1])),
|
|
1254
|
+
'g',
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1158
1259
|
toJSON() {
|
|
1159
1260
|
return {
|
|
1160
1261
|
prefix: this.prefix,
|
|
1161
1262
|
quote: this.quote,
|
|
1263
|
+
quoteStart: this.quoteStart,
|
|
1264
|
+
quoteEnd: this.quoteEnd,
|
|
1162
1265
|
suffix: this.suffix,
|
|
1163
1266
|
pageNumber: this.pageNumber,
|
|
1164
1267
|
pageIndex: this.pageIndex,
|
|
@@ -1178,7 +1281,7 @@ export class BookReaderTextFragment {
|
|
|
1178
1281
|
/**
|
|
1179
1282
|
* Builds a TextFragment string from a given text selection.
|
|
1180
1283
|
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
1181
|
-
* @param {
|
|
1284
|
+
* @param {Element[]} contextElements elements providing context for the selection
|
|
1182
1285
|
* @returns {BookReaderTextFragment}
|
|
1183
1286
|
*/
|
|
1184
1287
|
static fromSelection(selection, contextElements) {
|
|
@@ -1200,18 +1303,30 @@ export class BookReaderTextFragment {
|
|
|
1200
1303
|
const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
|
|
1201
1304
|
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
1202
1305
|
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1306
|
+
const CONTEXT_WORD_COUNT = 3;
|
|
1307
|
+
|
|
1308
|
+
const preStartText = replaceWhitespace(preStartRange.toString());
|
|
1309
|
+
let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
|
|
1310
|
+
let prefixWords = countWords(prefix);
|
|
1311
|
+
|
|
1312
|
+
const postEndText = replaceWhitespace(postEndRange.toString());
|
|
1313
|
+
let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
|
|
1314
|
+
let suffixWords = countWords(suffix);
|
|
1315
|
+
|
|
1316
|
+
if (prefixWords < CONTEXT_WORD_COUNT) {
|
|
1317
|
+
// Ran out of words! To reduce the risk of ambiguity, try extending suffix
|
|
1318
|
+
suffix = getFirstWords(2 * CONTEXT_WORD_COUNT - prefixWords, postEndText);
|
|
1319
|
+
suffixWords = countWords(suffix);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (suffixWords < CONTEXT_WORD_COUNT) {
|
|
1323
|
+
// Ran out of words on the suffix side as well, try extending prefix
|
|
1324
|
+
prefix = getLastWords(2 * CONTEXT_WORD_COUNT - suffixWords, preStartText);
|
|
1325
|
+
prefixWords = countWords(prefix);
|
|
1326
|
+
}
|
|
1212
1327
|
|
|
1213
1328
|
// 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()
|
|
1329
|
+
const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
|
|
1215
1330
|
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1216
1331
|
|
|
1217
1332
|
return new BookReaderTextFragment({
|
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
|