@internetarchive/bookreader 5.0.0-115 → 5.0.0-116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BookReader/BookReader.js +1 -43
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +49 -7
- package/BookReader/ia-bookreader-bundle.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.search.js +1 -1
- package/BookReader/plugins/plugin.search.js.map +1 -1
- package/BookReader/plugins/plugin.text_selection.js +43 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +45 -3
- package/BookReader/plugins/plugin.translate.js.map +1 -1
- package/BookReader/plugins/plugin.tts.js +1 -1
- package/BookReader/plugins/plugin.tts.js.map +1 -1
- package/BookReader/plugins/plugin.url.js +1 -1
- package/BookReader/plugins/plugin.url.js.map +1 -1
- package/BookReader/plugins/plugin.vendor-fullscreen.js +1 -1
- package/BookReader/plugins/plugin.vendor-fullscreen.js.map +1 -1
- package/jsconfig.json +2 -3
- package/package.json +7 -4
- package/src/BookReader.js +8 -20
- package/src/plugins/plugin.text_selection.js +4 -90
- package/src/plugins/search/plugin.search.js +2 -0
- package/src/plugins/url/UrlPlugin.js +12 -0
- package/src/util/TextSelectionManager.js +168 -146
- package/src/util/generators.js +89 -0
|
@@ -8,6 +8,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
10
|
import { isIOS, isAndroid } from './browserSniffing.js';
|
|
11
|
+
import { genAt, genFilter } from './generators.js';
|
|
11
12
|
|
|
12
13
|
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
13
14
|
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
@@ -247,8 +248,8 @@ export class TextSelectionManager {
|
|
|
247
248
|
// Find the last allowed word in the selection
|
|
248
249
|
const lastAllowedWord = genAt(
|
|
249
250
|
genFilter(
|
|
250
|
-
|
|
251
|
-
(node) => node.classList
|
|
251
|
+
treeWalkRange(range, 'element'),
|
|
252
|
+
(node) => node.classList.contains(
|
|
252
253
|
this.selectionElement[0].replace(".", ""),
|
|
253
254
|
),
|
|
254
255
|
),
|
|
@@ -267,55 +268,58 @@ export class TextSelectionManager {
|
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
/**
|
|
270
|
-
* @
|
|
271
|
-
*
|
|
272
|
-
* @param {
|
|
273
|
-
* @
|
|
271
|
+
* @overload
|
|
272
|
+
* @param {Range} range
|
|
273
|
+
* @param {'text'} filter
|
|
274
|
+
* @returns {Generator<Text>}
|
|
274
275
|
*/
|
|
275
|
-
export function genAt(iterable, index) {
|
|
276
|
-
let i = 0;
|
|
277
|
-
for (const x of iterable) {
|
|
278
|
-
if (i == index) {
|
|
279
|
-
return x;
|
|
280
|
-
}
|
|
281
|
-
i++;
|
|
282
|
-
}
|
|
283
|
-
return undefined;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
276
|
/**
|
|
287
|
-
* @
|
|
288
|
-
*
|
|
289
|
-
* @param {
|
|
290
|
-
* @
|
|
277
|
+
* @overload
|
|
278
|
+
* @param {Range} range
|
|
279
|
+
* @param {'element'} filter
|
|
280
|
+
* @returns {Generator<Element>}
|
|
281
|
+
*/
|
|
282
|
+
/**
|
|
283
|
+
* @overload
|
|
284
|
+
* @param {Range} range
|
|
285
|
+
* @param {'text+element'} [filter]
|
|
286
|
+
* @returns {Generator<Text | Element>}
|
|
291
287
|
*/
|
|
292
|
-
export function* genFilter(iterable, fn) {
|
|
293
|
-
for (const x of iterable) {
|
|
294
|
-
if (fn(x)) yield x;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
288
|
/**
|
|
299
289
|
* Depth traverse the DOM tree starting at `start`, and ending at `end`.
|
|
300
|
-
* @param {
|
|
301
|
-
* @param {
|
|
290
|
+
* @param {Range} range
|
|
291
|
+
* @param {'text' | 'element' | 'text+element'} [filter]
|
|
302
292
|
* @returns {Generator<Node>}
|
|
303
293
|
*/
|
|
304
|
-
export function*
|
|
294
|
+
export function* treeWalkRange(range, filter = 'text+element') {
|
|
295
|
+
const start = range.startContainer;
|
|
296
|
+
const end = range.endContainer;
|
|
305
297
|
let done = false;
|
|
306
298
|
|
|
307
299
|
/**
|
|
308
300
|
* @param {Node} node
|
|
309
301
|
*/
|
|
302
|
+
const passesFilter = node => {
|
|
303
|
+
return (
|
|
304
|
+
(filter === 'text' && node.nodeType === Node.TEXT_NODE) ||
|
|
305
|
+
(filter === 'element' && node.nodeType === Node.ELEMENT_NODE) ||
|
|
306
|
+
(filter === 'text+element')
|
|
307
|
+
);
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* @param {Node} node
|
|
312
|
+
* @returns {Generator<Node>}
|
|
313
|
+
*/
|
|
310
314
|
function* walk(node, {children = true, parents = true, siblings = true} = {}) {
|
|
311
315
|
if (node === end) {
|
|
312
316
|
done = true;
|
|
313
|
-
yield node;
|
|
317
|
+
if (passesFilter(node)) yield node;
|
|
314
318
|
return;
|
|
315
319
|
}
|
|
316
320
|
|
|
317
321
|
// yield self
|
|
318
|
-
yield node;
|
|
322
|
+
if (passesFilter(node)) yield node;
|
|
319
323
|
|
|
320
324
|
// First iterate children (depth-first traversal)
|
|
321
325
|
if (children && node.firstChild) {
|
|
@@ -585,7 +589,6 @@ class BRSelectMenu extends LitElement {
|
|
|
585
589
|
*/
|
|
586
590
|
async handleCopyLinkToHighlight(e) {
|
|
587
591
|
e.preventDefault();
|
|
588
|
-
const currentParams = this.br.readQueryString();
|
|
589
592
|
const currentSelection = /** @type {Selection} */ (window.getSelection());
|
|
590
593
|
const range = currentSelection.getRangeAt(0);
|
|
591
594
|
const textLayer = this.getNodeTextLayer(range.startContainer);
|
|
@@ -595,19 +598,18 @@ class BRSelectMenu extends LitElement {
|
|
|
595
598
|
}
|
|
596
599
|
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, [textLayer]);
|
|
597
600
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
601
|
+
const currentParams = new URLSearchParams(this.br.readQueryString());
|
|
602
|
+
if (currentParams.has('text')) currentParams.delete('text');
|
|
603
|
+
|
|
604
|
+
let linkToHighlightParams = currentParams.toString();
|
|
605
|
+
// Use custom toUrlString, which decodes the delimiters for better readability
|
|
606
|
+
linkToHighlightParams += (linkToHighlightParams ? '&' : '') + `text=${textFragment.toUrlString()}`;
|
|
607
|
+
|
|
606
608
|
const currentUrl = window.location;
|
|
607
609
|
const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
|
|
608
610
|
const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
|
|
609
611
|
const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
|
|
610
|
-
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}
|
|
612
|
+
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}?${linkToHighlightParams}${hash}`;
|
|
611
613
|
|
|
612
614
|
await navigator.clipboard.writeText(linkToHighlight);
|
|
613
615
|
this.copyLinkOption?.showTemporaryText('Copied!');
|
|
@@ -839,12 +841,23 @@ export function getLastMostNode(parent) {
|
|
|
839
841
|
}
|
|
840
842
|
|
|
841
843
|
/**
|
|
842
|
-
*
|
|
844
|
+
* Normalizes text fragment whitespace and removes special delimiter characters
|
|
845
|
+
* to allow for reliable url encoding/decoding
|
|
846
|
+
*
|
|
847
|
+
* Note: It's very important this *does not* change the length of the string to
|
|
848
|
+
* ensure matching works smoothly later.
|
|
849
|
+
*
|
|
843
850
|
* @param {String} string
|
|
844
|
-
* @returns
|
|
845
851
|
*/
|
|
846
|
-
function
|
|
847
|
-
return string.replace(
|
|
852
|
+
export function replaceTextFragmentDelimiters(string) {
|
|
853
|
+
return string.replace(/(:|-,|,-|,|\n)/g, s => s.length === 1 ? ' ' : ' ');
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* @param {String} s
|
|
858
|
+
*/
|
|
859
|
+
export function collapseWhitespace(s) {
|
|
860
|
+
return s.replace(/\s+/g, ' ').trim();
|
|
848
861
|
}
|
|
849
862
|
|
|
850
863
|
/**
|
|
@@ -861,7 +874,7 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
861
874
|
const startOffset = textNodes[0] === range.startContainer ?
|
|
862
875
|
range.startOffset :
|
|
863
876
|
0;
|
|
864
|
-
const normalizedWholePageString = normalize(range
|
|
877
|
+
const normalizedWholePageString = normalize(textLayerRangeToString(range));
|
|
865
878
|
const normalizedStartOffset = normalize(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
866
879
|
const matchRegex = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
867
880
|
|
|
@@ -886,8 +899,8 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
886
899
|
}
|
|
887
900
|
|
|
888
901
|
const foundRange = new Range();
|
|
889
|
-
foundRange.setStart(start.node,
|
|
890
|
-
foundRange.setEnd(end.node,
|
|
902
|
+
foundRange.setStart(start.node, start.offset);
|
|
903
|
+
foundRange.setEnd(end.node, end.offset);
|
|
891
904
|
return foundRange;
|
|
892
905
|
});
|
|
893
906
|
|
|
@@ -897,68 +910,74 @@ export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) =>
|
|
|
897
910
|
}
|
|
898
911
|
|
|
899
912
|
/**
|
|
900
|
-
*
|
|
913
|
+
* Maps an index from "normalized-space" (concatenated and normalized text) to a "node-space" index (node + offset).
|
|
901
914
|
* @param {Number} index
|
|
902
915
|
* @param {Node[]} nodes
|
|
903
916
|
* @param {boolean} isEnd
|
|
904
917
|
* @param {{ normalize?: function(string): string }} [options]
|
|
905
918
|
*/
|
|
906
919
|
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
|
-
|
|
920
|
+
// Rename to have consistent wording
|
|
921
|
+
const normalizedTargetIndex = index;
|
|
922
|
+
/** Characters in node-space counted so far */
|
|
923
|
+
let normalizedI = 0;
|
|
924
|
+
|
|
925
|
+
for (const node of nodes) {
|
|
926
|
+
const nodeText = node.textContent || '';
|
|
927
|
+
const normalizedText = normalize(nodeText);
|
|
928
|
+
const nodeNormalizedStart = normalizedI;
|
|
929
|
+
const nodeNormalizedEnd = nodeNormalizedStart + normalizedText.length + (isEnd ? 1 : 0);
|
|
930
|
+
|
|
931
|
+
if (nodeNormalizedEnd > normalizedTargetIndex) {
|
|
932
|
+
// We have found the node that contains the normalizedTargetIndex we are looking for!
|
|
933
|
+
const normalizedOffset = normalizedTargetIndex - nodeNormalizedStart;
|
|
934
|
+
|
|
935
|
+
// But the normalized text could be a shorter length than the node text,
|
|
936
|
+
// so we try to continually increase the nodeOffset
|
|
937
|
+
let nodeOffset = Math.min(normalizedOffset, nodeText.length);
|
|
938
|
+
|
|
939
|
+
const normalizedSubstring = isEnd ?
|
|
940
|
+
normalizedText.substring(0, normalizedOffset) :
|
|
941
|
+
normalizedText.substring(normalizedOffset);
|
|
942
|
+
|
|
943
|
+
let nodeSubstring = isEnd ?
|
|
944
|
+
normalize(nodeText.substring(0, normalizedOffset)) :
|
|
945
|
+
normalize(nodeText.substring(normalizedOffset));
|
|
946
|
+
|
|
947
|
+
const direction = (isEnd ? -1 : 1) * (normalizedSubstring.length > nodeSubstring.length ? -1 : 1);
|
|
948
|
+
while (nodeOffset >= 0 && nodeOffset <= nodeText.length) {
|
|
949
|
+
if (nodeSubstring.length === normalizedSubstring.length) {
|
|
950
|
+
return {node, offset: nodeOffset};
|
|
936
951
|
}
|
|
937
|
-
|
|
952
|
+
nodeOffset += direction;
|
|
938
953
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
954
|
+
nodeSubstring = isEnd ?
|
|
955
|
+
normalize(nodeText.substring(0, nodeOffset)) :
|
|
956
|
+
normalize(nodeText.substring(nodeOffset));
|
|
942
957
|
}
|
|
943
958
|
}
|
|
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
|
-
}
|
|
959
|
+
normalizedI += normalizedText.length;
|
|
958
960
|
}
|
|
959
961
|
return undefined;
|
|
960
962
|
}
|
|
961
963
|
|
|
964
|
+
/**
|
|
965
|
+
* @param {Range} range
|
|
966
|
+
*/
|
|
967
|
+
export function textLayerRangeToString(range) {
|
|
968
|
+
const textNodes = Array.from(genFilter(treeWalkRange(range, 'text'), isBRVisibleTextNode));
|
|
969
|
+
let result = textNodes.map(node => node.textContent).join('');
|
|
970
|
+
const firstTextNode = textNodes[0];
|
|
971
|
+
const lastTextNode = textNodes[textNodes.length - 1];
|
|
972
|
+
if (range.startContainer === firstTextNode) {
|
|
973
|
+
result = result.substring(range.startOffset);
|
|
974
|
+
}
|
|
975
|
+
if (range.endContainer === lastTextNode) {
|
|
976
|
+
result = result.substring(0, result.length - (lastTextNode.textContent.length - range.endOffset));
|
|
977
|
+
}
|
|
978
|
+
return result;
|
|
979
|
+
}
|
|
980
|
+
|
|
962
981
|
/**
|
|
963
982
|
* Takes a text quote object and a container element, and wraps the quote
|
|
964
983
|
* within the container element in a span to apply highlight-like styling
|
|
@@ -979,37 +998,29 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
|
979
998
|
const wholePageRange = new Range();
|
|
980
999
|
wholePageRange.setStart(firstPageNode, 0);
|
|
981
1000
|
wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
|
|
982
|
-
const normalize = replaceWhitespace;
|
|
983
1001
|
|
|
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);
|
|
1002
|
+
const pageWordNodes = Array.from(genFilter(treeWalkRange(wholePageRange, 'text'), isBRVisibleTextNode));
|
|
988
1003
|
|
|
989
1004
|
const broadRanges = findRangeForRegExp(
|
|
990
|
-
textFragment.toRegExp({
|
|
1005
|
+
textFragment.toRegExp({ context: true }),
|
|
991
1006
|
wholePageRange,
|
|
992
1007
|
pageWordNodes,
|
|
993
|
-
{ normalize },
|
|
1008
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
994
1009
|
);
|
|
995
1010
|
if (!broadRanges) {
|
|
996
1011
|
console.warn("Could not find quote with context in page");
|
|
997
1012
|
return;
|
|
998
1013
|
}
|
|
999
1014
|
|
|
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
|
-
}
|
|
1015
|
+
const broadRangeWordNodes = Array.from(genFilter(treeWalkRange(broadRanges[0], 'text'), isBRVisibleTextNode));
|
|
1006
1016
|
|
|
1007
1017
|
// At which point the quote should now be unambiguous!
|
|
1018
|
+
// FIXME: Alas no, it is ambiguous ; need to likely extract prefix and suffix separately?
|
|
1008
1019
|
const exactRanges = findRangeForRegExp(
|
|
1009
|
-
textFragment.toRegExp({
|
|
1020
|
+
textFragment.toRegExp({ context: false }),
|
|
1010
1021
|
broadRanges[0],
|
|
1011
1022
|
broadRangeWordNodes,
|
|
1012
|
-
{ normalize },
|
|
1023
|
+
{ normalize: replaceTextFragmentDelimiters },
|
|
1013
1024
|
);
|
|
1014
1025
|
if (!exactRanges) {
|
|
1015
1026
|
throw new Error("Could not find quote in page");
|
|
@@ -1037,6 +1048,14 @@ export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
|
1037
1048
|
});
|
|
1038
1049
|
}
|
|
1039
1050
|
|
|
1051
|
+
/**
|
|
1052
|
+
* @param {Text} node
|
|
1053
|
+
*/
|
|
1054
|
+
export function isBRVisibleTextNode(node) {
|
|
1055
|
+
// Exclude the duplicated spaces between words for MS Edge
|
|
1056
|
+
return !node.previousElementSibling?.classList.contains("BRspace");
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1040
1059
|
/**
|
|
1041
1060
|
* Given a Range, wraps its text contents in one or more <mark> elements.
|
|
1042
1061
|
* <mark> elements can't cross block boundaries, so this function walks the
|
|
@@ -1222,39 +1241,33 @@ export class BookReaderTextFragment {
|
|
|
1222
1241
|
});
|
|
1223
1242
|
}
|
|
1224
1243
|
|
|
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
1244
|
|
|
1241
1245
|
/**
|
|
1242
1246
|
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
1243
1247
|
* browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
|
|
1244
|
-
|
|
1245
|
-
|
|
1248
|
+
*
|
|
1249
|
+
* If quote text is long enough, it is serialized as `quoteStart,quoteEnd`.
|
|
1250
|
+
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1246
1251
|
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
|
|
1252
|
+
*
|
|
1247
1253
|
* @returns {string}
|
|
1248
1254
|
*/
|
|
1249
1255
|
toUrlString() {
|
|
1256
|
+
// When constructing to url, we can collapse the whitespace since the corresponding
|
|
1257
|
+
// toRegExp method is resilient to multiple whitespace characters.
|
|
1258
|
+
/** @param {string} s */
|
|
1259
|
+
const normalize = s => collapseWhitespace(replaceTextFragmentDelimiters(s).trim());
|
|
1260
|
+
|
|
1250
1261
|
// First the page number or index
|
|
1251
|
-
let str = this.pageNumber ?
|
|
1262
|
+
let str = this.pageNumber ? this.pageNumber : `n${this.pageIndex}`;
|
|
1263
|
+
str += ':';
|
|
1252
1264
|
|
|
1253
1265
|
if (this.prefix) {
|
|
1254
|
-
str +=
|
|
1266
|
+
str += normalize(this.prefix);
|
|
1267
|
+
str += '-,';
|
|
1255
1268
|
}
|
|
1256
1269
|
|
|
1257
|
-
const quote = this.quote
|
|
1270
|
+
const quote = this.quote ? normalize(this.quote) : null;
|
|
1258
1271
|
let shortenedQuoteParts = null;
|
|
1259
1272
|
if (quote && quote.length > MAX_FULL_QUOTE_URL_CHARS) {
|
|
1260
1273
|
const words = quote.match(/\S+/g) || [];
|
|
@@ -1268,27 +1281,33 @@ export class BookReaderTextFragment {
|
|
|
1268
1281
|
}
|
|
1269
1282
|
|
|
1270
1283
|
if (quote && !shortenedQuoteParts) {
|
|
1271
|
-
str +=
|
|
1284
|
+
str += quote;
|
|
1272
1285
|
} else if (shortenedQuoteParts) {
|
|
1273
|
-
str += `${
|
|
1286
|
+
str += `${shortenedQuoteParts.quoteStart},${shortenedQuoteParts.quoteEnd}`;
|
|
1274
1287
|
} else if (this.quoteStart && this.quoteEnd) {
|
|
1275
|
-
str += `${
|
|
1288
|
+
str += `${this.quoteStart},${this.quoteEnd}`;
|
|
1276
1289
|
} else {
|
|
1277
1290
|
throw new Error('Text fragment requires either a quote or quoteStart/quoteEnd');
|
|
1278
1291
|
}
|
|
1279
1292
|
|
|
1280
1293
|
if (this.suffix) {
|
|
1281
|
-
str +=
|
|
1294
|
+
str += ',-';
|
|
1295
|
+
str += normalize(this.suffix);
|
|
1282
1296
|
}
|
|
1283
|
-
|
|
1297
|
+
|
|
1298
|
+
return encodeURIComponent(str)
|
|
1299
|
+
// Bring back the delimiters so it's easier to read, but note not necessary.
|
|
1300
|
+
// Note hyphens are not encoded by encodeURIComponent, so no need to replace them
|
|
1301
|
+
.replace(/%2C/g, ',')
|
|
1302
|
+
.replace(/%3A/g, ':');
|
|
1284
1303
|
}
|
|
1285
1304
|
|
|
1286
1305
|
/**
|
|
1287
1306
|
* Build a regex that matches this quote payload.
|
|
1288
|
-
* @param {{
|
|
1307
|
+
* @param {{ context?: boolean }} [options]
|
|
1289
1308
|
* @returns {RegExp}
|
|
1290
1309
|
*/
|
|
1291
|
-
toRegExp({
|
|
1310
|
+
toRegExp({ context = false } = {}) {
|
|
1292
1311
|
/** @type {[String] | [String, String]} */
|
|
1293
1312
|
const quotes = this.quote ? [this.quote] : [this.quoteStart, this.quoteEnd];
|
|
1294
1313
|
|
|
@@ -1297,12 +1316,16 @@ export class BookReaderTextFragment {
|
|
|
1297
1316
|
if (this.suffix) quotes[quotes.length - 1] = quotes[quotes.length - 1] + ' ' + this.suffix;
|
|
1298
1317
|
}
|
|
1299
1318
|
|
|
1319
|
+
// Make it resilient to extra whitespace
|
|
1320
|
+
/** @param {String} quote */
|
|
1321
|
+
const quoteToRegExpString = (quote) => RegExp.escape(replaceTextFragmentDelimiters(quote)).replace(/(\\x20)+/g, '\\s+');
|
|
1322
|
+
|
|
1300
1323
|
if (quotes.length === 1) {
|
|
1301
|
-
return new RegExp(
|
|
1324
|
+
return new RegExp(quoteToRegExpString(quotes[0]), 'gi');
|
|
1302
1325
|
} else {
|
|
1303
1326
|
return new RegExp(
|
|
1304
|
-
|
|
1305
|
-
'
|
|
1327
|
+
quoteToRegExpString(quotes[0]) + '[\\s\\S]*?' + quoteToRegExpString(quotes[1]),
|
|
1328
|
+
'gi',
|
|
1306
1329
|
);
|
|
1307
1330
|
}
|
|
1308
1331
|
}
|
|
@@ -1356,11 +1379,11 @@ export class BookReaderTextFragment {
|
|
|
1356
1379
|
|
|
1357
1380
|
const CONTEXT_WORD_COUNT = 3;
|
|
1358
1381
|
|
|
1359
|
-
const preStartText =
|
|
1382
|
+
const preStartText = textLayerRangeToString(preStartRange);
|
|
1360
1383
|
let prefix = getLastWords(CONTEXT_WORD_COUNT, preStartText);
|
|
1361
1384
|
let prefixWords = countWords(prefix);
|
|
1362
1385
|
|
|
1363
|
-
const postEndText =
|
|
1386
|
+
const postEndText = textLayerRangeToString(postEndRange);
|
|
1364
1387
|
let suffix = getFirstWords(CONTEXT_WORD_COUNT, postEndText);
|
|
1365
1388
|
let suffixWords = countWords(suffix);
|
|
1366
1389
|
|
|
@@ -1376,8 +1399,7 @@ export class BookReaderTextFragment {
|
|
|
1376
1399
|
prefixWords = countWords(prefix);
|
|
1377
1400
|
}
|
|
1378
1401
|
|
|
1379
|
-
|
|
1380
|
-
const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
|
|
1402
|
+
const quote = textLayerRangeToString(fullQuoteRange);
|
|
1381
1403
|
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1382
1404
|
|
|
1383
1405
|
return new BookReaderTextFragment({
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @template T
|
|
5
|
+
* Get the i-th element of an iterable
|
|
6
|
+
* @param {Iterable<T>} iterable
|
|
7
|
+
* @param {number} index
|
|
8
|
+
*/
|
|
9
|
+
export function genAt(iterable, index) {
|
|
10
|
+
let i = 0;
|
|
11
|
+
for (const x of iterable) {
|
|
12
|
+
if (i == index) return x;
|
|
13
|
+
i++;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @template T
|
|
20
|
+
* Generator version of filter
|
|
21
|
+
* @param {Iterable<T>} iterable
|
|
22
|
+
* @param {function(T): boolean} fn
|
|
23
|
+
*/
|
|
24
|
+
export function* genFilter(iterable, fn) {
|
|
25
|
+
for (const x of iterable) {
|
|
26
|
+
if (fn(x)) yield x;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @template TFrom, TTo
|
|
32
|
+
* Generator version of map
|
|
33
|
+
* @param {Iterable<TFrom>} gen
|
|
34
|
+
* @param {function(TFrom): TTo} fn
|
|
35
|
+
* @returns {Iterable<TTo>}
|
|
36
|
+
*/
|
|
37
|
+
export function* genMap(gen, fn) {
|
|
38
|
+
for (const x of gen) yield fn(x);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @template T
|
|
43
|
+
* Generator that provides a sliding window of 3 elements,
|
|
44
|
+
* prev, current, and next.
|
|
45
|
+
* @param {Iterable<T>} gen
|
|
46
|
+
* @returns {Iterable<[T | undefined, T, T | undefined]>}
|
|
47
|
+
*/
|
|
48
|
+
export function* lookAroundWindow(gen) {
|
|
49
|
+
let prev = undefined;
|
|
50
|
+
let cur = undefined;
|
|
51
|
+
let next = undefined;
|
|
52
|
+
for (const x of gen) {
|
|
53
|
+
if (typeof cur !== 'undefined') {
|
|
54
|
+
next = x;
|
|
55
|
+
yield [prev, cur, next];
|
|
56
|
+
}
|
|
57
|
+
prev = cur;
|
|
58
|
+
cur = x;
|
|
59
|
+
next = undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof cur !== 'undefined') {
|
|
63
|
+
yield [prev, cur, next];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @template T1, T2
|
|
69
|
+
* Lazy zip implementation to avoid importing lodash
|
|
70
|
+
* Expects iterators to be of the same length
|
|
71
|
+
* @param {Iterable<T1>} gen1
|
|
72
|
+
* @param {Iterable<T2>} gen2
|
|
73
|
+
* @returns {Iterable<[T1, T2]>}
|
|
74
|
+
*/
|
|
75
|
+
export function* zip(gen1, gen2) {
|
|
76
|
+
const it1 = gen1[Symbol.iterator]();
|
|
77
|
+
const it2 = gen2[Symbol.iterator]();
|
|
78
|
+
while (true) {
|
|
79
|
+
const r1 = it1.next();
|
|
80
|
+
const r2 = it2.next();
|
|
81
|
+
if (r1.done && r2.done) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (r1.done || r2.done) {
|
|
85
|
+
throw new Error('zip: one of the iterators is done');
|
|
86
|
+
}
|
|
87
|
+
yield [r1.value, r2.value];
|
|
88
|
+
}
|
|
89
|
+
}
|