@internetarchive/bookreader 5.0.0-110 → 5.0.0-111
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 +59 -0
- package/BookReader/BookReader.js +1 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/ia-bookreader-bundle.js +74 -77
- package/BookReader/ia-bookreader-bundle.js.map +1 -1
- package/BookReader/plugins/plugin.chapters.js +1 -1
- package/BookReader/plugins/plugin.chapters.js.map +1 -1
- package/BookReader/plugins/plugin.experiments.js +1 -1
- package/BookReader/plugins/plugin.experiments.js.map +1 -1
- package/BookReader/plugins/plugin.text_selection.js +23 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +25 -3
- package/BookReader/plugins/plugin.translate.js.map +1 -1
- package/BookReader/plugins/plugin.url.js +2 -1
- package/BookReader/plugins/plugin.url.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.url.js.map +1 -1
- package/package.json +2 -1
- package/src/BookReader/Mode2UpLit.js +1 -3
- package/src/BookReader/utils/SelectionObserver.js +9 -1
- package/src/BookReader.js +17 -2
- package/src/css/_TextSelection.scss +59 -0
- package/src/plugins/plugin.chapters.js +8 -3
- package/src/plugins/plugin.experiments.js +21 -3
- package/src/plugins/plugin.text_selection.js +17 -0
- package/src/plugins/url/UrlPlugin.js +23 -3
- package/src/plugins/url/plugin.url.js +63 -7
- package/src/util/TextSelectionManager.js +252 -2
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { SelectionObserver } from "../BookReader/utils/SelectionObserver.js";
|
|
3
|
+
import { html, LitElement } from 'lit';
|
|
4
|
+
import { customElement } from 'lit/decorators.js';
|
|
5
|
+
import '@internetarchive/icon-share';
|
|
3
6
|
|
|
4
7
|
export class TextSelectionManager {
|
|
5
8
|
options = {
|
|
@@ -7,6 +10,11 @@ export class TextSelectionManager {
|
|
|
7
10
|
maxProtectedWords: 200,
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
/** @type {BRSelectMenu} */
|
|
14
|
+
selectMenu;
|
|
15
|
+
/** @type {boolean} */
|
|
16
|
+
selectionMenuEnabled = false;
|
|
17
|
+
|
|
10
18
|
/**
|
|
11
19
|
* @param {string} layer Selector for the text layer to manage
|
|
12
20
|
* @param {import('../BookReader.js').default} br
|
|
@@ -23,6 +31,9 @@ export class TextSelectionManager {
|
|
|
23
31
|
this.selectionElement = selectionElement;
|
|
24
32
|
this.selectionObserver = new SelectionObserver(this.layer, this._onSelectionChange);
|
|
25
33
|
this.options.maxProtectedWords = maxWords ? maxWords : 200;
|
|
34
|
+
|
|
35
|
+
this.selectMenu = new BRSelectMenu(br);
|
|
36
|
+
this.selectMenu.className = "br-select-menu__root";
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
init() {
|
|
@@ -35,6 +46,21 @@ export class TextSelectionManager {
|
|
|
35
46
|
// Set a class on the page to avoid hiding it when zooming/etc
|
|
36
47
|
this.br.refs.$br.find('.BRpagecontainer--hasSelection').removeClass('BRpagecontainer--hasSelection');
|
|
37
48
|
$(window.getSelection().anchorNode).closest('.BRpagecontainer').addClass('BRpagecontainer--hasSelection');
|
|
49
|
+
this.selectMenu.showMenu();
|
|
50
|
+
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (selectEvent == 'focusChanged') {
|
|
54
|
+
// hide the button as user changes their selection
|
|
55
|
+
if (this.mouseIsDown) {
|
|
56
|
+
this.selectMenu.hideMenu();
|
|
57
|
+
} else if (window.getSelection().toString()) {
|
|
58
|
+
this.selectMenu.showMenu();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (selectEvent == 'cleared') {
|
|
63
|
+
this.selectMenu.hideMenu();
|
|
38
64
|
}
|
|
39
65
|
}).attach();
|
|
40
66
|
}
|
|
@@ -42,6 +68,9 @@ export class TextSelectionManager {
|
|
|
42
68
|
// Need attach + detach methods to toggle w/ Translation plugin
|
|
43
69
|
attach() {
|
|
44
70
|
this.selectionObserver.attach();
|
|
71
|
+
if (this.selectionMenuEnabled) {
|
|
72
|
+
this.renderSelectionMenu();
|
|
73
|
+
}
|
|
45
74
|
if (this.br.protected) {
|
|
46
75
|
document.addEventListener('selectionchange', this._limitSelection);
|
|
47
76
|
// Prevent right clicking when selected text
|
|
@@ -67,8 +96,12 @@ export class TextSelectionManager {
|
|
|
67
96
|
}
|
|
68
97
|
}
|
|
69
98
|
|
|
99
|
+
renderSelectionMenu() {
|
|
100
|
+
if (document.querySelector('.br-select-menu__option')) return;
|
|
101
|
+
document.body.append(this.selectMenu);
|
|
102
|
+
}
|
|
70
103
|
/**
|
|
71
|
-
* @param {'started' | 'cleared'} type
|
|
104
|
+
* @param {'started' | 'cleared' | 'focusChanged'} type
|
|
72
105
|
* @param {HTMLElement} target
|
|
73
106
|
*/
|
|
74
107
|
_onSelectionChange = (type, target) => {
|
|
@@ -76,6 +109,8 @@ export class TextSelectionManager {
|
|
|
76
109
|
this.textSelectingMode(target);
|
|
77
110
|
} else if (type === 'cleared') {
|
|
78
111
|
this.defaultMode(target);
|
|
112
|
+
} else if (type === 'focusChanged') {
|
|
113
|
+
// do nothing, just wait for the mouseup to trigger the styling change
|
|
79
114
|
} else {
|
|
80
115
|
throw new Error(`Unknown type ${type}`);
|
|
81
116
|
}
|
|
@@ -127,6 +162,7 @@ export class TextSelectionManager {
|
|
|
127
162
|
// blocking selection
|
|
128
163
|
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
129
164
|
this.mouseIsDown = true;
|
|
165
|
+
this.selectMenu.hideMenu();
|
|
130
166
|
if ($(event.target).is(this.selectionElement.join(", "))) {
|
|
131
167
|
event.stopPropagation();
|
|
132
168
|
}
|
|
@@ -134,6 +170,7 @@ export class TextSelectionManager {
|
|
|
134
170
|
|
|
135
171
|
$(textLayer).on("mouseup.textSelectPluginHandler", (event) => {
|
|
136
172
|
this.mouseIsDown = false;
|
|
173
|
+
this.selectMenu.hideMenu();
|
|
137
174
|
textLayer.style.pointerEvents = "none";
|
|
138
175
|
if (skipNextMouseup) {
|
|
139
176
|
skipNextMouseup = false;
|
|
@@ -156,18 +193,21 @@ export class TextSelectionManager {
|
|
|
156
193
|
$(textLayer).off(".textSelectPluginHandler");
|
|
157
194
|
|
|
158
195
|
$(textLayer).on("mousedown.textSelectPluginHandler", (event) => {
|
|
196
|
+
if (event.which != 1) return;
|
|
159
197
|
this.mouseIsDown = true;
|
|
160
198
|
event.stopPropagation();
|
|
199
|
+
this.selectMenu.hideMenu();
|
|
161
200
|
});
|
|
162
201
|
|
|
163
202
|
// Prevent page flip on click
|
|
164
203
|
$(textLayer).on('mouseup.textSelectPluginHandler', (event) => {
|
|
165
204
|
this.mouseIsDown = false;
|
|
205
|
+
if (event.which != 1) return;
|
|
166
206
|
event.stopPropagation();
|
|
207
|
+
this.selectMenu.showMenu();
|
|
167
208
|
});
|
|
168
209
|
}
|
|
169
210
|
|
|
170
|
-
|
|
171
211
|
_limitSelection = () => {
|
|
172
212
|
const selection = window.getSelection();
|
|
173
213
|
if (!selection.rangeCount) return;
|
|
@@ -206,6 +246,98 @@ export class TextSelectionManager {
|
|
|
206
246
|
};
|
|
207
247
|
}
|
|
208
248
|
|
|
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
|
+
|
|
209
341
|
/**
|
|
210
342
|
* @template T
|
|
211
343
|
* Get the i-th element of an iterable
|
|
@@ -280,3 +412,121 @@ export function* walkBetweenNodes(start, end) {
|
|
|
280
412
|
|
|
281
413
|
yield* walk(start);
|
|
282
414
|
}
|
|
415
|
+
|
|
416
|
+
@customElement('br-select-menu')
|
|
417
|
+
class BRSelectMenu extends LitElement {
|
|
418
|
+
/** @type {import('../BookReader.js').default} */
|
|
419
|
+
br;
|
|
420
|
+
|
|
421
|
+
constructor(br) {
|
|
422
|
+
super();
|
|
423
|
+
this.br = br;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** @override */
|
|
427
|
+
createRenderRoot() {
|
|
428
|
+
// Disable shadow DOM; that would require a huge rejiggering of CSS
|
|
429
|
+
return this;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
render() {
|
|
433
|
+
return html`
|
|
434
|
+
<button @click=${this.handleCopyLinkToHighlight} class="br-select-menu__option">
|
|
435
|
+
<ia-icon-share class="br-select-menu__icon" aria-hidden="true"></ia-icon-share>
|
|
436
|
+
<span class="br-select-menu__label">Copy Link to Highlight</span>
|
|
437
|
+
</button>
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @param {MouseEvent} e
|
|
443
|
+
*/
|
|
444
|
+
handleCopyLinkToHighlight(e) {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
|
|
447
|
+
const currentParams = this.br.readQueryString();
|
|
448
|
+
const currentSelection = window.getSelection();
|
|
449
|
+
/** @type {HTMLElement} */
|
|
450
|
+
const textLayer = currentSelection.anchorNode.parentElement.closest('.BRtextLayer');
|
|
451
|
+
const textFragmentUrlParam = createTextFragmentUrlParam(currentSelection, Array.from(document.querySelectorAll('.BRpage-visible')));
|
|
452
|
+
|
|
453
|
+
// Note: Have to do a param construction to avoid url-encoding of commas in the text fragment param
|
|
454
|
+
let linkToHighlightParams = currentParams;
|
|
455
|
+
if (currentParams.includes('text=')) {
|
|
456
|
+
linkToHighlightParams = currentParams.replace(/(text=)[\w\W\d%]+/, textFragmentUrlParam);
|
|
457
|
+
} else {
|
|
458
|
+
const sep = linkToHighlightParams ? '&' : '?';
|
|
459
|
+
linkToHighlightParams += `${sep}${textFragmentUrlParam}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const currentUrl = window.location;
|
|
463
|
+
// TODO - updateResumeValue + getCookiePath in plugin.resume.js overrides the adjustedUrlPageNumPath, check how to workaround this
|
|
464
|
+
// TODO - won't work with hash mode
|
|
465
|
+
const adjustedUrlPageNumPath = currentUrl.pathname.toString().replace(/(?<=\/page\/)\d+(?=\/)/, textLayer.parentElement.getAttribute('data-page-num'));
|
|
466
|
+
|
|
467
|
+
const linkToHighlight = `${currentUrl.origin}${adjustedUrlPageNumPath}${linkToHighlightParams}${currentUrl?.hash || ''}`;
|
|
468
|
+
navigator.clipboard.writeText(linkToHighlight);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
showMenu() {
|
|
472
|
+
if (this.br.plugins.translate?.userToggleTranslate) return;
|
|
473
|
+
const currentSelection = window.getSelection();
|
|
474
|
+
const start = currentSelection.anchorNode.parentElement;
|
|
475
|
+
const end = currentSelection.focusNode.parentElement; // will always be a text node
|
|
476
|
+
const height = 30;
|
|
477
|
+
const width = 60;
|
|
478
|
+
const startBoundingRect = start.getBoundingClientRect();
|
|
479
|
+
const endBoundingRect = end.getBoundingClientRect();
|
|
480
|
+
let hlButtonTop = startBoundingRect.top - height;
|
|
481
|
+
let hlButtonLeft = startBoundingRect.left - width;
|
|
482
|
+
if (currentSelection.direction == 'backward') {
|
|
483
|
+
hlButtonTop = endBoundingRect.top - height;
|
|
484
|
+
hlButtonLeft = endBoundingRect.left - width;
|
|
485
|
+
}
|
|
486
|
+
this.style.top = `${hlButtonTop}px`;
|
|
487
|
+
this.style.left = `${hlButtonLeft}px`;
|
|
488
|
+
this.style.zIndex = '1';
|
|
489
|
+
this.style.position = 'absolute';
|
|
490
|
+
this.style.display = 'block';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
hideMenu = () => {
|
|
494
|
+
this.style.display = 'none';
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* @param {number} numWords
|
|
501
|
+
* @param {string} text
|
|
502
|
+
* @return {string}
|
|
503
|
+
*/
|
|
504
|
+
export function getFirstWords(numWords, text) {
|
|
505
|
+
text = text.trim();
|
|
506
|
+
const re = new RegExp(String.raw`^(\S+(\s+|$)){1,${numWords}}`);
|
|
507
|
+
const m = text.match(re);
|
|
508
|
+
return m ? m[0].trim() : "";
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* @param {number} numWords
|
|
513
|
+
* @param {string} text
|
|
514
|
+
* @return {string}
|
|
515
|
+
*/
|
|
516
|
+
export function getLastWords(numWords, text) {
|
|
517
|
+
text = text.trim();
|
|
518
|
+
const re = new RegExp(String.raw`((^|\s+)\S+){1,${numWords}}\s*?$`);
|
|
519
|
+
const m = text.match(re);
|
|
520
|
+
return m ? m[0].trim() : "";
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @param {HTMLElement | Element} parent
|
|
525
|
+
* @returns {Node}
|
|
526
|
+
*/
|
|
527
|
+
export function getLastMostElement(parent) {
|
|
528
|
+
while (parent.lastElementChild) {
|
|
529
|
+
parent = parent.lastElementChild;
|
|
530
|
+
}
|
|
531
|
+
return parent;
|
|
532
|
+
}
|