@internetarchive/bookreader 5.0.0-110 → 5.0.0-112

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.
Files changed (32) hide show
  1. package/BookReader/BookReader.css +59 -0
  2. package/BookReader/BookReader.js +1 -1
  3. package/BookReader/BookReader.js.map +1 -1
  4. package/BookReader/ia-bookreader-bundle.js +74 -77
  5. package/BookReader/ia-bookreader-bundle.js.map +1 -1
  6. package/BookReader/plugins/plugin.chapters.js +1 -1
  7. package/BookReader/plugins/plugin.chapters.js.map +1 -1
  8. package/BookReader/plugins/plugin.experiments.js +1 -1
  9. package/BookReader/plugins/plugin.experiments.js.map +1 -1
  10. package/BookReader/plugins/plugin.search.js.map +1 -1
  11. package/BookReader/plugins/plugin.text_selection.js +23 -1
  12. package/BookReader/plugins/plugin.text_selection.js.map +1 -1
  13. package/BookReader/plugins/plugin.translate.js +25 -3
  14. package/BookReader/plugins/plugin.translate.js.map +1 -1
  15. package/BookReader/plugins/plugin.tts.js.map +1 -1
  16. package/BookReader/plugins/plugin.url.js +2 -1
  17. package/BookReader/plugins/plugin.url.js.LICENSE.txt +1 -0
  18. package/BookReader/plugins/plugin.url.js.map +1 -1
  19. package/package.json +2 -1
  20. package/src/BookReader/Mode2UpLit.js +1 -3
  21. package/src/BookReader/Navbar/Navbar.js +9 -10
  22. package/src/BookReader/utils/SelectionObserver.js +9 -1
  23. package/src/BookReader.js +17 -2
  24. package/src/css/_TextSelection.scss +59 -0
  25. package/src/plugins/plugin.chapters.js +8 -3
  26. package/src/plugins/plugin.experiments.js +21 -3
  27. package/src/plugins/plugin.text_selection.js +17 -0
  28. package/src/plugins/translate/plugin.translate.js +68 -22
  29. package/src/plugins/tts/utils.js +1 -1
  30. package/src/plugins/url/UrlPlugin.js +23 -3
  31. package/src/plugins/url/plugin.url.js +63 -7
  32. package/src/util/TextSelectionManager.js +252 -2
