@internetarchive/bookreader 5.0.0-111 → 5.0.0-113
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 +13 -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/Navbar/Navbar.js +9 -10
- 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 +15 -9
- package/src/plugins/plugin.experiments.js +31 -5
- package/src/plugins/plugin.text_selection.js +19 -56
- package/src/plugins/translate/plugin.translate.js +68 -22
- package/src/plugins/tts/utils.js +1 -1
- package/src/plugins/url/UrlPlugin.js +2 -15
- package/src/plugins/url/plugin.url.js +7 -59
- package/src/util/TextSelectionManager.js +834 -137
- package/BookReader/plugins/plugin.url.js.LICENSE.txt +0 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
3
3
|
import { html, LitElement } from 'lit';
|
|
4
|
-
import { customElement } from 'lit/decorators.js';
|
|
4
|
+
import { customElement, property, query } from 'lit/decorators.js';
|
|
5
|
+
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
5
6
|
import '@internetarchive/icon-share';
|
|
7
|
+
import '@internetarchive/icon-edit-pencil/icon-edit-pencil.js';
|
|
8
|
+
|
|
9
|
+
const BR_HIGHLIGHTS_LOCAL_STORAGE_KEY = "BRhighlightStorage";
|
|
6
10
|
|
|
7
11
|
export class TextSelectionManager {
|
|
8
12
|
options = {
|
|
@@ -12,8 +16,10 @@ export class TextSelectionManager {
|
|
|
12
16
|
|
|
13
17
|
/** @type {BRSelectMenu} */
|
|
14
18
|
selectMenu;
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
get selectMenuEnabled() {
|
|
21
|
+
return this.br.plugins.experiments?.isEnabled('copyLinkToHighlight') || this.br.plugins.experiments?.isEnabled('annotateHighlight');
|
|
22
|
+
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* @param {string} layer Selector for the text layer to manage
|
|
@@ -46,21 +52,21 @@ export class TextSelectionManager {
|
|
|
46
52
|
// Set a class on the page to avoid hiding it when zooming/etc
|
|
47
53
|
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
48
54
|
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
49
|
-
this.
|
|
55
|
+
this.showSelectMenu();
|
|
50
56
|
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
if (selectEvent == '
|
|
59
|
+
if (selectEvent == 'changed') {
|
|
54
60
|
// hide the button as user changes their selection
|
|
55
61
|
if (this.mouseIsDown) {
|
|
56
|
-
this.
|
|
57
|
-
} else if (window.getSelection()
|
|
58
|
-
this.
|
|
62
|
+
this.hideSelectMenu();
|
|
63
|
+
} else if (window.getSelection()?.toString()) {
|
|
64
|
+
this.showSelectMenu();
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
if (selectEvent == 'cleared') {
|
|
63
|
-
this.
|
|
69
|
+
this.hideSelectMenu();
|
|
64
70
|
}
|
|
65
71
|
}).attach();
|
|
66
72
|
}
|
|
@@ -68,9 +74,7 @@ export class TextSelectionManager {
|
|
|
68
74
|
// Need attach + detach methods to toggle w/ Translation plugin
|
|
69
75
|
attach() {
|
|
70
76
|
this.selectionObserver.attach();
|
|
71
|
-
|
|
72
|
-
this.renderSelectionMenu();
|
|
73
|
-
}
|
|
77
|
+
|
|
74
78
|
if (this.br.protected) {
|
|
75
79
|
document.addEventListener('selectionchange', this._limitSelection);
|
|
76
80
|
// Prevent right clicking when selected text
|
|
@@ -96,12 +100,8 @@ export class TextSelectionManager {
|
|
|
96
100
|
}
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
renderSelectionMenu() {
|
|
100
|
-
if (document.querySelector('.br-select-menu__option')) return;
|
|
101
|
-
document.body.append(this.selectMenu);
|
|
102
|
-
}
|
|
103
103
|
/**
|
|
104
|
-
* @param {'started' | 'cleared' | '
|
|
104
|
+
* @param {'started' | 'cleared' | 'changed'} type
|
|
105
105
|
* @param {HTMLElement} target
|
|
106
106
|
*/
|
|
107
107
|
_onSelectionChange = (type, target) => {
|
|
@@ -109,7 +109,7 @@ export class TextSelectionManager {
|
|
|
109
109
|
this.textSelectingMode(target);
|
|
110
110
|
} else if (type === 'cleared') {
|
|
111
111
|
this.defaultMode(target);
|
|
112
|
-
} else if (type === '
|
|
112
|
+
} else if (type === 'changed') {
|
|
113
113
|
// do nothing, just wait for the mouseup to trigger the styling change
|
|
114
114
|
} else {
|
|
115
115
|
throw new Error(`Unknown type ${type}`);
|
|
@@ -162,7 +162,7 @@ export class TextSelectionManager {
|
|
|
162
162
|
// blocking selection
|
|
163
163
|
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
164
164
|
this.mouseIsDown = true;
|
|
165
|
-
this.
|
|
165
|
+
this.hideSelectMenu();
|
|
166
166
|
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
167
167
|
event.stopPropagation();
|
|
168
168
|
}
|
|
@@ -170,7 +170,7 @@ export class TextSelectionManager {
|
|
|
170
170
|
|
|
171
171
|
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
172
172
|
this.mouseIsDown = false;
|
|
173
|
-
this.
|
|
173
|
+
this.hideSelectMenu();
|
|
174
174
|
textLayer.style.pointerEvents = "none";
|
|
175
175
|
if (skipNextMouseup) {
|
|
176
176
|
skipNextMouseup = false;
|
|
@@ -196,7 +196,7 @@ export class TextSelectionManager {
|
|
|
196
196
|
if (event.which != 1) return;
|
|
197
197
|
this.mouseIsDown = true;
|
|
198
198
|
event.stopPropagation();
|
|
199
|
-
this.
|
|
199
|
+
this.hideSelectMenu();
|
|
200
200
|
});
|
|
201
201
|
|
|
202
202
|
// Prevent page flip on click
|
|
@@ -204,10 +204,27 @@ export class TextSelectionManager {
|
|
|
204
204
|
this.mouseIsDown = false;
|
|
205
205
|
if (event.which != 1) return;
|
|
206
206
|
event.stopPropagation();
|
|
207
|
-
this.
|
|
207
|
+
this.showSelectMenu();
|
|
208
208
|
});
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
showSelectMenu() {
|
|
212
|
+
if (!this.selectMenuEnabled) return;
|
|
213
|
+
|
|
214
|
+
this.selectMenu.copyLinkToHighlightEnabled = this.br.plugins.experiments.isEnabled('copyLinkToHighlight');
|
|
215
|
+
this.selectMenu.highlightAnnotationEnabled = this.br.plugins.experiments.isEnabled('annotateHighlight');
|
|
216
|
+
|
|
217
|
+
if (!this.selectMenu.isConnected) {
|
|
218
|
+
document.body.append(this.selectMenu);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.selectMenu.show();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
hideSelectMenu() {
|
|
225
|
+
this.selectMenu.hide();
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
_limitSelection = () => {
|
|
212
229
|
const selection = window.getSelection();
|
|
213
230
|
if (!selection.rangeCount) return;
|
|
@@ -246,98 +263,6 @@ export class TextSelectionManager {
|
|
|
246
263
|
};
|
|
247
264
|
}
|
|
248
265
|
|
|
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
266
|
/**
|
|
342
267
|
* @template T
|
|
343
268
|
* Get the i-th element of an iterable
|
|
@@ -413,11 +338,133 @@ export function* walkBetweenNodes(start, end) {
|
|
|
413
338
|
yield* walk(start);
|
|
414
339
|
}
|
|
415
340
|
|
|
341
|
+
@customElement('br-menu-option')
|
|
342
|
+
export class BRSelectMenuOption extends LitElement {
|
|
343
|
+
@property({type: String})
|
|
344
|
+
icon = '';
|
|
345
|
+
|
|
346
|
+
@property({type: String})
|
|
347
|
+
label = '';
|
|
348
|
+
|
|
349
|
+
/** @type {string | null} */
|
|
350
|
+
temporaryText = null;
|
|
351
|
+
|
|
352
|
+
@property({type: Number, attribute: 'temporary-text-duration'})
|
|
353
|
+
temporaryTextDuration = 1200;
|
|
354
|
+
|
|
355
|
+
@property({type: String, attribute: 'aria-label'})
|
|
356
|
+
ariaLabel = '';
|
|
357
|
+
|
|
358
|
+
@property({type: Boolean, attribute: 'live-label'})
|
|
359
|
+
liveLabel = false;
|
|
360
|
+
|
|
361
|
+
@property({type: Boolean})
|
|
362
|
+
temporaryTextVisible = false;
|
|
363
|
+
|
|
364
|
+
/** @type {number | null} */
|
|
365
|
+
temporaryTextTimeoutId = null;
|
|
366
|
+
|
|
367
|
+
/** @override */
|
|
368
|
+
createRenderRoot() {
|
|
369
|
+
// Keep styles from existing stylesheet by opting out of shadow DOM
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** @override */
|
|
374
|
+
disconnectedCallback() {
|
|
375
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
376
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
377
|
+
this.temporaryTextTimeoutId = null;
|
|
378
|
+
}
|
|
379
|
+
super.disconnectedCallback();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @param {string} text
|
|
384
|
+
*/
|
|
385
|
+
showTemporaryText(text) {
|
|
386
|
+
if (!text?.trim()) return;
|
|
387
|
+
|
|
388
|
+
this.temporaryText = text;
|
|
389
|
+
|
|
390
|
+
this.temporaryTextVisible = true;
|
|
391
|
+
if (this.temporaryTextTimeoutId != null) {
|
|
392
|
+
window.clearTimeout(this.temporaryTextTimeoutId);
|
|
393
|
+
}
|
|
394
|
+
this.temporaryTextTimeoutId = window.setTimeout(() => {
|
|
395
|
+
this.temporaryTextVisible = false;
|
|
396
|
+
this.temporaryText = null;
|
|
397
|
+
this.temporaryTextTimeoutId = null;
|
|
398
|
+
}, this.temporaryTextDuration);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
renderIcon() {
|
|
402
|
+
if (this.icon === 'share') {
|
|
403
|
+
return html`<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>`;
|
|
404
|
+
}
|
|
405
|
+
if (this.icon === 'edit-pencil') {
|
|
406
|
+
return html`<ia-icon-edit-pencil class="br-select-menu__icon" aria-hidden="true"></ia-icon-edit-pencil>`;
|
|
407
|
+
}
|
|
408
|
+
return '';
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
render() {
|
|
412
|
+
const baseLabel = this.label ?? '';
|
|
413
|
+
const hasTemporaryText = !!this.temporaryText;
|
|
414
|
+
const temporaryLabel = hasTemporaryText ? this.temporaryText : baseLabel;
|
|
415
|
+
const showTemporaryLabel = hasTemporaryText && this.temporaryTextVisible;
|
|
416
|
+
const visibleLabel = showTemporaryLabel ? temporaryLabel : baseLabel;
|
|
417
|
+
const providedAriaLabel = this.ariaLabel?.trim();
|
|
418
|
+
const accessibleLabel = providedAriaLabel && providedAriaLabel !== visibleLabel.trim() ? providedAriaLabel : undefined;
|
|
419
|
+
const sizingLabel = baseLabel.length >= temporaryLabel.length ? baseLabel : temporaryLabel;
|
|
420
|
+
|
|
421
|
+
return html`
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
class="br-select-menu__option"
|
|
425
|
+
role="menuitem"
|
|
426
|
+
aria-label=${ifDefined(accessibleLabel)}
|
|
427
|
+
>
|
|
428
|
+
${this.renderIcon()}
|
|
429
|
+
${hasTemporaryText ? html`
|
|
430
|
+
<span class="br-select-menu__label-wrap" style="display: inline-flex; position: relative; align-items: center;">
|
|
431
|
+
<span
|
|
432
|
+
class="br-select-menu__label"
|
|
433
|
+
style="visibility: hidden;"
|
|
434
|
+
aria-hidden="true"
|
|
435
|
+
>${sizingLabel}</span>
|
|
436
|
+
<span
|
|
437
|
+
class="br-select-menu__label"
|
|
438
|
+
style="position: absolute; inset: 0; display: inline-flex; align-items: center;"
|
|
439
|
+
aria-live=${this.liveLabel ? 'polite' : 'off'}
|
|
440
|
+
>${showTemporaryLabel ? temporaryLabel : baseLabel}</span>
|
|
441
|
+
</span>
|
|
442
|
+
` : html`
|
|
443
|
+
<span class="br-select-menu__label" aria-live=${this.liveLabel ? 'polite' : 'off'}>${baseLabel}</span>
|
|
444
|
+
`}
|
|
445
|
+
</button>
|
|
446
|
+
`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
416
450
|
@customElement('br-select-menu')
|
|
417
451
|
class BRSelectMenu extends LitElement {
|
|
418
452
|
/** @type {import('../BookReader.js').default} */
|
|
419
453
|
br;
|
|
420
454
|
|
|
455
|
+
/** @type {BRSelectMenuOption | null} */
|
|
456
|
+
@query('#copy-link-option')
|
|
457
|
+
copyLinkOption;
|
|
458
|
+
|
|
459
|
+
@property({type: Boolean})
|
|
460
|
+
copyLinkToHighlightEnabled = true;
|
|
461
|
+
|
|
462
|
+
@property({type: Boolean})
|
|
463
|
+
highlightAnnotationEnabled = true;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* @param {import('../BookReader.js').default} br
|
|
467
|
+
*/
|
|
421
468
|
constructor(br) {
|
|
422
469
|
super();
|
|
423
470
|
this.br = br;
|
|
@@ -429,46 +476,225 @@ class BRSelectMenu extends LitElement {
|
|
|
429
476
|
return this;
|
|
430
477
|
}
|
|
431
478
|
|
|
479
|
+
/** @override */
|
|
480
|
+
connectedCallback() {
|
|
481
|
+
super.connectedCallback();
|
|
482
|
+
this.setAttribute('role', 'menu');
|
|
483
|
+
this.setAttribute('aria-label', 'Text selection actions');
|
|
484
|
+
this.setAttribute('aria-orientation', 'vertical');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
renderCopyLinkToHighlightOption() {
|
|
488
|
+
return html`
|
|
489
|
+
<br-menu-option
|
|
490
|
+
id="copy-link-option"
|
|
491
|
+
@click=${this.handleCopyLinkToHighlight}
|
|
492
|
+
icon="share"
|
|
493
|
+
label="Copy Link to Highlight"
|
|
494
|
+
live-label
|
|
495
|
+
></br-menu-option>
|
|
496
|
+
`;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
renderRemoveOption() {
|
|
500
|
+
return html`
|
|
501
|
+
<br-menu-option
|
|
502
|
+
@click=${this.handleDeleteHighlight}
|
|
503
|
+
icon="share"
|
|
504
|
+
label="Delete Highlight and Annotation"
|
|
505
|
+
></br-menu-option>
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
renderAddAnnotationOption() {
|
|
510
|
+
return html`
|
|
511
|
+
<br-menu-option
|
|
512
|
+
@click=${this.handleAddAnnotation}
|
|
513
|
+
icon="edit-pencil"
|
|
514
|
+
label="Annotate"
|
|
515
|
+
></br-menu-option>
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
renderHighlightOption() {
|
|
520
|
+
return html`
|
|
521
|
+
<br-menu-option
|
|
522
|
+
@click=${this.handleHighlightSave}
|
|
523
|
+
icon="edit-pencil"
|
|
524
|
+
label="Highlight Selection"
|
|
525
|
+
></br-menu-option>
|
|
526
|
+
`;
|
|
527
|
+
}
|
|
528
|
+
renderLocalStorageOptions() {
|
|
529
|
+
return html`
|
|
530
|
+
<br-menu-option
|
|
531
|
+
@click=${this.renderSavedHighlights}
|
|
532
|
+
icon="share"
|
|
533
|
+
label="Load Highlights"
|
|
534
|
+
></br-menu-option>
|
|
535
|
+
<br-menu-option
|
|
536
|
+
@click=${() => {window.localStorage.removeItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY);}}
|
|
537
|
+
icon="share"
|
|
538
|
+
label="Remove Stored Highlights"
|
|
539
|
+
></br-menu-option>`;
|
|
540
|
+
}
|
|
541
|
+
|
|
432
542
|
render() {
|
|
543
|
+
// TODO change the second button to use a different icon
|
|
433
544
|
return html`
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
545
|
+
${this.copyLinkToHighlightEnabled ? this.renderCopyLinkToHighlightOption() : ''}
|
|
546
|
+
${this.highlightAnnotationEnabled && !this.nodesForRemoval ? this.renderHighlightOption() : ''}
|
|
547
|
+
${this.highlightAnnotationEnabled ? this.renderLocalStorageOptions() : ''}
|
|
548
|
+
${this.nodesForRemoval ? this.renderRemoveOption() : ''}
|
|
438
549
|
`;
|
|
439
550
|
}
|
|
440
551
|
|
|
441
552
|
/**
|
|
442
553
|
* @param {MouseEvent} e
|
|
443
554
|
*/
|
|
444
|
-
handleCopyLinkToHighlight(e) {
|
|
555
|
+
async handleCopyLinkToHighlight(e) {
|
|
445
556
|
e.preventDefault();
|
|
446
|
-
|
|
447
557
|
const currentParams = this.br.readQueryString();
|
|
448
558
|
const currentSelection = window.getSelection();
|
|
449
|
-
|
|
450
|
-
const textLayer = currentSelection.anchorNode.parentElement.closest('.BRtextLayer');
|
|
451
|
-
const textFragmentUrlParam = createTextFragmentUrlParam(currentSelection, Array.from(document.querySelectorAll('.BRpage-visible')));
|
|
559
|
+
const textFragment = BookReaderTextFragment.fromSelection(currentSelection, this.br.$('.BRpage-visible').toArray());
|
|
452
560
|
|
|
453
561
|
// Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
|
|
454
562
|
let linkToHighlightParams = currentParams;
|
|
455
563
|
if (currentParams.includes('text=')) {
|
|
456
|
-
linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/,
|
|
564
|
+
linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, `text=${textFragment.toUrlString()}`);
|
|
457
565
|
} else {
|
|
458
566
|
const sep = linkToHighlightParams ? '&' : '?';
|
|
459
|
-
linkToHighlightParams += `${sep}
|
|
567
|
+
linkToHighlightParams += `${sep}text=${textFragment.toUrlString()}`;
|
|
460
568
|
}
|
|
461
|
-
|
|
462
569
|
const currentUrl = window.location;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const
|
|
570
|
+
const pageNum = textFragment.pageNumber || `n${textFragment.pageIndex}`;
|
|
571
|
+
const adjustedUrlPageNumPath = currentUrl.pathname.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`);
|
|
572
|
+
const hash = currentUrl.hash ? currentUrl.hash.replace(/((?:^|[#/])page)\/[^/]+/, `$1/${pageNum}`) : '';
|
|
573
|
+
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${hash}`;
|
|
574
|
+
|
|
575
|
+
await navigator.clipboard.writeText(linkToHighlight);
|
|
576
|
+
this.copyLinkOption?.showTemporaryText('Copied!');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Returns the closest BRtextLayer element on the page that contains the target node
|
|
581
|
+
* @param {Node} node
|
|
582
|
+
* @returns {HTMLElement | null}
|
|
583
|
+
*/
|
|
584
|
+
getNodeTextLayer(node) {
|
|
585
|
+
if (!node) return;
|
|
586
|
+
const element = 'closest' in node ? node : node.parentElement;
|
|
587
|
+
return element?.closest('.BRtextLayer') ?? null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Retrieves the current selected text on the page and serializes the quote contents + context
|
|
592
|
+
* The selection is also changed in the DOM to highlight the words
|
|
593
|
+
*/
|
|
594
|
+
handleHighlightSave() {
|
|
595
|
+
const currentSelection = window.getSelection();
|
|
596
|
+
const start = currentSelection.direction === 'backward' ? currentSelection.focusNode.parentElement : currentSelection.anchorNode.parentElement;
|
|
597
|
+
const textLayer = this.getNodeTextLayer(start);
|
|
598
|
+
const highlight = BookReaderTextFragment.fromSelection(currentSelection, [textLayer.parentElement]);
|
|
599
|
+
highlight.uuid = `id-${crypto.randomUUID().split("-")[4]}`;
|
|
600
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
601
|
+
highlights.push(highlight);
|
|
602
|
+
this.saveToLocalStorage(highlights);
|
|
603
|
+
this.renderSavedHighlights();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
handleAddAnnotation(e) {
|
|
607
|
+
// TODO
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
handleDeleteHighlight() {
|
|
611
|
+
if (this.nodesForRemoval) {
|
|
612
|
+
const uuid = retrieveUUID(this.nodesForRemoval[0]);
|
|
613
|
+
for (const ele of this.nodesForRemoval) {
|
|
614
|
+
const tempText = ele.textContent;
|
|
615
|
+
const parent = ele.parentElement;
|
|
616
|
+
if (parent.classList.contains('BRwordElement') || parent.classList.contains('BRspace')) {
|
|
617
|
+
ele.remove();
|
|
618
|
+
parent.textContent = tempText;
|
|
619
|
+
} else {
|
|
620
|
+
console.log("This element did not match removal criteria:", parent, ele);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
this.deleteHighlight(uuid);
|
|
624
|
+
this.clearNodesForRemoval();
|
|
625
|
+
} else {
|
|
626
|
+
console.log("there is nothing to remove");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* @param {string} uuid
|
|
632
|
+
*/
|
|
633
|
+
deleteHighlight(uuid) {
|
|
634
|
+
const highlights = this.loadHighlightsFromLocalStorage();
|
|
635
|
+
for (let idx = 0; idx < highlights.length; idx++) {
|
|
636
|
+
if (highlights[idx].uuid === uuid) {
|
|
637
|
+
highlights.splice(idx, 1);
|
|
638
|
+
this.saveToLocalStorage(highlights);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Saves all the highlights in an array to localStorage
|
|
646
|
+
* @param {BookReaderSavedHighlight[]} highlights
|
|
647
|
+
*/
|
|
648
|
+
saveToLocalStorage(highlights) {
|
|
649
|
+
window.localStorage.setItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY, JSON.stringify(highlights.map(hl => hl.toJSON())));
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* @returns {BookReaderSavedHighlight[]}
|
|
654
|
+
*/
|
|
655
|
+
loadHighlightsFromLocalStorage() {
|
|
656
|
+
return JSON.parse(window.localStorage.getItem(BR_HIGHLIGHTS_LOCAL_STORAGE_KEY) || "[]")
|
|
657
|
+
.map(item => BookReaderTextFragment.fromJSON(item));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
renderSavedHighlights() {
|
|
661
|
+
for (const hl of this.loadHighlightsFromLocalStorage()) {
|
|
662
|
+
const textLayer = /** @type {HTMLElement} */ (this.br.$(`.pagediv${hl.pageIndex} .BRtextLayer`)[0]);
|
|
663
|
+
if (!textLayer) continue;
|
|
664
|
+
renderHighlight(textLayer, hl);
|
|
665
|
+
// Attach click behaviour here? Only need one handler per text layer
|
|
666
|
+
$(textLayer)
|
|
667
|
+
.off('mouseup.BRHighlightClick')
|
|
668
|
+
.on('mouseup.BRHighlightClick', (e) => {
|
|
669
|
+
if (!e.target.classList.contains("BRhighlight")) return;
|
|
670
|
+
e.stopPropagation();
|
|
671
|
+
this.handleHighlightClick(e.target);
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {HTMLElement} target
|
|
678
|
+
*/
|
|
679
|
+
handleHighlightClick(target) {
|
|
680
|
+
const textLayer = this.getNodeTextLayer(target);
|
|
681
|
+
const identifier = retrieveUUID(target);
|
|
682
|
+
const selectedQuoteNodes = textLayer.querySelectorAll(`.${identifier}`);
|
|
683
|
+
|
|
684
|
+
const firstNode = selectedQuoteNodes[0];
|
|
685
|
+
const lastNode = selectedQuoteNodes[selectedQuoteNodes.length - 1];
|
|
466
686
|
|
|
467
|
-
const
|
|
468
|
-
|
|
687
|
+
const highlightRange = document.createRange();
|
|
688
|
+
highlightRange.setStart(firstNode, 0);
|
|
689
|
+
highlightRange.setEnd(lastNode, 1);
|
|
690
|
+
|
|
691
|
+
const currentSelection = window.getSelection();
|
|
692
|
+
currentSelection.removeAllRanges();
|
|
693
|
+
currentSelection.addRange(highlightRange);
|
|
694
|
+
this.show();
|
|
469
695
|
}
|
|
470
696
|
|
|
471
|
-
|
|
697
|
+
show() {
|
|
472
698
|
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
473
699
|
const currentSelection = window.getSelection();
|
|
474
700
|
const start = currentSelection.anchorNode.parentElement;
|
|
@@ -488,12 +714,22 @@ class BRSelectMenu extends LitElement {
|
|
|
488
714
|
this.style.zIndex = '1';
|
|
489
715
|
this.style.position = 'absolute';
|
|
490
716
|
this.style.display = 'block';
|
|
717
|
+
this.clearNodesForRemoval();
|
|
491
718
|
}
|
|
492
719
|
|
|
493
|
-
|
|
720
|
+
hide = () => {
|
|
494
721
|
this.style.display = 'none';
|
|
722
|
+
this.clearNodesForRemoval();
|
|
495
723
|
return;
|
|
496
724
|
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Remove temporary storage for the currently selected highlight and updates selection menu options
|
|
728
|
+
*/
|
|
729
|
+
clearNodesForRemoval = () => {
|
|
730
|
+
this.nodesForRemoval = null;
|
|
731
|
+
this.requestUpdate();
|
|
732
|
+
}
|
|
497
733
|
}
|
|
498
734
|
|
|
499
735
|
/**
|
|
@@ -521,12 +757,473 @@ export function getLastWords(numWords, text) {
|
|
|
521
757
|
}
|
|
522
758
|
|
|
523
759
|
/**
|
|
524
|
-
* @param {
|
|
760
|
+
* @param {Node} parent
|
|
525
761
|
* @returns {Node}
|
|
526
762
|
*/
|
|
527
|
-
export function
|
|
528
|
-
while (parent.
|
|
529
|
-
parent = parent.
|
|
763
|
+
export function getFirstMostNode(parent) {
|
|
764
|
+
while (parent.firstChild) {
|
|
765
|
+
parent = parent.firstChild;
|
|
530
766
|
}
|
|
531
767
|
return parent;
|
|
532
768
|
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* @param {Node} parent
|
|
772
|
+
* @returns {Node}
|
|
773
|
+
*/
|
|
774
|
+
export function getLastMostNode(parent) {
|
|
775
|
+
while (parent.lastChild) {
|
|
776
|
+
parent = parent.lastChild;
|
|
777
|
+
}
|
|
778
|
+
return parent;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Strips the whitespace to normalize text
|
|
783
|
+
* @param {String} string
|
|
784
|
+
* @returns
|
|
785
|
+
*/
|
|
786
|
+
function replaceWhitespace(string) {
|
|
787
|
+
return string.replace(/\s+/g, " ");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Checks if quote matches the text content and existing range, then identifies the start and end nodes that contain the quote string.
|
|
792
|
+
* @param {String} quote - The text to find
|
|
793
|
+
* @param {Range} range - the range to search in
|
|
794
|
+
* @param {Node[]} textNodes - visible text nodes within the range
|
|
795
|
+
* @returns {Range | undefined} Range that encompasses the quote, or undefined if no match is found
|
|
796
|
+
*
|
|
797
|
+
* Lightly adapted from
|
|
798
|
+
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L765
|
|
799
|
+
*/
|
|
800
|
+
export function findRangeForQuote(quote, range, textNodes) {
|
|
801
|
+
const startOffset = textNodes[0] === range.startContainer ?
|
|
802
|
+
range.startOffset :
|
|
803
|
+
0;
|
|
804
|
+
const normalizedWholePageString = replaceWhitespace(range.toString());
|
|
805
|
+
const normalizedQuote = replaceWhitespace(quote);
|
|
806
|
+
let searchStart = 0;
|
|
807
|
+
let start;
|
|
808
|
+
let end;
|
|
809
|
+
while (searchStart < normalizedWholePageString.length) {
|
|
810
|
+
const matchedIndex = normalizedWholePageString.indexOf(normalizedQuote, searchStart);
|
|
811
|
+
if (matchedIndex === -1) return undefined;
|
|
812
|
+
const normalizedStartOffset = replaceWhitespace(textNodes[0].textContent.slice(0, startOffset)).length;
|
|
813
|
+
start = getBoundaryPointAtIndex(
|
|
814
|
+
normalizedStartOffset + matchedIndex,
|
|
815
|
+
textNodes, false);
|
|
816
|
+
end = getBoundaryPointAtIndex(
|
|
817
|
+
normalizedStartOffset + matchedIndex + normalizedQuote.length,
|
|
818
|
+
textNodes, true);
|
|
819
|
+
if (start != null && end != null) {
|
|
820
|
+
const foundRange = new Range();
|
|
821
|
+
foundRange.setStart(start.node, 0);
|
|
822
|
+
foundRange.setEnd(end.node, 1);
|
|
823
|
+
return foundRange;
|
|
824
|
+
}
|
|
825
|
+
searchStart = matchedIndex + 1;
|
|
826
|
+
}
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Uses the index that matches the quote string and normalizes the string contents to find the correct node
|
|
833
|
+
* @param {Number} index
|
|
834
|
+
* @param {Node[]} nodes
|
|
835
|
+
* @param {boolean} isEnd
|
|
836
|
+
*/
|
|
837
|
+
export function getBoundaryPointAtIndex(index, nodes, isEnd) {
|
|
838
|
+
let counted = 0;
|
|
839
|
+
let normalizedData;
|
|
840
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
841
|
+
const node = nodes[i];
|
|
842
|
+
if (node.className === 'BRlineElement') {
|
|
843
|
+
// Treat the lineElement as a space for now, will check if the previous node was hyphenated or another lineElement later
|
|
844
|
+
normalizedData = ' ';
|
|
845
|
+
} else {
|
|
846
|
+
if (!normalizedData) normalizedData = replaceWhitespace(node.textContent);
|
|
847
|
+
}
|
|
848
|
+
let nodeEnd = counted + normalizedData.length;
|
|
849
|
+
if (isEnd) nodeEnd += 1;
|
|
850
|
+
if (nodeEnd > index) {
|
|
851
|
+
const normalizedOffset = index - counted;
|
|
852
|
+
let denormalizedOffset = Math.min(index - counted, node.textContent.length);
|
|
853
|
+
|
|
854
|
+
const targetSubstring = isEnd ?
|
|
855
|
+
normalizedData.substring(0, normalizedOffset) :
|
|
856
|
+
normalizedData.substring(normalizedOffset);
|
|
857
|
+
|
|
858
|
+
let candidateSubstring = isEnd ?
|
|
859
|
+
replaceWhitespace(node.textContent.substring(0, normalizedOffset)) :
|
|
860
|
+
replaceWhitespace(node.textContent.substring(normalizedOffset));
|
|
861
|
+
|
|
862
|
+
const direction = (isEnd ? -1 : 1) * (targetSubstring.length > candidateSubstring.length ? -1 : 1);
|
|
863
|
+
while (denormalizedOffset >= 0 &&
|
|
864
|
+
denormalizedOffset <= node.textContent.length) {
|
|
865
|
+
if (candidateSubstring.length === targetSubstring.length) {
|
|
866
|
+
return {node : node, offset: denormalizedOffset};
|
|
867
|
+
}
|
|
868
|
+
denormalizedOffset += direction;
|
|
869
|
+
|
|
870
|
+
candidateSubstring = isEnd ?
|
|
871
|
+
node.textContent.substring(0, denormalizedOffset) :
|
|
872
|
+
node.textContent.substring(denormalizedOffset);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
counted += normalizedData.length;
|
|
876
|
+
|
|
877
|
+
if (i + 1 < nodes.length) {
|
|
878
|
+
const nextNormalizedData = replaceWhitespace(nodes[i + 1].textContent);
|
|
879
|
+
// Hyphenated words prove to be an issue since spaces are being inserted between BRlineElements
|
|
880
|
+
// 1st case explicitly check the node class to prevent double counted spaces
|
|
881
|
+
// 2nd case can happen from node traversal when loading from localStorage
|
|
882
|
+
if (nodes[i - 1]?.classList.contains("BRwordElement--hyphen") && node.className === 'BRlineElement') {
|
|
883
|
+
counted -= 1;
|
|
884
|
+
} else if (nodes[i - 1]?.className === 'BRlineElement' && node.className === 'BRlineElement') {
|
|
885
|
+
counted -= 1;
|
|
886
|
+
}
|
|
887
|
+
normalizedData = nextNormalizedData;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Takes a text quote object and a container element, and wraps the quote
|
|
895
|
+
* within the container element in a span to apply highlight-like styling
|
|
896
|
+
*
|
|
897
|
+
* Iterate through the range and determine the "start" and "end" nodes
|
|
898
|
+
* Example of how this is done via polyfill
|
|
899
|
+
* https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/main/src/text-fragment-utils.js#L743
|
|
900
|
+
*
|
|
901
|
+
* @param {HTMLElement} textLayer
|
|
902
|
+
* @param {BookReaderTextFragment} quote
|
|
903
|
+
* @param {string | null} cssClassName optional css class to add to the highlight span element
|
|
904
|
+
*/
|
|
905
|
+
export function renderHighlight(textLayer, quote, cssClassName = null) {
|
|
906
|
+
// Create a range that encompasses the entire text content
|
|
907
|
+
const firstPageNode = getFirstMostNode(textLayer);
|
|
908
|
+
const lastPageNode = getLastMostNode(textLayer);
|
|
909
|
+
const wholePageRange = new Range();
|
|
910
|
+
wholePageRange.setStart(firstPageNode, 0);
|
|
911
|
+
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);
|
|
923
|
+
|
|
924
|
+
// Retrieve the text nodes and relevant whitespace elements
|
|
925
|
+
// 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
|
+
const pageWordNodes = Array.from(textLayer.querySelectorAll('.BRwordElement, .BRspace, br, .BRlineElement'));
|
|
927
|
+
pageWordNodes.splice(0, 1);
|
|
928
|
+
const broadRange = findRangeForQuote(convertedFullContext, wholePageRange, pageWordNodes);
|
|
929
|
+
if (!broadRange) {
|
|
930
|
+
console.warn("Could not find quote with context in page, falling back to finding quote without context:", quote.quote);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const broadRangeWordNodes = [];
|
|
935
|
+
for (const el of walkBetweenNodes(broadRange?.startContainer, broadRange?.endContainer)) {
|
|
936
|
+
if (el.classList?.contains('BRwordElement') || el.classList?.contains('BRspace') || el.classList?.contains('BRlineElement')) {
|
|
937
|
+
broadRangeWordNodes.push(el);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// At which point the quote should now be unambiguous!
|
|
942
|
+
const exactRange = findRangeForQuote(quote.quote, broadRange, broadRangeWordNodes);
|
|
943
|
+
if (!exactRange) {
|
|
944
|
+
throw new Error("Could not find quote in page");
|
|
945
|
+
}
|
|
946
|
+
const startTextNode = getFirstMostNode(exactRange.startContainer);
|
|
947
|
+
const endTextNode = getFirstMostNode(exactRange.endContainer);
|
|
948
|
+
|
|
949
|
+
// markRange requires the range to start and end within text nodes
|
|
950
|
+
const exactRangeTextNodes = new Range();
|
|
951
|
+
exactRangeTextNodes.setStart(startTextNode, 0);
|
|
952
|
+
exactRangeTextNodes.setEnd(endTextNode, endTextNode.textContent.length);
|
|
953
|
+
|
|
954
|
+
// Don't mark if already marked
|
|
955
|
+
if (startTextNode.parentElement.classList.contains("BRhighlight") ||
|
|
956
|
+
endTextNode.parentElement.classList.contains("BRhighlight")) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
markRange(exactRangeTextNodes, () => {
|
|
961
|
+
const mark = document.createElement("mark");
|
|
962
|
+
mark.classList.add("BRhighlight");
|
|
963
|
+
if (cssClassName) mark.classList.add(cssClassName);
|
|
964
|
+
if (quote.uuid) mark.classList.add(quote.uuid);
|
|
965
|
+
return mark;
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Given a Range, wraps its text contents in one or more <mark> elements.
|
|
971
|
+
* <mark> elements can't cross block boundaries, so this function walks the
|
|
972
|
+
* tree to find all the relevant text nodes and wraps them.
|
|
973
|
+
* @param {Range} range - the range to mark. Must start and end inside of
|
|
974
|
+
* text nodes.
|
|
975
|
+
* @param {function(): Element} createWrappingElement - a function that creates
|
|
976
|
+
* the element to wrap text nodes in. This is called once per text node.
|
|
977
|
+
* @return {Element[]} The <mark> nodes that were created.
|
|
978
|
+
*
|
|
979
|
+
* Lightly adapted from https://github.com/GoogleChromeLabs/text-fragments-polyfill/blob/abc6ed408b3f20e91d9cbda9977748459f5e3877/src/text-fragment-utils.js#L456
|
|
980
|
+
*/
|
|
981
|
+
function markRange(
|
|
982
|
+
range,
|
|
983
|
+
createWrappingElement = () => document.createElement("mark"),
|
|
984
|
+
) {
|
|
985
|
+
if (range.startContainer.nodeType != Node.TEXT_NODE ||
|
|
986
|
+
range.endContainer.nodeType != Node.TEXT_NODE)
|
|
987
|
+
return [];
|
|
988
|
+
|
|
989
|
+
// If the range is entirely within a single node, just surround it.
|
|
990
|
+
if (range.startContainer === range.endContainer) {
|
|
991
|
+
const trivialMark = createWrappingElement();
|
|
992
|
+
range.surroundContents(trivialMark);
|
|
993
|
+
return [trivialMark];
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Start node -- special case
|
|
997
|
+
const startNode = range.startContainer;
|
|
998
|
+
const startNodeSubrange = range.cloneRange();
|
|
999
|
+
startNodeSubrange.setEndAfter(startNode);
|
|
1000
|
+
|
|
1001
|
+
// End node -- special case
|
|
1002
|
+
const endNode = range.endContainer;
|
|
1003
|
+
const endNodeSubrange = range.cloneRange();
|
|
1004
|
+
endNodeSubrange.setStartBefore(endNode);
|
|
1005
|
+
|
|
1006
|
+
// In between nodes
|
|
1007
|
+
const marks = [];
|
|
1008
|
+
range.setStartAfter(startNode);
|
|
1009
|
+
range.setEndBefore(endNode);
|
|
1010
|
+
const walker = document.createTreeWalker(
|
|
1011
|
+
range.commonAncestorContainer,
|
|
1012
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
|
1013
|
+
{
|
|
1014
|
+
acceptNode: function(node) {
|
|
1015
|
+
if (!range.intersectsNode(node)) return NodeFilter.FILTER_REJECT;
|
|
1016
|
+
|
|
1017
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
1018
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
1019
|
+
return NodeFilter.FILTER_SKIP;
|
|
1020
|
+
},
|
|
1021
|
+
},
|
|
1022
|
+
);
|
|
1023
|
+
let node = walker.nextNode();
|
|
1024
|
+
while (node) {
|
|
1025
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
1026
|
+
const mark = createWrappingElement();
|
|
1027
|
+
node.parentNode.insertBefore(mark, node);
|
|
1028
|
+
mark.appendChild(node);
|
|
1029
|
+
marks.push(mark);
|
|
1030
|
+
}
|
|
1031
|
+
node = walker.nextNode();
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const startMark = createWrappingElement();
|
|
1035
|
+
startNodeSubrange.surroundContents(startMark);
|
|
1036
|
+
const endMark = createWrappingElement();
|
|
1037
|
+
endNodeSubrange.surroundContents(endMark);
|
|
1038
|
+
|
|
1039
|
+
return [startMark, ...marks, endMark];
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Get UUID assigned to the highlight element from class list
|
|
1044
|
+
* @param {HTMLElement} ele
|
|
1045
|
+
* @returns
|
|
1046
|
+
*/
|
|
1047
|
+
function retrieveUUID(ele) {
|
|
1048
|
+
if (!ele) return null;
|
|
1049
|
+
const findUUID = Array.from(ele?.classList).filter((name) => {
|
|
1050
|
+
if (name.slice(0, 2).includes('id')) {
|
|
1051
|
+
return name;
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
if (findUUID.length) {
|
|
1055
|
+
return findUUID[0];
|
|
1056
|
+
}
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* An extension of the fields defined by the browser-native TextFragment;
|
|
1062
|
+
* See https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Fragment/Text_fragments
|
|
1063
|
+
*
|
|
1064
|
+
* Text fragment string format: `pageNumber:prefix-,quote,-suffix`
|
|
1065
|
+
* Note the ':' and ',' separators must not be encoded, but
|
|
1066
|
+
* the pageNumber, prefix, quote, and suffix text can be encoded.
|
|
1067
|
+
*/
|
|
1068
|
+
export class BookReaderTextFragment {
|
|
1069
|
+
/**
|
|
1070
|
+
* @param {object} params
|
|
1071
|
+
* @param {string | null}params.prefix
|
|
1072
|
+
* @param {string} params.quote
|
|
1073
|
+
* @param {string | null} params.suffix
|
|
1074
|
+
* @param {string | null} params.pageNumber Page number; e.g. asserted page number or the n-prefixed page index
|
|
1075
|
+
* @param {number} params.pageIndex Page index; e.g. zero-based index of the page
|
|
1076
|
+
* @param {string | null} [params.uuid] UUID for the text fragment if it has one
|
|
1077
|
+
*/
|
|
1078
|
+
constructor({ prefix, quote, suffix, pageNumber, pageIndex, uuid }) {
|
|
1079
|
+
/** @type {string|null} */
|
|
1080
|
+
this.prefix = prefix;
|
|
1081
|
+
/** @type {string} */
|
|
1082
|
+
this.quote = quote;
|
|
1083
|
+
/** @type {string|null} */
|
|
1084
|
+
this.suffix = suffix;
|
|
1085
|
+
/** @type {string|null} Page number; e.g. asserted page number or the n-prefixed page index */
|
|
1086
|
+
this.pageNumber = pageNumber;
|
|
1087
|
+
/** @type {number} Page index; e.g. zero-based index of the page */
|
|
1088
|
+
this.pageIndex = pageIndex;
|
|
1089
|
+
/** @type {string | null} UUID for the text fragment if it has one */
|
|
1090
|
+
this.uuid = uuid ?? null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Parse a text fragment from its string value (the part after `text=`).
|
|
1095
|
+
* Expected format: `pageNumber:prefix-,quote,-suffix`
|
|
1096
|
+
* @param {string} str
|
|
1097
|
+
* @param {import('@/src/BookReader/BookModel.js').BookModel} book
|
|
1098
|
+
* @param {number} fallbackPageIndex A fallback page index to use if the text
|
|
1099
|
+
* fragment does not specify a page number or page index.
|
|
1100
|
+
* @returns {BookReaderTextFragment}
|
|
1101
|
+
*/
|
|
1102
|
+
static fromString(str, book, fallbackPageIndex) {
|
|
1103
|
+
// Basically matches:
|
|
1104
|
+
// (stuff):(stuff)-,(stuff),-(stuff)
|
|
1105
|
+
const match = str.match(/^(?:(.*?):)?(?:(.*?)-,)?(.*?)(?:,-(.*))?$/);
|
|
1106
|
+
if (!match) {
|
|
1107
|
+
throw new Error(`Invalid text fragment format: ${str}`);
|
|
1108
|
+
}
|
|
1109
|
+
const pageNumber = match[1] ? decodeURIComponent(match[1]) : null;
|
|
1110
|
+
const prefix = match[2] ? decodeURIComponent(match[2]) : null;
|
|
1111
|
+
const quote = decodeURIComponent(match[3]);
|
|
1112
|
+
const suffix = match[4] ? decodeURIComponent(match[4]) : null;
|
|
1113
|
+
const pageIndex = pageNumber ? book.getPageIndex(pageNumber) : fallbackPageIndex;
|
|
1114
|
+
if (typeof pageIndex !== 'number') {
|
|
1115
|
+
throw new Error(`Could not determine page index for text fragment with page number ${pageNumber}`);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
return new BookReaderTextFragment({ prefix, quote, suffix, pageNumber, pageIndex });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Extract and parse a text fragment from a URL string containing a `text=` parameter.
|
|
1123
|
+
* @param {string} urlString
|
|
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|null}
|
|
1128
|
+
*/
|
|
1129
|
+
static fromUrl(urlString, book, fallbackPageIndex) {
|
|
1130
|
+
// Can't parse with eg new URLSearchParams since the text fragment format includes unencoded
|
|
1131
|
+
// commas and colons, so need to do a regex match to extract the text fragment string
|
|
1132
|
+
const textMatch = urlString.match(/[&?#]?text=([^&]*)/);
|
|
1133
|
+
if (!textMatch) return null;
|
|
1134
|
+
return BookReaderTextFragment.fromString(textMatch[1], book, fallbackPageIndex);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Outputs a url-safe string serialization of the text fragment, that's a variation of the standard
|
|
1139
|
+
* browser TextFragment format to include page information: `pageNumber:prefix-,quote,-suffix`
|
|
1140
|
+
* Note the ':' and ',' separators must not and are not encoded, but
|
|
1141
|
+
* the pageNumber, prefix, quote, and suffix text are encoded.
|
|
1142
|
+
* @returns {string}
|
|
1143
|
+
*/
|
|
1144
|
+
toUrlString() {
|
|
1145
|
+
// First the page number or index
|
|
1146
|
+
let str = this.pageNumber ? `${encodeURIComponent(this.pageNumber)}:` : `n${this.pageIndex}:`;
|
|
1147
|
+
|
|
1148
|
+
if (this.prefix) {
|
|
1149
|
+
str += `${encodeURIComponent(this.prefix)}-,`;
|
|
1150
|
+
}
|
|
1151
|
+
str += encodeURIComponent(this.quote);
|
|
1152
|
+
if (this.suffix) {
|
|
1153
|
+
str += `,-${encodeURIComponent(this.suffix)}`;
|
|
1154
|
+
}
|
|
1155
|
+
return str;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
toJSON() {
|
|
1159
|
+
return {
|
|
1160
|
+
prefix: this.prefix,
|
|
1161
|
+
quote: this.quote,
|
|
1162
|
+
suffix: this.suffix,
|
|
1163
|
+
pageNumber: this.pageNumber,
|
|
1164
|
+
pageIndex: this.pageIndex,
|
|
1165
|
+
uuid: this.uuid,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Create a BookReaderTextFragment instance from a JSON object.
|
|
1171
|
+
* @param {object} json
|
|
1172
|
+
* @returns {BookReaderTextFragment}
|
|
1173
|
+
*/
|
|
1174
|
+
static fromJSON(json) {
|
|
1175
|
+
return new BookReaderTextFragment(json);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Builds a TextFragment string from a given text selection.
|
|
1180
|
+
* @param {Selection} selection currently selected text, eg `document.getSelection()`
|
|
1181
|
+
* @param {HTMLElement[]} contextElements elements providing context for the selection
|
|
1182
|
+
* @returns {BookReaderTextFragment}
|
|
1183
|
+
*/
|
|
1184
|
+
static fromSelection(selection, contextElements) {
|
|
1185
|
+
const range = selection.getRangeAt(0);
|
|
1186
|
+
|
|
1187
|
+
// Partially selected words need to be captured completely
|
|
1188
|
+
const fullQuoteRange = new Range();
|
|
1189
|
+
const startTextNode = getLastMostNode(range.startContainer);
|
|
1190
|
+
const endTextNode = getFirstMostNode(range.endContainer);
|
|
1191
|
+
fullQuoteRange.setStart(startTextNode, 0);
|
|
1192
|
+
fullQuoteRange.setEnd(endTextNode, range.endOffset == 0 ? 0 : (endTextNode.textContent?.length ?? 0));
|
|
1193
|
+
|
|
1194
|
+
const preStartRange = document.createRange();
|
|
1195
|
+
preStartRange.setStart(getFirstMostNode(contextElements[0]), 0);
|
|
1196
|
+
preStartRange.setEnd(startTextNode, 0);
|
|
1197
|
+
|
|
1198
|
+
const postEndRange = document.createRange();
|
|
1199
|
+
postEndRange.setStart(endTextNode, fullQuoteRange.endOffset);
|
|
1200
|
+
const lastWordOfPageEl = getLastMostNode(contextElements[contextElements.length - 1]);
|
|
1201
|
+
postEndRange.setEnd(lastWordOfPageEl, Math.max(0, lastWordOfPageEl.textContent.length - 1));
|
|
1202
|
+
|
|
1203
|
+
// prefixes/suffixes cannot contain paragraph breaks, words that are from more than one line break away should not be included
|
|
1204
|
+
const prefix = getLastWords(3, preStartRange.toString())
|
|
1205
|
+
.replace(/[ ]+/g, " ")
|
|
1206
|
+
.trim()
|
|
1207
|
+
.replace(/^[^\n]*\n/gm, "");
|
|
1208
|
+
const suffix = getFirstWords(3, postEndRange.toString())
|
|
1209
|
+
.replace(/[ ]+/g, " ")
|
|
1210
|
+
.trim()
|
|
1211
|
+
.replace(/\n[^\n]*$/gm, "");
|
|
1212
|
+
|
|
1213
|
+
// 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().replace(/\s+/g, " ").trim();
|
|
1215
|
+
const pageContainerEl = startTextNode.parentElement.closest(".BRpagecontainer");
|
|
1216
|
+
|
|
1217
|
+
return new BookReaderTextFragment({
|
|
1218
|
+
quote,
|
|
1219
|
+
prefix,
|
|
1220
|
+
suffix,
|
|
1221
|
+
pageNumber: pageContainerEl.getAttribute("data-page-num"),
|
|
1222
|
+
pageIndex: parseFloat(pageContainerEl.getAttribute("data-index")),
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* @typedef {BookReaderTextFragment & { uuid: string }} BookReaderSavedHighlight
|
|
1229
|
+
*/
|