@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.
@@ -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
+ }