@@ -314,7 +314,7 @@ export class Navbar {
314
314
  const $sliders = this.$root.find('.BRpager').slider({
315
315
  animate: true,
316
316
  min: 0,
317
- max: br.book.getNumLeafs() - 1,
317
+ max: Math.max(0, br.book.getNumLeafs() - 1),
318
318
  value: br.currentIndex(),
319
319
  range: "min",
320
320
  });
@@ -324,8 +324,8 @@ export class Navbar {
324
324
  $sliders.find('.ui-slider-handle').attr({
325
325
  'role': 'slider',
326
326
  'aria-label': 'Page navigation slider',
327
- 'aria-valuemin': 1,
328
- 'aria-valuemax': br.book.getNumLeafs(),
327
+ 'aria-valuemin': 0,
328
+ 'aria-valuemax': Math.max(0, br.book.getNumLeafs() - 1),
329
329
  });
330
330
 
331
331
  // Ignore up/down arrow keys and page up/down keys, since they're confusingly different
@@ -396,7 +396,6 @@ export class Navbar {
396
396
  */
397
397
  getNavPageNumString(index) {
398
398
  const { br } = this;
399
- // Accessible index starts at 0 (alas) so we add 1 to make human
400
399
  const pageNum = br.book.getPageNum(index);
401
400
  const pageType = br.book.getPageProp(index, 'pageType');
402
401
  const numLeafs = br.book.getNumLeafs();
@@ -425,7 +424,7 @@ export class Navbar {
425
424
  updateNavPageNum(index) {
426
425
  this.$root.find('.BRcurrentpage').html(this.getNavPageNumString(index));
427
426
  this.$root.find('.ui-slider-handle').attr({
428
- 'aria-valuenow': index + 1,
427
+ 'aria-valuenow': index,
429
428
  'aria-valuetext': this.getNavPageNumString(index),
430
429
  });
431
430
  }
@@ -446,19 +445,19 @@ export class Navbar {
446
445
 
447
446
  /**
448
447
  * Renders the html for the page string
449
- * @param {number} index
450
- * @param {number} numLeafs
448
+ * @param {PageIndex} index 0-indexed page index (0..numLeafs-1)
449
+ * @param {number} numLeafs total number of leaves
451
450
  * @param {number|string} pageNum
452
451
  * @param {*} pageType - Deprecated
453
452
  * @param {number} maxPageNum
454
- * @return {string}
453
+ * @return {string} e.g. "Page 14 (3/39)"
455
454
  */
456
455
  export function getNavPageNumHtml(index, numLeafs, pageNum, pageType, maxPageNum) {
457
456
  const pageIsAsserted = pageNum[0] != 'n';
458
- const pageIndex = index + 1;
459
457
 
460
458
  if (!pageIsAsserted) {
461
459
  pageNum = '—';
462
460
  }
463
- return `Page ${pageNum} (${pageIndex}/${numLeafs})`;
461
+ const lastIndex = Math.max(0, numLeafs - 1);
462
+ return `Page ${pageNum} (${index}/${lastIndex})`;
464
463
  }
@@ -4,10 +4,12 @@ export class SelectionObserver {
4
4
  startedInSelector = false;
5
5
  /** @type {HTMLElement} */
6
6
  target = null;
7
+ /** @type {Node} */
8
+ lastKnownFocusNode = null;
7
9
 
8
10
  /**
9
11
  * @param {string} selector
10
- * @param {function('started' | 'cleared', HTMLElement): any} handler
12
+ * @param {function('started' | 'cleared' | 'focusChanged', HTMLElement): any} handler
11
13
  */
12
14
  constructor(selector, handler) {
13
15
  this.selector = selector;
@@ -34,9 +36,15 @@ export class SelectionObserver {
34
36
  if (!target) return;
35
37
  this.target = target;
36
38
  this.selecting = true;
39
+ this.lastKnownFocusNode = sel.focusNode;
37
40
  this.handler('started', this.target);
38
41
  }
39
42
 
43
+ if (this.selecting && (this.lastKnownFocusNode != sel.focusNode || sel.toString() && !sel.isCollapsed)) {
44
+ this.lastKnownFocusNode = sel.focusNode;
45
+ this.handler('focusChanged', this.target);
46
+ }
47
+
40
48
  if (this.selecting && (sel.isCollapsed || !sel.toString() || !$(sel.anchorNode).closest(this.selector)[0])) {
41
49
  this.selecting = false;
42
50
  this.handler('cleared', this.target);
package/src/BookReader.js CHANGED
@@ -1970,10 +1970,25 @@ BookReader.prototype.queryStringFromParams = function(
1970
1970
  if (params.search && urlMode === 'history') {
1971
1971
  newParams.set('q', params.search);
1972
1972
  }
1973
+
1974
+ let textFragmentParam = '';
1975
+ // Need to pull out text separately to avoid the spaces becoming encoded as +, which
1976
+ // the browser seems not to handle with the text fragment
1977
+ if (newParams.get('text')) {
1978
+ newParams.delete('text');
1979
+ textFragmentParam = `text=${this.urlPlugin.retrieveTextFragment(currQueryString)}`;
1980
+ }
1981
+
1973
1982
  // https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/toString
1974
1983
  // Note: This method returns the query string without the question mark.
1975
- const result = newParams.toString();
1976
- return result ? '?' + result : '';
1984
+ let result = newParams.toString();
1985
+ if (textFragmentParam) {
1986
+ if (result) result += '&';
1987
+ result += textFragmentParam;
1988
+ }
1989
+ if (result) result = '?' + result;
1990
+
1991
+ return result;
1977
1992
  };
1978
1993
 
1979
1994
  /**
@@ -150,4 +150,63 @@
150
150
 
151
151
  .BRtranslateLayer .BRparagraphElement.BRtranslateHidden {
152
152
  display: none;
153
+ }
154
+
155
+ .br-select-menu__root {
156
+ display: none;
157
+ border-radius: 20px;
158
+ background-color: #333;
159
+ color-scheme: dark;
160
+ padding: 2px;
161
+ overflow: hidden;
162
+ transition: border-radius 0.2s;
163
+ }
164
+ .br-select-menu__root:hover {
165
+ border-radius: 8px;
166
+ }
167
+
168
+ .br-select-menu__root:hover .br-select-menu__option {
169
+ border-radius: 6px;
170
+ }
171
+
172
+ .br-select-menu__option {
173
+ --iconWidth: 15px;
174
+ --iconHeight: 15px;
175
+ --iconFillColor: currentColor;
176
+ background: transparent;
177
+ display: flex;
178
+ align-items: center;
179
+ border-radius: 20px;
180
+ font-family: inherit;
181
+ border: 0;
182
+ transition: background-color 0.2s, border-radius 0.2s;
183
+ cursor: pointer;
184
+ text-wrap: nowrap;
185
+ padding: 4px;
186
+ }
187
+
188
+ .br-select-menu__option:hover {
189
+ background-color: rgba(255, 255, 255, 0.1);
190
+ }
191
+
192
+ .br-select-menu__icon {
193
+ display: block;
194
+ flex-shrink: 0;
195
+ width: 17px;
196
+ height: 17px;
197
+ }
198
+
199
+ .br-select-menu__label {
200
+ width: 0px;
201
+ opacity: 0;
202
+ interpolate-size: allow-keywords;
203
+ transition: width 0.2s;
204
+ font-size: 12px;
205
+ }
206
+
207
+
208
+ .br-select-menu__root:hover .br-select-menu__label {
209
+ width: auto;
210
+ margin-left: 4px;
211
+ opacity: 1;
153
212
  }
@@ -6,6 +6,7 @@ import { styleMap } from 'lit/directives/style-map.js';
6
6
  import '@internetarchive/icon-toc/icon-toc.js';
7
7
  import { BookReaderPlugin } from "../BookReaderPlugin.js";
8
8
  import { applyVariables } from "../util/strings.js";
9
+ import { promisifyEvent } from "../BookReader/utils.js";
9
10
  /** @typedef {import('@/src/BookReader/BookModel.js').PageIndex} PageIndex */
10
11
  /** @typedef {import('@/src/BookReader/BookModel.js').PageString} PageString */
11
12
  /** @typedef {import('@/src/BookReader/BookModel.js').LeafNum} LeafNum */
@@ -94,11 +95,15 @@ export class ChaptersPlugin extends BookReaderPlugin {
94
95
  this._tocEntries = rawTableOfContents
95
96
  .map(rawTOCEntry => (Object.assign({}, rawTOCEntry, {
96
97
  pageIndex: (
97
- typeof(rawTOCEntry.leaf) == 'number' ? this.br.book.leafNumToIndex(rawTOCEntry.leaf) :
98
- rawTOCEntry.pagenum ? this.br.book.getPageIndex(rawTOCEntry.pagenum) :
99
- undefined
98
+ typeof rawTOCEntry.page_index === 'number' ? rawTOCEntry.page_index :
99
+ typeof(rawTOCEntry.leaf) == 'number' ? this.br.book.leafNumToIndex(rawTOCEntry.leaf) :
100
+ rawTOCEntry.pagenum ? this.br.book.getPageIndex(rawTOCEntry.pagenum) :
101
+ undefined
100
102
  ),
101
103
  })));
104
+ if (!this.br.shell) {
105
+ await promisifyEvent(window, 'BookReader:PostInit');
106
+ }
102
107
  this._render();
103
108
  this.br.bind(BookReader.eventNames.pageChanged, () => this._updateCurrent());
104
109
  }
@@ -51,11 +51,27 @@ export class ExperimentsPlugin extends BookReaderPlugin {
51
51
  localStorageKey: 'BrExperiments',
52
52
 
53
53
  /** The experiments that should be shown in the experiments panel */
54
- enabledExperiments: ['translate'],
54
+ enabledExperiments: ['translate', 'copyLinkToHighlight'],
55
55
  }
56
56
 
57
57
  /** @type {ExperimentModel[]} */
58
58
  allExperiments = [
59
+ new class extends ExperimentModel {
60
+ name = 'copyLinkToHighlight';
61
+ title = 'Copy to Selection URL';
62
+ description = 'Share text selection via URL';
63
+ learnMore = 'none';
64
+ icon = null;
65
+ enabled = false;
66
+ async enable ({ manual = false }) {
67
+ this.br.plugins.textSelection.enableSelectionMenu();
68
+ }
69
+ async disable() {
70
+ sleep(0).then(() => {
71
+ window.location.reload();
72
+ });
73
+ }
74
+ }(),
59
75
  new class extends ExperimentModel {
60
76
  name = 'translate';
61
77
  title = 'Translate Plugin';
@@ -123,7 +139,9 @@ export class ExperimentsPlugin extends BookReaderPlugin {
123
139
  for (const experiment of this.allExperiments) {
124
140
  // TODO: imagesBaseURL should be replaced with assetRoot everywhere
125
141
  experiment.assetRoot = this.br.options.imagesBaseURL.replace(/images\/$/, '');
126
- experiment.icon = experiment.buildAssetPath(experiment.icon);
142
+ if (experiment.icon) {
143
+ experiment.icon = experiment.buildAssetPath(experiment.icon);
144
+ }
127
145
  experiment.br = this.br;
128
146
  }
129
147
 
@@ -269,7 +287,7 @@ export class BrExperimentToggle extends LitElement {
269
287
  return html`
270
288
  <div class="experiment-card" style="margin-bottom: 10px;">
271
289
  <div style="display: flex; align-items: center; gap: 10px;">
272
- <img src="${this.icon}" style="width: 20px; height: 20px;" alt="" />
290
+ ${this.icon ? html`<img src="${this.icon}" style="width: 20px; height: 20px;" alt="" />` : ''}
273
291
  <div style="flex-grow: 1; font-weight: bold;">${this.title}</div>
274
292
  </div>
275
293
  <p style="opacity: 0.9">
@@ -53,10 +53,21 @@ export class TextSelectionPlugin extends BookReaderPlugin {
53
53
  init() {
54
54
  if (!this.options.enabled) return;
55
55
 
56
+ this.br.on('pageVisible', (_, {pageContainerEl}) => {
57
+ if (pageContainerEl.querySelector('.BRtextLayer')) {
58
+ this.br.trigger('textLayerVisible', {pageContainerEl});
59
+ }
60
+ });
61
+
56
62
  this.loadData();
57
63
  this.textSelectionManager.init();
58
64
  }
59
65
 
66
+ enableSelectionMenu() {
67
+ this.textSelectionManager.selectionMenuEnabled = true;
68
+ this.textSelectionManager.renderSelectionMenu();
69
+ }
70
+
60
71
  /**
61
72
  * @override
62
73
  * @param {PageContainer} pageContainer
@@ -188,6 +199,7 @@ export class TextSelectionPlugin extends BookReaderPlugin {
188
199
  paragEl.style.marginTop = `${newTop}px`;
189
200
  yAdded += newTop;
190
201
  textLayer.appendChild(paragEl);
202
+ textLayer.appendChild(document.createTextNode('\n'));
191
203
  }
192
204
  $container.append(textLayer);
193
205
  this.textSelectionManager.stopPageFlip($container);
@@ -195,6 +207,11 @@ export class TextSelectionPlugin extends BookReaderPlugin {
195
207
  pageIndex,
196
208
  pageContainer,
197
209
  });
210
+
211
+ // Check if page is visible
212
+ if ($container.hasClass('BRpage-visible')) {
213
+ this.br.trigger('textLayerVisible', {pageContainerEl: $container[0]});
214
+ }
198
215
  }
199
216
 
200
217
  /**
@@ -1,9 +1,9 @@
1
1
  // @ts-check
2
- import { html, LitElement } from 'lit';
2
+ import { css, html, LitElement } from 'lit';
3
3
  import { BookReaderPlugin } from '../../BookReaderPlugin.js';
4
- import { customElement, property } from 'lit/decorators.js';
4
+ import { customElement, property, query } from 'lit/decorators.js';
5
5
  import { TranslationManager } from "./TranslationManager.js";
6
- import { toISO6391 } from '../tts/utils.js';
6
+ import { toISO6391, toNativeName } from '../tts/utils.js';
7
7
  import { sortBy } from '../../../src/BookReader/utils.js';
8
8
  import { TextSelectionManager } from '../../../src/util/TextSelectionManager.js';
9
9
  import '@internetarchive/ia-activity-indicator';
@@ -63,14 +63,12 @@ export class TranslatePlugin extends BookReaderPlugin {
63
63
  textSelectionManager = new TextSelectionManager('.BRtranslateLayer', this.br, {selectionElement: [".BRlineElement"]}, 1);
64
64
 
65
65
  async init() {
66
- const currentLanguage = toISO6391(this.br.options.bookLanguage.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, ""));
66
+ if (!this.options.enabled) return;
67
+
68
+ const bookLanguage = this.br.options.bookLanguage || '';
69
+ const currentLanguage = toISO6391(bookLanguage);
67
70
  this.langFromCode = currentLanguage ?? "en";
68
71
  this.textSelectionManager.init();
69
-
70
- if (!this.options.enabled) {
71
- return;
72
- }
73
-
74
72
  this.translationManager.publicPath = this.br.options.imagesBaseURL.replace(/\/+$/, '') + '/..';
75
73
 
76
74
  /**
@@ -307,6 +305,14 @@ export class TranslatePlugin extends BookReaderPlugin {
307
305
  }
308
306
 
309
307
  handleToggleTranslation = async () => {
308
+ const shouldEnableTranslation = !this.userToggleTranslate;
309
+ if (shouldEnableTranslation && !this.isSourceLanguageSupported()) {
310
+ this.userToggleTranslate = false;
311
+ this.translationManager.active = false;
312
+ this._render();
313
+ return;
314
+ }
315
+
310
316
  this.userToggleTranslate = !this.userToggleTranslate;
311
317
  this.translationManager.active = this.userToggleTranslate;
312
318
 
@@ -325,6 +331,10 @@ export class TranslatePlugin extends BookReaderPlugin {
325
331
  /**
326
332
  * Update translation side menu
327
333
  */
334
+ isSourceLanguageSupported() {
335
+ return this.translationManager.fromLanguages.some((lang) => lang.code == this.langFromCode);
336
+ }
337
+
328
338
  _render() {
329
339
  this.br.shell.menuProviders['translate'] = {
330
340
  id: 'translate',
@@ -339,6 +349,7 @@ export class TranslatePlugin extends BookReaderPlugin {
339
349
  this._panel.detectedToLang = this.langToCode;
340
350
  this._panel.detectedFromLang = this.langFromCode;
341
351
  this._panel.loadingModel = this.loadingModel;
352
+ this._panel.sourceLanguageSupported = this.isSourceLanguageSupported();
342
353
  }
343
354
  }"
344
355
  @langFromChanged="${this.handleFromLangChange}"
@@ -351,6 +362,7 @@ export class TranslatePlugin extends BookReaderPlugin {
351
362
  .detectedFromLang=${this.langFromCode}
352
363
  .detectedToLang=${this.langToCode}
353
364
  .loadingModel=${this.loadingModel}
365
+ .sourceLanguageSupported=${this.isSourceLanguageSupported()}
354
366
  class="translate-panel"
355
367
  />`,
356
368
  };
@@ -369,12 +381,27 @@ export class BrTranslatePanel extends LitElement {
369
381
  @property({ type: String }) detectedFromLang = '';
370
382
  @property({ type: String }) detectedToLang = '';
371
383
  @property({ type: Boolean }) loadingModel;
384
+ @property({ type: Boolean }) sourceLanguageSupported = true;
385
+
386
+ @query('#lang-from') langFromDropdown;
387
+ @query('#lang-to') langToDropdown;
388
+
389
+ static styles = css`
390
+ .disclaimer {
391
+ padding: 10px;
392
+ border-radius: 8px;
393
+ font-size: 12px;
394
+ margin: 0 0 10px;
395
+ background-color: rgba(255, 255, 255, 0.1);
396
+ color: rgba(255, 255, 255, 0.9);
397
+ }
372
398
 
373
- /** @override */
374
- createRenderRoot() {
375
- // Disable shadow DOM; that would require a huge rejiggering of CSS
376
- return this;
377
- }
399
+ .disclaimer--warning {
400
+ margin: 10px 0;
401
+ background-color: rgba(255, 186, 8, 0.18);
402
+ color: #ffe7a3;
403
+ }
404
+ `;
378
405
 
379
406
  connectedCallback() {
380
407
  super.connectedCallback();
@@ -382,11 +409,12 @@ export class BrTranslatePanel extends LitElement {
382
409
  }
383
410
 
384
411
  render() {
412
+ const statusWarning = this._statusWarning();
413
+
385
414
  return html`<div class="app" style="margin-top: 5%;padding-right: 5px;">
386
415
  <div
387
416
  class="disclaimer"
388
417
  id="disclaimerMessage"
389
- style="background-color: rgba(255,255,255,0.1);padding: 10px;border-radius: 8px;font-size: 12px;margin-bottom: 10px;color: rgba(255,255,255, 0.9);"
390
418
  >${this.disclaimerMessage}</div>
391
419
 
392
420
  <div class="panel panel--to" style="padding: 0 10px;">
@@ -405,7 +433,7 @@ export class BrTranslatePanel extends LitElement {
405
433
  </div>
406
434
 
407
435
  <div class="panel panel--start" style="text-align: right;padding: 0 10px;/*! font-size: 18px; */margin-top: 10px;">
408
- <button class="start-translation-brn" @click="${this._toggleTranslation}">
436
+ <button class="start-translation-brn" @click="${this._toggleTranslation}" ?disabled=${!this.sourceLanguageSupported}>
409
437
  ${this.userTranslationActive ? "Stop Translating" : "Translate"}
410
438
  </button>
411
439
  </div>
@@ -426,9 +454,14 @@ export class BrTranslatePanel extends LitElement {
426
454
  })
427
455
  }
428
456
  </select>
429
- </details>
457
+ </details>
458
+ </div>
430
459
  <div class="footer" id="status" style="margin-top:5%">
431
- ${this._statusWarning()}
460
+ ${
461
+ statusWarning ? html`
462
+ <div class="disclaimer disclaimer--warning">${statusWarning}</div>
463
+ ` : ''
464
+ }
432
465
  </div>
433
466
 
434
467
  <div class="lang-models-loading">
@@ -469,17 +502,27 @@ export class BrTranslatePanel extends LitElement {
469
502
  }
470
503
 
471
504
  _getSelectedLang(type) {
472
- /** @type {HTMLSelectElement} */
473
- const dropdown = this.querySelector(`#lang-${type}`);
505
+ const dropdown = type === 'from' ? this.langFromDropdown : this.langToDropdown;
474
506
  return dropdown ? dropdown.value : '';
475
507
  }
476
508
 
509
+ /**
510
+ * Resolves a human-readable language name for a language code.
511
+ * Prefers names from loaded translation language lists, then falls back to
512
+ * ISO language metadata via toNativeName.
513
+ * @param {string} code
514
+ * @returns {string}
515
+ */
477
516
  _getLangName(code) {
478
- const lang = [...this.fromLanguages, ...this.toLanguages].find(lang => lang.code === code);
479
- return lang ? lang.name : '';
517
+ const lang = [...this.fromLanguages, ...this.toLanguages]
518
+ .find(lang => lang.code === code);
519
+ return lang?.name || toNativeName(code) || code;
480
520
  }
481
521
 
482
522
  _toggleTranslation(event) {
523
+ if (!this.sourceLanguageSupported) {
524
+ return;
525
+ }
483
526
  const toggleTranslateEvent = new CustomEvent('toggleTranslation', {
484
527
  detail: {value: event.target.value},
485
528
  bubbles: true,
@@ -491,6 +534,9 @@ export class BrTranslatePanel extends LitElement {
491
534
 
492
535
  // TODO: Hardcoded warning message for now but should add more statuses
493
536
  _statusWarning() {
537
+ if (!this.sourceLanguageSupported) {
538
+ return `Translating from ${this._getLangName(this.detectedFromLang)} is not supported`;
539
+ }
494
540
  if (this.detectedFromLang == this.detectedToLang) {
495
541
  return "Translate To language is the same as the Source language";
496
542
  }
@@ -53,7 +53,7 @@ export function toISO6391(language) {
53
53
  /**
54
54
  *
55
55
  * @param {string} language
56
- * @returns {string}
56
+ * @returns {string | null}
57
57
  */
58
58
  export function toNativeName(language) {
59
59
  if (!language) return null;
@@ -161,7 +161,7 @@ export class UrlPlugin {
161
161
  * If it was changeed, update the urlState
162
162
  */
163
163
  listenForHashChanges() {
164
- this.oldLocationHash = window.location.hash.substr(1);
164
+ this.oldLocationHash = this.getHash();
165
165
  if (this.urlLocationPollId) {
166
166
  clearInterval(this.urlLocationPollId);
167
167
  this.urlLocationPollId = null;
@@ -169,7 +169,7 @@ export class UrlPlugin {
169
169
 
170
170
  // check if the URL changes
171
171
  const updateHash = () => {
172
- const newFragment = window.location.hash.substr(1);
172
+ const newFragment = this.getHash();
173
173
  const hasFragmentChange = newFragment != this.oldLocationHash;
174
174
 
175
175
  if (!hasFragmentChange) { return; }
@@ -182,10 +182,30 @@ export class UrlPlugin {
182
182
  /**
183
183
  * Will read either the hash or URL and return the bookreader fragment
184
184
  */
185
- pullFromAddressBar (location = window.location) {
185
+ pullFromAddressBar(location = window.location) {
186
186
  const path = this.urlMode === 'history'
187
187
  ? (location.pathname.substr(this.urlHistoryBasePath.length) + location.search)
188
188
  : location.hash.substr(1);
189
189
  this.urlState = this.urlStringToUrlState(path);
190
190
  }
191
+
192
+ /**
193
+ * Get the hash out of the current URL. Also augments it with the text
194
+ * from the main part of the URL, since that is not readable by JS
195
+ * from the actual hash
196
+ * @returns
197
+ */
198
+ getHash() {
199
+ const text = this.retrieveTextFragment(window.location.search);
200
+ const textFragment = text ? `:~:text=${text[0]}` : '';
201
+ return `${window.location.hash.slice(1)}${textFragment}`;
202
+ }
203
+
204
+ /**
205
+ * @param {string} urlString
206
+ * @returns {string}
207
+ */
208
+ retrieveTextFragment(urlString) {
209
+ return urlString.match(/(?<=[&?]?text=)[^&]*/);
210
+ }
191
211
  }
@@ -1,6 +1,7 @@
1
1
  /* global BookReader */
2
2
 
3
3
  import { UrlPlugin } from "./UrlPlugin.js";
4
+ import { sleep } from "../../BookReader/utils.js";
4
5
 
5
6
  /**
6
7
  * Plugin for URL management in BookReader
@@ -25,7 +26,7 @@ jQuery.extend(BookReader.defaultOptions, {
25
26
  urlHistoryBasePath: '/',
26
27
 
27
28
  /** Only these params will be reflected onto the URL */
28
- urlTrackedParams: ['page', 'search', 'mode', 'region', 'highlight', 'view'],
29
+ urlTrackedParams: ['page', 'search', 'mode', 'region', 'highlight', 'view', 'text'],
29
30
 
30
31
  /** If true, don't update the URL when `page == n0 (eg "/page/n0")` */
31
32
  urlTrackIndex0: false,
@@ -42,6 +43,10 @@ BookReader.prototype.setup = (function(super_) {
42
43
  this.locationPollId = null;
43
44
  this.oldLocationHash = null;
44
45
  this.oldUserHash = null;
46
+ // Should include the :~:text= prefix
47
+ this.textFragment = null;
48
+ // Tracks the original textFragment page num when first loaded
49
+ this.textFragmentPage = null;
45
50
  };
46
51
  })(BookReader.prototype.setup);
47
52
 
@@ -140,11 +145,23 @@ BookReader.prototype.urlUpdateFragment = function() {
140
145
  return validParams;
141
146
  }, {});
142
147
 
148
+ // eg 'page/3/mode/2up'; no query params (in hash mode, it might have /search/term)
149
+ // Does NOT have the :~:text fragment
143
150
  const newFragment = this.fragmentFromParams(params, this.options.urlMode);
151
+ const newFragmentWithSlash = newFragment === '' ? '' : `/${newFragment}`;
152
+ // eg 'page/3/mode/2up'; no query params
153
+ // WILL CONTAIN the :~:text fragment in hash mode (!)
144
154
  const currFragment = this.urlReadFragment();
155
+ // This should have both ?q=foo&text=bar (and any other params) as an encoded string
145
156
  const currQueryString = this.getLocationSearch();
157
+ // Eg ?q=foo&text=bar; only query params, no fragment
146
158
  const newQueryString = this.queryStringFromParams(params, currQueryString, this.options.urlMode);
147
- if (currFragment === newFragment && currQueryString === newQueryString) {
159
+
160
+ // NOTE: If ?text is in the URL, we will fire fragment change events on every render; which is
161
+ // not desireable, but currently don't have a way to handle re-writing ?text to the hash text
162
+ // fragment form, :~:text=foo.
163
+ const hasTextParam = this.urlPlugin.retrieveTextFragment(currQueryString);
164
+ if (currFragment === newFragment && currQueryString === newQueryString && !hasTextParam) {
148
165
  return;
149
166
  }
150
167
 
@@ -153,12 +170,19 @@ BookReader.prototype.urlUpdateFragment = function() {
153
170
  this.options.urlMode = 'hash';
154
171
  } else {
155
172
  const baseWithoutSlash = this.options.urlHistoryBasePath.replace(/\/+$/, '');
156
- const newFragmentWithSlash = newFragment === '' ? '' : `/${newFragment}`;
157
-
173
+ const textFragment = this.urlPlugin.retrieveTextFragment(newQueryString);
158
174
  const newUrlPath = `${baseWithoutSlash}${newFragmentWithSlash}${newQueryString}`;
175
+ const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;
176
+ if (!this.textFragmentPage && textFragment) {
177
+ this.textFragmentPage = extractedPage ? extractedPage : null;
178
+ this.textFragment = `:~:text=${textFragment}`;
179
+ }
159
180
  try {
160
181
  window.history.replaceState({}, null, newUrlPath);
161
182
  this.oldLocationHash = newFragment + newQueryString;
183
+ if (textFragment) {
184
+ this.oldLocationHash += `:~:text=${textFragment[0]}`;
185
+ }
162
186
  } catch (e) {
163
187
  // DOMException on Chrome when in sandboxed iframe
164
188
  this.options.urlMode = 'hash';
@@ -168,8 +192,22 @@ BookReader.prototype.urlUpdateFragment = function() {
168
192
 
169
193
  if (this.options.urlMode === 'hash') {
170
194
  const newQueryStringSearch = this.urlParamsFiltersOnlySearch(this.readQueryString());
171
- window.location.replace('#' + newFragment + newQueryStringSearch);
172
- this.oldLocationHash = newFragment + newQueryStringSearch;
195
+ let textFragment = this.urlPlugin.retrieveTextFragment(this.readQueryString());
196
+ const extractedPage = this.urlPlugin.urlStringToUrlState(newFragmentWithSlash)?.page;
197
+
198
+ if (textFragment) {
199
+ textFragment = `:~:text=${textFragment[0]}`;
200
+ } else {
201
+ textFragment = '';
202
+ }
203
+ if (!this.textFragmentPage && textFragment) {
204
+ this.textFragmentPage = extractedPage ? extractedPage : null;
205
+ this.textFragment = textFragment;
206
+ } else if (this.textFragmentPage && extractedPage != this.textFragmentPage) {
207
+ textFragment = '';
208
+ }
209
+ window.location.replace('#' + newFragment + newQueryStringSearch + textFragment);
210
+ this.oldLocationHash = newFragment + newQueryStringSearch + textFragment;
173
211
  }
174
212
  };
175
213
 
@@ -195,7 +233,7 @@ BookReader.prototype.urlReadFragment = function() {
195
233
  if (urlMode === 'history') {
196
234
  return window.location.pathname.substr(urlHistoryBasePath.length);
197
235
  } else {
198
- return window.location.hash.substr(1);
236
+ return this.urlPlugin.getHash();
199
237
  }
200
238
  };
201
239
 
@@ -210,6 +248,24 @@ export class BookreaderUrlPlugin extends BookReader {
210
248
  init() {
211
249
  if (this.options.enableUrlPlugin) {
212
250
  this.urlPlugin = new UrlPlugin(this.options);
251
+ const location = this.getLocationSearch();
252
+ if (location.includes("text=")) {
253
+ this.on('textLayerVisible', async (_, {pageContainerEl}) => {
254
+ const visiblePageNum = pageContainerEl.getAttribute('data-page-num');
255
+
256
+ // Hack: More time mode 1up page "settle down" from user scrolling
257
+ await sleep(this.mode === 1 ? 900 : 100);
258
+
259
+ // No textFragment found or the textFragment stored doesn't match current visible page loaded
260
+ if (!this.textFragment || this.textFragmentPage !== visiblePageNum) return;
261
+ if (this.options.urlMode === 'history') {
262
+ window.location.replace(`#${this.textFragment}`);
263
+ } else {
264
+ // for urlMode hash, textFragment is stored in oldLocationHash already
265
+ window.location.replace(`#${this.oldLocationHash}`);
266
+ }
267
+ });
268
+ }
213
269
  this.bind(BookReader.eventNames.PostInit, () => {
214
270
  const { urlMode } = this.options;
215
271