@internetarchive/bookreader 5.0.0-112 → 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 +20 -7
- package/BookReader/BookReader.js +43 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +3 -45
- 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.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 -23
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +3 -25
- 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 -2
- 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/package.json +1 -1
- package/src/BookReader/utils/SelectionObserver.js +2 -2
- package/src/BookReader.js +5 -4
- package/src/css/_BRpages.scss +5 -2
- package/src/css/_TextSelection.scss +21 -9
- package/src/ia-bookreader/ia-bookreader.js +13 -2
- package/src/plugins/plugin.experiments.js +44 -8
- package/src/plugins/plugin.text_selection.js +26 -56
- package/src/plugins/tts/plugin.tts.js +3 -3
- package/src/plugins/tts/utils.js +0 -10
- package/src/plugins/url/UrlPlugin.js +2 -15
- package/src/plugins/url/plugin.url.js +7 -59
- package/src/util/TextSelectionManager.js +950 -138
- package/src/util/strings.js +10 -0
- package/BookReader/plugins/plugin.url.js.LICENSE.txt +0 -1
|
@@ -1,8 +1,15 @@
|
|
|
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
|
-
import { customElement } from 'lit/decorators.js';
|
|
5
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
6
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
5
7
|
import '@internetarchive/icon-share';
|
|
8
|
+
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
9
|
+
|
|
10
|
+
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
11
|
+
const MAX_FULL_QUOTE_URL_CHARS = 80;
|
|
12
|
+
const TRUNCATED_QUOTE_WORD_COUNT = 3;
|
|
6
13
|
|
|
7
14
|
export class TextSelectionManager {
|
|
8
15
|
options = {
|
|
@@ -12,8 +19,10 @@ export class TextSelectionManager {
|
|
|
12
19
|
|
|
13
20
|
/** @type {BRSelectMenu} */
|
|
14
21
|
selectMenu;
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
|
|
23
|
+
get selectMenuEnabled() {
|
|
24
|
+
return this.br.plugins.experiments?.isEnabled('copyLinkToHighlight') || this.br.plugins.experiments?.isEnabled('annotateHighlight');
|
|
25
|
+
}
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
28
|
* @param {string} layer Selector for the text layer to manage
|
|
@@ -46,21 +55,21 @@ export class TextSelectionManager {
|
|
|
46
55
|
// Set a class on the page to avoid hiding it when zooming/etc
|
|
47
56
|
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
48
57
|
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
49
|
-
this.
|
|
58
|
+
this.showSelectMenu();
|
|
50
59
|
|
|
51
60
|
}
|
|
52
61
|
|
|
53
|
-
if (selectEvent == '
|
|
62
|
+
if (selectEvent == 'changed') {
|
|
54
63
|
// hide the button as user changes their selection
|
|
55
64
|
if (this.mouseIsDown) {
|
|
56
|
-
this.
|
|
57
|
-
} else if (window.getSelection()
|
|
58
|
-
this.
|
|
65
|
+
this.hideSelectMenu();
|
|
66
|
+
} else if (window.getSelection()?.toString()) {
|
|
67
|
+
this.showSelectMenu();
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
if (selectEvent == 'cleared') {
|
|
63
|
-
this.
|
|
72
|
+
this.hideSelectMenu();
|
|
64
73
|
}
|
|
65
74
|
}).attach();
|
|
66
75
|
}
|
|
@@ -68,9 +77,7 @@ export class TextSelectionManager {
|
|
|
68
77
|
// Need attach + detach methods to toggle w/ Translation plugin
|
|
69
78
|
attach() {
|
|
70
79
|
this.selectionObserver.attach();
|
|
71
|
-
|
|
72
|
-
this.renderSelectionMenu();
|
|
73
|
-
}
|
|
80
|
+
|
|
74
81
|
if (this.br.protected) {
|
|
75
82
|
document.addEventListener('selectionchange', this._limitSelection);
|
|
76
83
|
// Prevent right clicking when selected text
|
|
@@ -96,12 +103,8 @@ export class TextSelectionManager {
|
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
renderSelectionMenu() {
|
|
100
|
-
if (document.querySelector('.br-select-menu__option')) return;
|
|
101
|
-
document.body.append(this.selectMenu);
|
|
102
|
-
}
|
|
103
106
|
/**
|
|
104
|
-
* @param {'started' | 'cleared' | '
|
|
107
|
+
* @param {'started' | 'cleared' | 'changed'} type
|
|
105
108
|
* @param {HTMLElement} target
|
|
106
109
|
*/
|
|
107
110
|
_onSelectionChange = (type, target) => {
|
|
@@ -109,7 +112,7 @@ export class TextSelectionManager {
|
|
|
109
112
|
this.textSelectingMode(target);
|
|
110
113
|
} else if (type === 'cleared') {
|
|
111
114
|
this.defaultMode(target);
|
|
112
|
-
} else if (type === '
|
|
115
|
+
} else if (type === 'changed') {
|
|
113
116
|
// do nothing, just wait for the mouseup to trigger the styling change
|
|
114
117
|
} else {
|
|
115
118
|
throw new Error(`Unknown type ${type}`);
|
|
@@ -162,7 +165,7 @@ export class TextSelectionManager {
|
|
|
162
165
|
// blocking selection
|
|
163
166
|
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
164
167
|
this.mouseIsDown = true;
|
|
165
|
-
this.
|
|
168
|
+
this.hideSelectMenu();
|
|
166
169
|
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
167
170
|
event.stopPropagation();
|
|
168
171
|
}
|
|
@@ -170,7 +173,7 @@ export class TextSelectionManager {
|
|
|
170
173
|
|
|
171
174
|
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
172
175
|
this.mouseIsDown = false;
|
|
173
|
-
this.
|
|
176
|
+
this.hideSelectMenu();
|
|
174
177
|
textLayer.style.pointerEvents = "none";
|
|
175
178
|
if (skipNextMouseup) {
|
|
176
179
|
skipNextMouseup = false;
|
|
@@ -196,7 +199,7 @@ export class TextSelectionManager {
|
|
|
196
199
|
if (event.which != 1) return;
|
|
197
200
|
this.mouseIsDown = true;
|
|
198
201
|
event.stopPropagation();
|
|
199
|
-
this.
|
|
202
|
+
this.hideSelectMenu();
|
|
200
203
|
});
|
|
201
204
|
|
|
202
205
|
// Prevent page flip on click
|
|
@@ -204,10 +207,27 @@ export class TextSelectionManager {
|
|
|
204
207
|
this.mouseIsDown = false;
|
|
205
208
|
if (event.which != 1) return;
|
|
206
209
|
event.stopPropagation();
|
|
207
|
-
this.
|
|
210
|
+
this.showSelectMenu();
|
|
208
211
|
});
|
|
209
212
|
}
|
|
210
213
|
|
|
214
|
+
showSelectMenu() {
|
|
215
|
+
if (!this.selectMenuEnabled) return;
|
|
216
|
+
|
|
217
|
+
this.selectMenu.copyLinkToHighlightEnabled = this.br.plugins.experiments.isEnabled('copyLinkToHighlight');
|
|
218
|
+
this.selectMenu.highlightAnnotationEnabled = this.br.plugins.experiments.isEnabled('annotateHighlight');
|
|
219
|
+
|
|
220
|
+
if (!this.selectMenu.isConnected) {
|
|
221
|
+
document.body.append(this.selectMenu);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.selectMenu.show();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
hideSelectMenu() {
|
|
228
|
+
this.selectMenu.hide();
|
|
229
|
+
}
|
|
230
|
+
|
|
211
231
|
_limitSelection = () => {
|
|
212
232
|
const selection = window.getSelection();
|
|
213
233
|
if (!selection.rangeCount) return;
|
|
@@ -246,98 +266,6 @@ export class TextSelectionManager {
|
|
|
246
266
|
};
|
|
247
267
|
}
|
|
248
268
|
|
|
249
|
-
/**
|
|
250
|
-
* Builds a TextFragment string from a given text selection.
|
|
251
|
-
* Note does not include the fragment directive `:~:` or # symbol
|
|
252
|
-
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
253
|
-
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
254
|
-
* @param {HTMLElement[]} contextElements elements providing context for the selection
|
|
255
|
-
* @returns {string}
|
|
256
|
-
*/
|
|
257
|
-
export function createTextFragmentUrlParam(selection, contextElements) {
|
|
258
|
-
// TODO: Can import something that handles this more gracefully? see -
|
|
259
|
-
// https://web.dev/articles/text-fragments#:~:text=In%20its%20simplest%20form%2C%20the%20syntax%20of,percent%2Dencoded%20text%20I%20want%20to%20link%20to.
|
|
260
|
-
|
|
261
|
-
// :~:text=[prefix-,]textStart[,textEnd][,-suffix]
|
|
262
|
-
const highlightedText = selection.toString().replace(/[\s]+/g, " ").trim().split(" ");
|
|
263
|
-
const direction = selection.direction;
|
|
264
|
-
const startNode = direction == 'backward' ? selection.focusNode : selection.anchorNode;
|
|
265
|
-
const endNode = direction == 'backward' ? selection.anchorNode : selection.focusNode;
|
|
266
|
-
// If text selection begins or ends with a space, we look for the next eligible word to serve as the start or end word
|
|
267
|
-
const startWord = startNode.textContent.replace(/[\s]+/g, "") ? startNode.textContent : highlightedText[0];
|
|
268
|
-
const endWord = endNode.textContent.replace(/[\s]+/g, "") ? endNode.textContent : highlightedText[highlightedText.length - 1];
|
|
269
|
-
|
|
270
|
-
const textStartRe = RegExp.escape(startWord);
|
|
271
|
-
const textEndRe = RegExp.escape(endWord);
|
|
272
|
-
|
|
273
|
-
// 's' regex modifier ensures the `.` also captures newline characters
|
|
274
|
-
// Need to use lookahead/lookbehind assertions to allow for overlapping quotes (i.e. multiple "Holmes" on the same page)
|
|
275
|
-
const startPhraseMatchRe = new RegExp(String.raw`(?<=(${textStartRe}).*?)(${textEndRe})`, "gis");
|
|
276
|
-
const endPhraseMatchRe = new RegExp(String.raw`(${textStartRe})(?=.*?(${textEndRe}))`, "gis");
|
|
277
|
-
|
|
278
|
-
// Duplicated spaces in pageLayer.textContent for some reason
|
|
279
|
-
const selectionContext = contextElements
|
|
280
|
-
.map((el) => el.textContent)
|
|
281
|
-
.join(' ')
|
|
282
|
-
.replace(/\s+/g, " ");
|
|
283
|
-
const startPhraseFoundMatches = selectionContext.matchAll(startPhraseMatchRe).toArray();
|
|
284
|
-
const endPhraseFoundMatches = selectionContext.matchAll(endPhraseMatchRe).toArray();
|
|
285
|
-
if (startPhraseFoundMatches.length == 1 && endPhraseFoundMatches.length == 1) {
|
|
286
|
-
// If `startWord...endWord` quote is unambiguous and only occurs once, no prefix-/-suffix is needed for the URL param
|
|
287
|
-
return `text=${encodeURIComponent(startWord)},${encodeURIComponent(endWord)}`;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Need to add some additional context to `startWord...endWord` by including surrounding words before and after the keywords
|
|
291
|
-
const preStartRange = document.createRange();
|
|
292
|
-
preStartRange.setStart(contextElements[0].firstElementChild, 0);
|
|
293
|
-
preStartRange.setEnd(startNode, 0);
|
|
294
|
-
|
|
295
|
-
const postEndRange = document.createRange();
|
|
296
|
-
postEndRange.setStart(endNode, endNode.textContent.length);
|
|
297
|
-
const lastWordOfPageEl = getLastMostElement(contextElements[contextElements.length - 1]);
|
|
298
|
-
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
299
|
-
|
|
300
|
-
// prefixes/suffixes cannot contain paragraph breaks, words that are from more than one line break away should not be included
|
|
301
|
-
const prefix = getLastWords(3, preStartRange.toString())
|
|
302
|
-
.replace(/[ ]+/g, " ")
|
|
303
|
-
.trim()
|
|
304
|
-
.replace(/^[^\n]*\n/gm, "");
|
|
305
|
-
const suffix = getFirstWords(3, postEndRange.toString())
|
|
306
|
-
.replace(/[ ]+/g, " ")
|
|
307
|
-
.trim()
|
|
308
|
-
.replace(/\n[^\n]*$/gm, "");
|
|
309
|
-
|
|
310
|
-
// Partially selected words need to be captured completely
|
|
311
|
-
// Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
|
|
312
|
-
const fullHighlight = selection.toString().replace(/\s+/g, " ").trim().split(/\s/g);
|
|
313
|
-
// Capture start/end words that may be partially highlighted
|
|
314
|
-
if (startNode.textContent.trim().length != 0) {
|
|
315
|
-
if (!startNode.textContent.includes(fullHighlight[0])) {
|
|
316
|
-
fullHighlight.unshift(startNode.textContent);
|
|
317
|
-
} else {
|
|
318
|
-
fullHighlight[0] = startNode.textContent;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (endNode.textContent.trim().length != 0) {
|
|
322
|
-
if (!endNode.textContent.includes(fullHighlight[fullHighlight.length - 1])) {
|
|
323
|
-
fullHighlight.push(endNode.textContent);
|
|
324
|
-
}
|
|
325
|
-
fullHighlight[fullHighlight.length - 1] = endNode.textContent;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
let quote = [fullHighlight.join(" ")];
|
|
329
|
-
if (fullHighlight.length > 6) {
|
|
330
|
-
quote = [fullHighlight.slice(0, 3).join(" "), fullHighlight.slice(-3).join(" ")];
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const textFragmentArr = [];
|
|
334
|
-
if (prefix) textFragmentArr.push(`${prefix}-`);
|
|
335
|
-
textFragmentArr.push(...quote);
|
|
336
|
-
if (suffix) textFragmentArr.push(`-${suffix}`);
|
|
337
|
-
|
|
338
|
-
return `text=${textFragmentArr.map(encodeURIComponent).join(',')}`;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
269
|
/**
|
|
342
270
|
* @template T
|
|
343
271
|
* Get the i-th element of an iterable
|
|
@@ -413,11 +341,133 @@ export function* walkBetweenNodes(start, end) {
|
|
|
413
341
|
yield* walk(start);
|
|
414
342
|
}
|
|
415
343
|
|
|
344
|
+
@customElement('br-menu-option')
|
|
345
|
+
export class BRSelectMenuOption extends LitElement {
|
|
346
|
+
@property({type: String})
|
|
347
|
+
icon = '';
|
|
348
|
+
|
|
349
|
+
@property({type: String})
|
|
350
|
+
label = '';
|
|
351
|
+
|
|
352
|
+
/** @type {string | null} */
|
|
353
|
+
temporaryText = null;
|
|
354
|
+
|
|
355
|
+
@property({type: Number, attribute: 'temporary-text-duration'})
|
|
356
|
+
temporaryTextDuration = 1200;
|
|
357
|
+
|
|
358
|
+
@property({type: String, attribute: 'aria-label'})
|
|
359
|
+
ariaLabel = '';
|
|
360
|
+
|
|
361
|
+
@property({type: Boolean, attribute: 'live-label'})
|
|
362
|
+
liveLabel = false;
|
|
363
|
+
|
|
364
|
+
@property({type: Boolean})
|
|
365
|
+
temporaryTextVisible = false;
|
|
366
|
+
|
|
367
|
+
/** @type {number | null} */
|
|
368
|
+
temporaryTextTimeoutId = null;
|
|
369
|
+
|
|
370
|
+
/** @override */
|
|
371
|
+
createRenderRoot() {
|
|
372
|
+
// Keep styles from existing stylesheet by opting out of shadow DOM
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @override */
|
|
377
|
+
disconnectedCallback() {
|
|
378
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
379
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
380
|
+
this.temporaryTextTimeoutId = null;
|
|
381
|
+
}
|
|
382
|
+
super.disconnectedCallback();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {string} text
|
|
387
|
+
*/
|
|
388
|
+
showTemporaryText(text) {
|
|
389
|
+
if (!text?.trim()) return;
|
|
390
|
+
|
|
391
|
+
this.temporaryText = text;
|
|
392
|
+
|
|
393
|
+
this.temporaryTextVisible = true;
|
|
394
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
395
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
396
|
+
}
|
|
397
|
+
this.temporaryTextTimeoutId = window.setTimeout(() => {
|
|
398
|
+
this.temporaryTextVisible = false;
|
|
399
|
+
this.temporaryText = null;
|
|
400
|
+
this.temporaryTextTimeoutId = null;
|
|
401
|
+
}, this.temporaryTextDuration);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
renderIcon() {
|
|
405
|
+
if (this.icon === 'share') {
|
|
406
|
+
return html`<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>`;
|
|
407
|
+
}
|
|
408
|
+
if (this.icon === 'edit-pencil') {
|
|
409
|
+
return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
|
|
410
|
+
}
|
|
411
|
+
return '';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
render() {
|
|
415
|
+
const baseLabel = this.label ?? '';
|
|
416
|
+
const hasTemporaryText = !!this.temporaryText;
|
|
417
|
+
const temporaryLabel = hasTemporaryText ? this.temporaryText : baseLabel;
|
|
418
|
+
const showTemporaryLabel = hasTemporaryText && this.temporaryTextVisible;
|
|
419
|
+
const visibleLabel = showTemporaryLabel ? temporaryLabel : baseLabel;
|
|
420
|
+
const providedAriaLabel = this.ariaLabel?.trim();
|
|
421
|
+
const accessibleLabel = providedAriaLabel && providedAriaLabel !== visibleLabel.trim() ? providedAriaLabel : undefined;
|
|
422
|
+
const sizingLabel = baseLabel.length >= temporaryLabel.length ? baseLabel : temporaryLabel;
|
|
423
|
+
|
|
424
|
+
return html`
|
|
425
|
+
<button
|
|
426
|
+
type="button"
|
|
427
|
+
class="br-select-menu__option"
|
|
428
|
+
role="menuitem"
|
|
429
|
+
aria-label=${ifDefined(accessibleLabel)}
|
|
430
|
+
>
|
|
431
|
+
${this.renderIcon()}
|
|
432
|
+
${hasTemporaryText ? html`
|
|
433
|
+
<span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
|
|
434
|
+
<span
|
|
435
|
+
class="br-select-menu__label"
|
|
436
|
+
style="visibility: hidden;"
|
|
437
|
+
aria-hidden="true"
|
|
438
|
+
>${sizingLabel}</span>
|
|
439
|
+
<span
|
|
440
|
+
class="br-select-menu__label"
|
|
441
|
+
style="position: absolute; inset: 0; display: inline-flex; align-items: center;"
|
|
442
|
+
aria-live=${this.liveLabel ? 'polite' : 'off'}
|
|
443
|
+
>${showTemporaryLabel ? temporaryLabel : baseLabel}</span>
|
|
444
|
+
</span>
|
|
445
|
+
` : html`
|
|
446
|
+
<span class="br-select-menu__label" aria-live=${this.liveLabel ? 'polite' : 'off'}>${baseLabel}</span>
|
|
447
|
+
`}
|
|
448
|
+
</button>
|
|
449
|
+
`;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
416
453
|
@customElement('br-select-menu')
|
|
417
454
|
class BRSelectMenu extends LitElement {
|
|
418
455
|
/** @type {import('../BookReader.js').default} */
|
|
419
456
|
br;
|
|
420
457
|
|
|
458
|
+
/** @type {BRSelectMenuOption | null} */
|
|
459
|
+
@query('#copy-link-option')
|
|
460
|
+
copyLinkOption;
|
|
461
|
+
|
|
462
|
+
@property({type: Boolean})
|
|
463
|
+
copyLinkToHighlightEnabled = true;
|
|
464
|
+
|
|
465
|
+
@property({type: Boolean})
|
|
466
|
+
highlightAnnotationEnabled = true;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* @param {import('../BookReader.js').default} br
|
|
470
|
+
*/
|
|
421
471
|
constructor(br) {
|
|
422
472
|
super();
|
|
423
473
|
this.br = br;
|
|
@@ -429,46 +479,231 @@ class BRSelectMenu extends LitElement {
|
|
|
429
479
|
return this;
|
|
430
480
|
}
|
|
431
481
|
|
|
482
|
+
/** @override */
|
|
483
|
+
connectedCallback() {
|
|
484
|
+
super.connectedCallback();
|
|
485
|
+
this.setAttribute('role', 'menu');
|
|
486
|
+
this.setAttribute('aria-label', 'Text selection actions');
|
|
487
|
+
this.setAttribute('aria-orientation', 'vertical');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
renderCopyLinkToHighlightOption() {
|
|
491
|
+
return html`
|
|
492
|
+
<br-menu-option
|
|
493
|
+
id="copy-link-option"
|
|
494
|
+
@click=${this.handleCopyLinkToHighlight}
|
|
495
|
+
icon="share"
|
|
496
|
+
label="Copy Link to Highlight"
|
|
497
|
+
live-label
|
|
498
|
+
></br-menu-option>
|
|
499
|
+
`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
renderRemoveOption() {
|
|
503
|
+
return html`
|
|
504
|
+
<br-menu-option
|
|
505
|
+
@click=${this.handleDeleteHighlight}
|
|
506
|
+
icon="share"
|
|
507
|
+
label="Delete Highlight and Annotation"
|
|
508
|
+
></br-menu-option>
|
|
509
|
+
`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
renderAddAnnotationOption() {
|
|
513
|
+
return html`
|
|
514
|
+
<br-menu-option
|
|
515
|
+
@click=${this.handleAddAnnotation}
|
|
516
|
+
icon="edit-pencil"
|
|
517
|
+
label="Annotate"
|
|
518
|
+
></br-menu-option>
|
|
519
|
+
`;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
renderHighlightOption() {
|
|
523
|
+
return html`
|
|
524
|
+
<br-menu-option
|
|
525
|
+
@click=${this.handleHighlightSave}
|
|
526
|
+
icon="edit-pencil"
|
|
527
|
+
label="Highlight Selection"
|
|
528
|
+
></br-menu-option>
|
|
529
|
+
`;
|
|
530
|
+
}
|
|
531
|
+
renderLocalStorageOptions() {
|
|
532
|
+
return html`
|
|
533
|
+
<br-menu-option
|
|
534
|
+
@click=${this.renderSavedHighlights}
|
|
535
|
+
icon="share"
|
|
536
|
+
label="Load Highlights"
|
|
537
|
+
></br-menu-option>
|
|
538
|
+
<br-menu-option
|
|
539
|
+
@click=${() => {window.localStorage.removeItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY);}}
|
|
540
|
+
icon="share"
|
|
541
|
+
label="Remove Stored Highlights"
|
|
542
|
+
></br-menu-option>`;
|
|
543
|
+
}
|
|
544
|
+
|
|
432
545
|
render() {
|
|
546
|
+
// TODO change the second button to use a different icon
|
|
433
547
|
return html`
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
548
|
+
${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
|
|
549
|
+
${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
|
|
550
|
+
${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
|
|
551
|
+
${this.nodesForRemoval ? this.renderRemoveOption() : ''}
|
|
438
552
|
`;
|
|
439
553
|
}
|
|
440
554
|
|
|
441
555
|
/**
|
|
442
556
|
* @param {MouseEvent} e
|
|
443
557
|
*/
|
|
444
|
-
handleCopyLinkToHighlight(e) {
|
|
558
|
+
async handleCopyLinkToHighlight(e) {
|
|
445
559
|
e.preventDefault();
|
|
446
|
-
|
|
447
560
|
const currentParams = this.br.readQueryString();
|
|
448
|
-
const currentSelection = window.getSelection();
|
|
449
|
-
|
|
450
|
-
const textLayer =
|
|
451
|
-
|
|
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]);
|
|
452
569
|
|
|
453
570
|
// Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
|
|
454
571
|
let linkToHighlightParams = currentParams;
|
|
455
572
|
if (currentParams.includes('text=')) {
|
|
456
|
-
linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/,
|
|
573
|
+
linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
|
|
457
574
|
} else {
|
|
458
575
|
const sep = linkToHighlightParams ? '&' : '?';
|
|
459
|
-
linkToHighlightParams += `${sep}
|
|
576
|
+
linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
|
|
460
577
|
}
|
|
461
|
-
|
|
462
578
|
const currentUrl = window.location;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
579
|
+
const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
|
|
580
|
+
const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
|
|
581
|
+
const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
|
|
582
|
+
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
|
|
583
|
+
|
|
584
|
+
await navigator.clipboard.writeText(linkToHighlight);
|
|
585
|
+
this.copyLinkOption?.showTemporaryText('Copied!');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
590
|
+
* @param {Node} node
|
|
591
|
+
* @returns {Element | null}
|
|
592
|
+
*/
|
|
593
|
+
getNodeTextLayer(node) {
|
|
594
|
+
if (!node) return null;
|
|
595
|
+
const element = node instanceof Element ? node : node.parentElement;
|
|
596
|
+
return element?.closest('.BRtextLayer') ?? null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Retrieves the current selected text on the page and serializes the quote contents + context
|
|
601
|
+
* The selection is also changed in the DOM to highlight the words
|
|
602
|
+
*/
|
|
603
|
+
handleHighlightSave() {
|
|
604
|
+
const currentSelection = window.getSelection();
|
|
605
|
+
const start = currentSelection.direction === 'backward' ? currentSelection.focusNode.parentElement : currentSelection.anchorNode.parentElement;
|
|
606
|
+
const textLayer = this.getNodeTextLayer(start);
|
|
607
|
+
const highlight = BookReaderTextFragment.fromSelection(currentSelection, [textLayer.parentElement]);
|
|
608
|
+
highlight.uuid = `id-${crypto.randomUUID().split("-")[4]}`;
|
|
609
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
610
|
+
highlights.push(highlight);
|
|
611
|
+
this.saveToLocalStorage(highlights);
|
|
612
|
+
this.renderSavedHighlights();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
handleAddAnnotation(e) {
|
|
616
|
+
// TODO
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
handleDeleteHighlight() {
|
|
620
|
+
if (this.nodesForRemoval) {
|
|
621
|
+
const uuid = retrieveUUID(this.nodesForRemoval[0]);
|
|
622
|
+
for (const ele of this.nodesForRemoval) {
|
|
623
|
+
const tempText = ele.textContent;
|
|
624
|
+
const parent = ele.parentElement;
|
|
625
|
+
if (parent.classList.contains('BRwordElement') || parent.classList.contains('BRspace')) {
|
|
626
|
+
ele.remove();
|
|
627
|
+
parent.textContent = tempText;
|
|
628
|
+
} else {
|
|
629
|
+
console.log("This element did not match removal criteria:", parent, ele);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
this.deleteHighlight(uuid);
|
|
633
|
+
this.clearNodesForRemoval();
|
|
634
|
+
} else {
|
|
635
|
+
console.log("there is nothing to remove");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* @param {string} uuid
|
|
641
|
+
*/
|
|
642
|
+
deleteHighlight(uuid) {
|
|
643
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
644
|
+
for (let idx = 0; idx < highlights.length; idx++) {
|
|
645
|
+
if (highlights[idx].uuid === uuid) {
|
|
646
|
+
highlights.splice(idx, 1);
|
|
647
|
+
this.saveToLocalStorage(highlights);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Saves all the highlights in an array to localStorage
|
|
655
|
+
* @param {BookReaderSavedHighlight[]} highlights
|
|
656
|
+
*/
|
|
657
|
+
saveToLocalStorage(highlights) {
|
|
658
|
+
window.localStorage.setItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY, JSON.stringify(highlights.map(hl => hl.toJSON())));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* @returns {BookReaderSavedHighlight[]}
|
|
663
|
+
*/
|
|
664
|
+
loadHighlightsFromLocalStorage() {
|
|
665
|
+
return JSON.parse(window.localStorage.getItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY) || "[]")
|
|
666
|
+
.map(item => BookReaderTextFragment.fromJSON(item));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
renderSavedHighlights() {
|
|
670
|
+
for (const hl of this.loadHighlightsFromLocalStorage()) {
|
|
671
|
+
const textLayer = /** @type {HTMLElement} */ (this.br.$(`.pagediv${hl.pageIndex} .BRtextLayer`)[0]);
|
|
672
|
+
if (!textLayer) continue;
|
|
673
|
+
renderHighlight(textLayer, hl);
|
|
674
|
+
// Attach click behaviour here? Only need one handler per text layer
|
|
675
|
+
$(textLayer)
|
|
676
|
+
.off('mouseup.BRHighlightClick')
|
|
677
|
+
.on('mouseup.BRHighlightClick', (e) => {
|
|
678
|
+
if (!e.target.classList.contains("BRhighlight")) return;
|
|
679
|
+
e.stopPropagation();
|
|
680
|
+
this.handleHighlightClick(e.target);
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* @param {HTMLElement} target
|
|
687
|
+
*/
|
|
688
|
+
handleHighlightClick(target) {
|
|
689
|
+
const textLayer = this.getNodeTextLayer(target);
|
|
690
|
+
const identifier = retrieveUUID(target);
|
|
691
|
+
const selectedQuoteNodes = textLayer.querySelectorAll(`.${identifier}`);
|
|
692
|
+
|
|
693
|
+
const firstNode = selectedQuoteNodes[0];
|
|
694
|
+
const lastNode = selectedQuoteNodes[selectedQuoteNodes.length - 1];
|
|
695
|
+
|
|
696
|
+
const highlightRange = document.createRange();
|
|
697
|
+
highlightRange.setStart(firstNode, 0);
|
|
698
|
+
highlightRange.setEnd(lastNode, 1);
|
|
466
699
|
|
|
467
|
-
const
|
|
468
|
-
|
|
700
|
+
const currentSelection = window.getSelection();
|
|
701
|
+
currentSelection.removeAllRanges();
|
|
702
|
+
currentSelection.addRange(highlightRange);
|
|
703
|
+
this.show();
|
|
469
704
|
}
|
|
470
705
|
|
|
471
|
-
|
|
706
|
+
show() {
|
|
472
707
|
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
473
708
|
const currentSelection = window.getSelection();
|
|
474
709
|
const start = currentSelection.anchorNode.parentElement;
|
|
@@ -488,12 +723,22 @@ class BRSelectMenu extends LitElement {
|
|
|
488
723
|
this.style.zIndex = '1';
|
|
489
724
|
this.style.position = 'absolute';
|
|
490
725
|
this.style.display = 'block';
|
|
726
|
+
this.clearNodesForRemoval();
|
|
491
727
|
}
|
|
492
728
|
|
|
493
|
-
|
|
729
|
+
hide = () => {
|
|
494
730
|
this.style.display = 'none';
|
|
731
|
+
this.clearNodesForRemoval();
|
|
495
732
|
return;
|
|
496
733
|
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Remove temporary storage for the currently selected highlight and updates selection menu options
|
|
737
|
+
*/
|
|
738
|
+
clearNodesForRemoval = () => {
|
|
739
|
+
this.nodesForRemoval = null;
|
|
740
|
+
this.requestUpdate();
|
|
741
|
+
}
|
|
497
742
|
}
|
|
498
743
|
|
|
499
744
|
/**
|
|
@@ -521,12 +766,579 @@ export function getLastWords(numWords, text) {
|
|
|
521
766
|
}
|
|
522
767
|
|
|
523
768
|
/**
|
|
524
|
-
* @param {
|
|
769
|
+
* @param {Node} parent
|
|
525
770
|
* @returns {Node}
|
|
526
771
|
*/
|
|
527
|
-
export function
|
|
528
|
-
while (parent.
|
|
529
|
-
parent = parent.
|
|
772
|
+
export function getFirstMostNode(parent) {
|
|
773
|
+
while (parent.firstChild) {
|
|
774
|
+
parent = parent.firstChild;
|
|
530
775
|
}
|
|
531
776
|
return parent;
|
|
532
777
|
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* @param {Node} parent
|
|
781
|
+
* @returns {Node}
|
|
782
|
+
*/
|
|
783
|
+
export function getLastMostNode(parent) {
|
|
784
|
+
while (parent.lastChild) {
|
|
785
|
+
parent = parent.lastChild;
|
|
786
|
+
}
|
|
787
|
+
return parent;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Strips the whitespace to normalize text
|
|
792
|
+
* @param {String} string
|
|
793
|
+
* @returns
|
|
794
|
+
*/
|
|
795
|
+
function replaceWhitespace(string) {
|
|
796
|
+
return string.replace(/\s+/g, " ");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Finds all regex matches within a range and returns their ranges.
|
|
801
|
+
* @param {RegExp} regex
|
|
802
|
+
* @param {Range} range - the range to search in
|
|
803
|
+
* @param {Node[]} textNodes - visible text nodes within the range
|
|
804
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
805
|
+
* @returns {Range[] | undefined} Range for each match, or undefined if no matches are found
|
|
806
|
+
*/
|
|
807
|
+
export function findRangeForRegExp(regex, range, textNodes, { normalize = (s) => s } = {}) {
|
|
808
|
+
if (!textNodes.length) return undefined;
|
|
809
|
+
|
|
810
|
+
const startOffset = textNodes[0] === range.startContainer ?
|
|
811
|
+
range.startOffset :
|
|
812
|
+
0;
|
|
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;
|
|
835
|
+
}
|
|
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
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Uses the index that matches the quote string and normalizes the string contents to find the correct node
|
|
850
|
+
* @param {Number} index
|
|
851
|
+
* @param {Node[]} nodes
|
|
852
|
+
* @param {boolean} isEnd
|
|
853
|
+
* @param {{ normalize?: function(string): string }} [options]
|
|
854
|
+
*/
|
|
855
|
+
export function getBoundaryPointAtIndex(index, nodes, isEnd, { normalize = (s) => s } = {}) {
|
|
856
|
+
let counted = 0;
|
|
857
|
+
let normalizedData;
|
|
858
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
859
|
+
const node = nodes[i];
|
|
860
|
+
if (node.className === 'BRlineElement') {
|
|
861
|
+
// Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
|
|
862
|
+
normalizedData = ' ';
|
|
863
|
+
} else {
|
|
864
|
+
if (!normalizedData) normalizedData = normalize(node.textContent);
|
|
865
|
+
}
|
|
866
|
+
let nodeEnd = counted + normalizedData.length;
|
|
867
|
+
if (isEnd) nodeEnd += 1;
|
|
868
|
+
if (nodeEnd > index) {
|
|
869
|
+
const normalizedOffset = index - counted;
|
|
870
|
+
let denormalizedOffset = Math.min(index - counted, node.textContent.length);
|
|
871
|
+
|
|
872
|
+
const targetSubstring = isEnd ?
|
|
873
|
+
normalizedData.substring(0, normalizedOffset) :
|
|
874
|
+
normalizedData.substring(normalizedOffset);
|
|
875
|
+
|
|
876
|
+
let candidateSubstring = isEnd ?
|
|
877
|
+
normalize(node.textContent.substring(0, normalizedOffset)) :
|
|
878
|
+
normalize(node.textContent.substring(normalizedOffset));
|
|
879
|
+
|
|
880
|
+
const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
|
|
881
|
+
while (denormalizedOffset >= 0 &&
|
|
882
|
+
denormalizedOffset <= node.textContent.length) {
|
|
883
|
+
if (candidateSubstring.length === targetSubstring.length) {
|
|
884
|
+
return {node : node, offset: denormalizedOffset};
|
|
885
|
+
}
|
|
886
|
+
denormalizedOffset += direction;
|
|
887
|
+
|
|
888
|
+
candidateSubstring = isEnd ?
|
|
889
|
+
node.textContent.substring(0, denormalizedOffset) :
|
|
890
|
+
node.textContent.substring(denormalizedOffset);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
counted += normalizedData.length;
|
|
894
|
+
|
|
895
|
+
if (i + 1 < nodes.length) {
|
|
896
|
+
const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
|
|
897
|
+
// Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
|
|
898
|
+
// 1st case explicitly check the node class to prevent double counted spaces
|
|
899
|
+
// 2nd case can happen from node traversal when loading from localStorage
|
|
900
|
+
if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
|
|
901
|
+
counted -= 1;
|
|
902
|
+
} else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
|
|
903
|
+
counted -= 1;
|
|
904
|
+
}
|
|
905
|
+
normalizedData = nextNormalizedData;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Takes a text quote object and a container element, and wraps the quote
|
|
913
|
+
* within the container element in a span to apply highlight-like styling
|
|
914
|
+
*
|
|
915
|
+
* Iterate through the range and determine the "start" and "end" nodes
|
|
916
|
+
* Example of how this is done via polyfill
|
|
917
|
+
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
|
|
918
|
+
*
|
|
919
|
+
* @param {HTMLElement} textLayer
|
|
920
|
+
* @param {BookReaderTextFragment} textFragment
|
|
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
|
|
923
|
+
*/
|
|
924
|
+
export function renderHighlight(textLayer, textFragment, cssClassName = null) {
|
|
925
|
+
// Create a range that encompasses the entire text content
|
|
926
|
+
const firstPageNode = getFirstMostNode(textLayer);
|
|
927
|
+
const lastPageNode = getLastMostNode(textLayer);
|
|
928
|
+
const wholePageRange = new Range();
|
|
929
|
+
wholePageRange.setStart(firstPageNode, 0);
|
|
930
|
+
wholePageRange.setEnd(lastPageNode, lastPageNode.textContent.length);
|
|
931
|
+
const normalize = replaceWhitespace;
|
|
932
|
+
|
|
933
|
+
// Retrieve the text nodes and relevant whitespace elements
|
|
934
|
+
// Need to keep the BRlineElement nodes in between to keep the index count consistent, remove first BRlineElement since text starts from the first real text node
|
|
935
|
+
const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
|
|
936
|
+
pageWordNodes.splice(0, 1);
|
|
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");
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const broadRangeWordNodes = [];
|
|
950
|
+
for (const el of walkBetweenNodes(broadRanges[0].startContainer, broadRanges[0].endContainer)) {
|
|
951
|
+
if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
|
|
952
|
+
broadRangeWordNodes.push(el);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// At which point the quote should now be unambiguous!
|
|
957
|
+
const exactRanges = findRangeForRegExp(
|
|
958
|
+
textFragment.toRegExp({ normalize, context: false }),
|
|
959
|
+
broadRanges[0],
|
|
960
|
+
broadRangeWordNodes,
|
|
961
|
+
{ normalize },
|
|
962
|
+
);
|
|
963
|
+
if (!exactRanges) {
|
|
964
|
+
throw new Error("Could not find quote in page");
|
|
965
|
+
}
|
|
966
|
+
const startTextNode = getFirstMostNode(exactRanges[0].startContainer);
|
|
967
|
+
const endTextNode = getFirstMostNode(exactRanges[0].endContainer);
|
|
968
|
+
|
|
969
|
+
// markRange requires the range to start and end within text nodes
|
|
970
|
+
const exactRangeTextNodes = new Range();
|
|
971
|
+
exactRangeTextNodes.setStart(startTextNode, 0);
|
|
972
|
+
exactRangeTextNodes.setEnd(endTextNode, endTextNode.textContent.length);
|
|
973
|
+
|
|
974
|
+
// Don't mark if already marked
|
|
975
|
+
if (startTextNode.parentElement.classList.contains("BRhighlight") ||
|
|
976
|
+
endTextNode.parentElement.classList.contains("BRhighlight")) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return markRange(exactRangeTextNodes, () => {
|
|
981
|
+
const mark = document.createElement("mark");
|
|
982
|
+
mark.classList.add("BRhighlight");
|
|
983
|
+
if (cssClassName) mark.classList.add(cssClassName);
|
|
984
|
+
if (textFragment.uuid) mark.classList.add(textFragment.uuid);
|
|
985
|
+
return mark;
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Given a Range, wraps its text contents in one or more <mark> elements.
|
|
991
|
+
* <mark> elements can't cross block boundaries, so this function walks the
|
|
992
|
+
* tree to find all the relevant text nodes and wraps them.
|
|
993
|
+
* @param {Range} range - the range to mark. Must start and end inside of
|
|
994
|
+
* text nodes.
|
|
995
|
+
* @param {function(): Element} createWrappingElement - a function that creates
|
|
996
|
+
* the element to wrap text nodes in. This is called once per text node.
|
|
997
|
+
* @return {Element[]} The <mark> nodes that were created.
|
|
998
|
+
*
|
|
999
|
+
* Lightly adapted from https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L456
|
|
1000
|
+
*/
|
|
1001
|
+
function markRange(
|
|
1002
|
+
range,
|
|
1003
|
+
createWrappingElement = () => document.createElement("mark"),
|
|
1004
|
+
) {
|
|
1005
|
+
if (range.startContainer.nodeType != Node.TEXT_NODE ||
|
|
1006
|
+
range.endContainer.nodeType != Node.TEXT_NODE)
|
|
1007
|
+
return [];
|
|
1008
|
+
|
|
1009
|
+
// If the range is entirely within a single node, just surround it.
|
|
1010
|
+
if (range.startContainer === range.endContainer) {
|
|
1011
|
+
const trivialMark = createWrappingElement();
|
|
1012
|
+
range.surroundContents(trivialMark);
|
|
1013
|
+
return [trivialMark];
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Start node -- special case
|
|
1017
|
+
const startNode = range.startContainer;
|
|
1018
|
+
const startNodeSubrange = range.cloneRange();
|
|
1019
|
+
startNodeSubrange.setEndAfter(startNode);
|
|
1020
|
+
|
|
1021
|
+
// End node -- special case
|
|
1022
|
+
const endNode = range.endContainer;
|
|
1023
|
+
const endNodeSubrange = range.cloneRange();
|
|
1024
|
+
endNodeSubrange.setStartBefore(endNode);
|
|
1025
|
+
|
|
1026
|
+
// In between nodes
|
|
1027
|
+
const marks = [];
|
|
1028
|
+
range.setStartAfter(startNode);
|
|
1029
|
+
range.setEndBefore(endNode);
|
|
1030
|
+
const walker = document.createTreeWalker(
|
|
1031
|
+
range.commonAncestorContainer,
|
|
1032
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
|
1033
|
+
{
|
|
1034
|
+
acceptNode: function(node) {
|
|
1035
|
+
if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT;
|
|
1036
|
+
|
|
1037
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
1038
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
1039
|
+
return NodeFilter.FILTER_SKIP;
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
);
|
|
1043
|
+
let node = walker.nextNode();
|
|
1044
|
+
while (node) {
|
|
1045
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1046
|
+
const mark = createWrappingElement();
|
|
1047
|
+
node.parentNode.insertBefore(mark, node);
|
|
1048
|
+
mark.appendChild(node);
|
|
1049
|
+
marks.push(mark);
|
|
1050
|
+
}
|
|
1051
|
+
node = walker.nextNode();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const startMark = createWrappingElement();
|
|
1055
|
+
startNodeSubrange.surroundContents(startMark);
|
|
1056
|
+
const endMark = createWrappingElement();
|
|
1057
|
+
endNodeSubrange.surroundContents(endMark);
|
|
1058
|
+
|
|
1059
|
+
return [startMark, ...marks, endMark];
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Get UUID assigned to the highlight element from class list
|
|
1064
|
+
* @param {HTMLElement} ele
|
|
1065
|
+
* @returns
|
|
1066
|
+
*/
|
|
1067
|
+
function retrieveUUID(ele) {
|
|
1068
|
+
if (!ele) return null;
|
|
1069
|
+
const findUUID = Array.from(ele?.classList).filter((name) => {
|
|
1070
|
+
if (name.slice(0, 2).includes('id')) {
|
|
1071
|
+
return name;
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
if (findUUID.length) {
|
|
1075
|
+
return findUUID[0];
|
|
1076
|
+
}
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* An extension of the fields defined by the browser-native TextFragment;
|
|
1082
|
+
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
1083
|
+
*
|
|
1084
|
+
* Text fragment string format: `pageNumber:prefix-,quote,-suffix`
|
|
1085
|
+
* Or for long quotes: `pageNumber:prefix-,quoteStart,quoteEnd,-suffix`
|
|
1086
|
+
* Note the ':' and ',' separators must not be encoded, but
|
|
1087
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text can be encoded.
|
|
1088
|
+
*/
|
|
1089
|
+
export class BookReaderTextFragment {
|
|
1090
|
+
/**
|
|
1091
|
+
* @param {object} params
|
|
1092
|
+
* @param {string | null}params.prefix
|
|
1093
|
+
* @param {string | null} [params.quote]
|
|
1094
|
+
* @param {string | null} [params.quoteStart]
|
|
1095
|
+
* @param {string | null} [params.quoteEnd]
|
|
1096
|
+
* @param {string | null} params.suffix
|
|
1097
|
+
* @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
|
|
1098
|
+
* @param {number} params.pageIndex Page index; e.g. zero-based index of the page
|
|
1099
|
+
* @param {string | null} [params.uuid] UUID for the text fragment if it has one
|
|
1100
|
+
*/
|
|
1101
|
+
constructor({ prefix, quote, quoteStart, quoteEnd, suffix, pageNumber, pageIndex, uuid }) {
|
|
1102
|
+
/** @type {string|null} */
|
|
1103
|
+
this.prefix = prefix;
|
|
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;
|
|
1110
|
+
/** @type {string|null} */
|
|
1111
|
+
this.suffix = suffix;
|
|
1112
|
+
/** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
|
|
1113
|
+
this.pageNumber = pageNumber;
|
|
1114
|
+
/** @type {number} Page index; e.g. zero-based index of the page */
|
|
1115
|
+
this.pageIndex = pageIndex;
|
|
1116
|
+
/** @type {string | null} UUID for the text fragment if it has one */
|
|
1117
|
+
this.uuid = uuid ?? null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Parse a text fragment from its string value (the part after `text=`).
|
|
1122
|
+
* Expected format: `pageNumber:prefix-,quote,-suffix`
|
|
1123
|
+
* @param {string} str
|
|
1124
|
+
* @param {import('@/src/BookReader/BookModel.js').BookModel} book
|
|
1125
|
+
* @param {number} fallbackPageIndex A fallback page index to use if the text
|
|
1126
|
+
* fragment does not specify a page number or page index.
|
|
1127
|
+
* @returns {BookReaderTextFragment}
|
|
1128
|
+
*/
|
|
1129
|
+
static fromString(str, book, fallbackPageIndex) {
|
|
1130
|
+
// Basically matches:
|
|
1131
|
+
// (stuff):(stuff)-,(stuff),-(stuff)
|
|
1132
|
+
const match = str.match(/^(?:(.*?):)?(?:(.*?)-,)?(.*?)(?:,-(.*))?$/);
|
|
1133
|
+
if (!match) {
|
|
1134
|
+
throw new Error(`Invalid text fragment format: ${str}`);
|
|
1135
|
+
}
|
|
1136
|
+
const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
|
|
1137
|
+
const prefix = match[2] ? decodeURIComponent(match[2]) : null;
|
|
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
|
+
|
|
1153
|
+
const suffix = match[4] ? decodeURIComponent(match[4]) : null;
|
|
1154
|
+
const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
|
|
1155
|
+
if (typeof pageIndex !== 'number') {
|
|
1156
|
+
throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
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
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Extract and parse a text fragment from a URL string containing a `text=` parameter.
|
|
1176
|
+
* @param {string} urlString
|
|
1177
|
+
* @param {import('@/src/BookReader/BookModel.js').BookModel} book
|
|
1178
|
+
* @param {number} fallbackPageIndex A fallback page index to use if the text
|
|
1179
|
+
* fragment does not specify a page number or page index.
|
|
1180
|
+
* @returns {BookReaderTextFragment|null}
|
|
1181
|
+
*/
|
|
1182
|
+
static fromUrl(urlString, book, fallbackPageIndex) {
|
|
1183
|
+
// Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
|
|
1184
|
+
// commas and colons, so need to do a regex match to extract the text fragment string
|
|
1185
|
+
const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
|
|
1186
|
+
if (!textMatch) return null;
|
|
1187
|
+
return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
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`.
|
|
1194
|
+
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1195
|
+
* the pageNumber, prefix, quote/quoteStart/quoteEnd, and suffix text are encoded.
|
|
1196
|
+
* @returns {string}
|
|
1197
|
+
*/
|
|
1198
|
+
toUrlString() {
|
|
1199
|
+
// First the page number or index
|
|
1200
|
+
let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
|
|
1201
|
+
|
|
1202
|
+
if (this.prefix) {
|
|
1203
|
+
str += `${encodeURIComponent(this.prefix)}-,`;
|
|
1204
|
+
}
|
|
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
|
+
|
|
1229
|
+
if (this.suffix) {
|
|
1230
|
+
str += `,-${encodeURIComponent(this.suffix)}`;
|
|
1231
|
+
}
|
|
1232
|
+
return str;
|
|
1233
|
+
}
|
|
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
|
+
|
|
1259
|
+
toJSON() {
|
|
1260
|
+
return {
|
|
1261
|
+
prefix: this.prefix,
|
|
1262
|
+
quote: this.quote,
|
|
1263
|
+
quoteStart: this.quoteStart,
|
|
1264
|
+
quoteEnd: this.quoteEnd,
|
|
1265
|
+
suffix: this.suffix,
|
|
1266
|
+
pageNumber: this.pageNumber,
|
|
1267
|
+
pageIndex: this.pageIndex,
|
|
1268
|
+
uuid: this.uuid,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Create a BookReaderTextFragment instance from a JSON object.
|
|
1274
|
+
* @param {object} json
|
|
1275
|
+
* @returns {BookReaderTextFragment}
|
|
1276
|
+
*/
|
|
1277
|
+
static fromJSON(json) {
|
|
1278
|
+
return new BookReaderTextFragment(json);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* Builds a TextFragment string from a given text selection.
|
|
1283
|
+
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
1284
|
+
* @param {Element[]} contextElements elements providing context for the selection
|
|
1285
|
+
* @returns {BookReaderTextFragment}
|
|
1286
|
+
*/
|
|
1287
|
+
static fromSelection(selection, contextElements) {
|
|
1288
|
+
const range = selection.getRangeAt(0);
|
|
1289
|
+
|
|
1290
|
+
// Partially selected words need to be captured completely
|
|
1291
|
+
const fullQuoteRange = new Range();
|
|
1292
|
+
const startTextNode = getLastMostNode(range.startContainer);
|
|
1293
|
+
const endTextNode = getFirstMostNode(range.endContainer);
|
|
1294
|
+
fullQuoteRange.setStart(startTextNode, 0);
|
|
1295
|
+
fullQuoteRange.setEnd(endTextNode, range.endOffset == 0 ? 0 : (endTextNode.textContent?.length ?? 0));
|
|
1296
|
+
|
|
1297
|
+
const preStartRange = document.createRange();
|
|
1298
|
+
preStartRange.setStart(getFirstMostNode(contextElements[0]), 0);
|
|
1299
|
+
preStartRange.setEnd(startTextNode, 0);
|
|
1300
|
+
|
|
1301
|
+
const postEndRange = document.createRange();
|
|
1302
|
+
postEndRange.setStart(endTextNode, fullQuoteRange.endOffset);
|
|
1303
|
+
const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
|
|
1304
|
+
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
1305
|
+
|
|
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
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Guarantee that all whitespace is replaced with just one space and that the first/last word of the highlight is not a space
|
|
1329
|
+
const quote = replaceWhitespace(fullQuoteRange.toString()).trim();
|
|
1330
|
+
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1331
|
+
|
|
1332
|
+
return new BookReaderTextFragment({
|
|
1333
|
+
quote,
|
|
1334
|
+
prefix,
|
|
1335
|
+
suffix,
|
|
1336
|
+
pageNumber: pageContainerEl.getAttribute("data-page-num"),
|
|
1337
|
+
pageIndex: parseFloat(pageContainerEl.getAttribute("data-index")),
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* @typedef {BookReaderTextFragment & { uuid: string }} BookReaderSavedHighlight
|
|
1344
|
+
*/
|