@internetarchive/bookreader 5.0.0-70-a3 → 5.0.0-72

Sign up to get free protection for your applications and to get access to all the features.
package/src/BookReader.js CHANGED
@@ -1788,7 +1788,7 @@ BookReader.prototype.paramsFromFragment = function(fragment) {
1788
1788
  // Index and page
1789
1789
  if ('undefined' != typeof(urlHash['page'])) {
1790
1790
  // page was set -- may not be int
1791
- params.page = urlHash['page'];
1791
+ params.page = decodeURIComponent(urlHash['page']);
1792
1792
  }
1793
1793
 
1794
1794
  // $$$ process /region
@@ -1820,11 +1820,10 @@ BookReader.prototype.paramsFromFragment = function(fragment) {
1820
1820
  * @return {string}
1821
1821
  */
1822
1822
  BookReader.prototype.fragmentFromParams = function(params, urlMode = 'hash') {
1823
- const separator = '/';
1824
1823
  const fragments = [];
1825
1824
 
1826
1825
  if ('undefined' != typeof(params.page)) {
1827
- fragments.push('page', params.page);
1826
+ fragments.push('page', encodeURIComponent(params.page));
1828
1827
  } else {
1829
1828
  if ('undefined' != typeof(params.index)) {
1830
1829
  // Don't have page numbering but we do have the index
@@ -1850,10 +1849,10 @@ BookReader.prototype.fragmentFromParams = function(params, urlMode = 'hash') {
1850
1849
 
1851
1850
  // search
1852
1851
  if (params.search && urlMode === 'hash') {
1853
- fragments.push('search', params.search);
1852
+ fragments.push('search', utils.encodeURIComponentPlus(params.search));
1854
1853
  }
1855
1854
 
1856
- return utils.encodeURIComponentPlus(fragments.join(separator)).replace(/%2F/g, '/');
1855
+ return fragments.join('/');
1857
1856
  };
1858
1857
 
1859
1858
  /**
@@ -1,4 +1,5 @@
1
1
  import PageChunkIterator from './PageChunkIterator.js';
2
+ import { hasLocalStorage } from './utils.js';
2
3
  /** @typedef {import('./utils.js').ISO6391} ISO6391 */
3
4
  /** @typedef {import('./PageChunk.js')} PageChunk */
4
5
 
@@ -80,6 +81,7 @@ export default class AbstractTTSEngine {
80
81
  */
81
82
  start(leafIndex, numLeafs) {
82
83
  this.playing = true;
84
+ this.paused = false;
83
85
  this.opts.onLoadingStart();
84
86
 
85
87
  this._chunkIterator = new PageChunkIterator(numLeafs, leafIndex, {
@@ -95,6 +97,7 @@ export default class AbstractTTSEngine {
95
97
  stop() {
96
98
  if (this.activeSound) this.activeSound.stop();
97
99
  this.playing = false;
100
+ this.paused = true;
98
101
  this._chunkIterator = null;
99
102
  this.activeSound = null;
100
103
  this.events.trigger('stop');
@@ -142,6 +145,10 @@ export default class AbstractTTSEngine {
142
145
  // MS Edge fires voices changed randomly very often
143
146
  this.events.off('voiceschanged', this.updateBestVoice);
144
147
  this.voice = this.getVoices().find(voice => voice.voiceURI === voiceURI);
148
+ // if the current book has a language set, store the selected voice with the book language as a suffix
149
+ if (this.opts.bookLanguage && hasLocalStorage()) {
150
+ localStorage.setItem(`BRtts-voice-${this.opts.bookLanguage}`, this.voice.voiceURI);
151
+ }
145
152
  if (this.activeSound) this.activeSound.setVoice(this.voice);
146
153
  }
147
154
 
@@ -221,10 +228,12 @@ export default class AbstractTTSEngine {
221
228
  // user languages that match the book language
222
229
  const matchingUserLangs = userLanguages.filter(lang => lang.startsWith(bookLanguage));
223
230
 
224
- // Try to find voices that intersect these two sets
225
- return AbstractTTSEngine.getMatchingVoice(matchingUserLangs, bookLangVoices) ||
231
+ // First try to find the last chosen voice from localStorage for the current book language
232
+ return AbstractTTSEngine.getMatchingStoredVoice(bookLangVoices, bookLanguage)
233
+ // Try to find voices that intersect these two sets
234
+ || AbstractTTSEngine.getMatchingVoice(matchingUserLangs, bookLangVoices)
226
235
  // no user languages match the books; let's return the best voice for the book language
227
- (bookLangVoices.find(v => v.default) || bookLangVoices[0])
236
+ || (bookLangVoices.find(v => v.default) || bookLangVoices[0])
228
237
  // No voices match the book language? let's find a voice in the user's language
229
238
  // and ignore book lang
230
239
  || AbstractTTSEngine.getMatchingVoice(userLanguages, voices)
@@ -232,6 +241,19 @@ export default class AbstractTTSEngine {
232
241
  || (voices.find(v => v.default) || voices[0]);
233
242
  }
234
243
 
244
+ /**
245
+ * @private
246
+ * Get the voice last selected by the user for the book language from localStorage.
247
+ * Returns undefined if no voice is stored or found.
248
+ * @param {SpeechSynthesisVoice[]} voices browser voices to choose from
249
+ * @param {ISO6391} bookLanguage book language to look for
250
+ * @return {SpeechSynthesisVoice | undefined}
251
+ */
252
+ static getMatchingStoredVoice(voices, bookLanguage) {
253
+ const storedVoice = hasLocalStorage() && localStorage.getItem(`BRtts-voice-${bookLanguage}`);
254
+ return (storedVoice ? voices.find(v => v.voiceURI === storedVoice) : undefined);
255
+ }
256
+
235
257
  /**
236
258
  * @private
237
259
  * Get the best voice that matches one of the BCP47 languages (order by preference)
@@ -251,7 +251,7 @@ BookReader.prototype.ttsPlayPause = function() {
251
251
  this.ttsToggle();
252
252
  } else {
253
253
  this.ttsEngine.togglePlayPause();
254
- this.ttsUpdateState(this.ttsEngine.paused);
254
+ this.ttsUpdateState();
255
255
  }
256
256
  };
257
257
 
@@ -64,3 +64,18 @@ function searchForISO6391(language, columnsToSearch) {
64
64
  }
65
65
  return null;
66
66
  }
67
+
68
+ /**
69
+ * Checks whether the current browser supports localStorage or
70
+ * if the current context has access to it.
71
+ * @return {boolean}
72
+ */
73
+ export function hasLocalStorage() {
74
+ try {
75
+ return !!window.localStorage;
76
+ } catch (e) {
77
+ // Will throw in sandboxed iframe
78
+ // DOMException: Window.localStorage getter: Forbidden in a sandboxed document without the 'allow-same-origin' flag.
79
+ return false;
80
+ }
81
+ }
@@ -45,7 +45,7 @@ export class UrlPlugin {
45
45
 
46
46
  const strPathParams = this.urlSchema
47
47
  .filter(s => s.position == 'path')
48
- .map(schema => pathParams[schema.name] ? `${schema.name}/${pathParams[schema.name]}` : '')
48
+ .map(schema => pathParams[schema.name] ? `${schema.name}/${encodeURIComponent(pathParams[schema.name])}` : '')
49
49
  .join('/');
50
50
 
51
51
  // replace consecutive slashes with a single slash + remove trailing slashes
@@ -83,15 +83,13 @@ export class UrlPlugin {
83
83
  const hasPropertyKey = doesKeyExists(urlStrSplitSlashObj, schema.name);
84
84
  const hasDeprecatedKey = doesKeyExists(schema, 'deprecated_for') && hasPropertyKey;
85
85
 
86
- if (hasDeprecatedKey) {
87
- urlState[schema.deprecated_for] = urlStrSplitSlashObj[schema.name];
86
+ // Not in the URL
87
+ if (!hasPropertyKey && !hasDeprecatedKey) {
88
88
  return;
89
89
  }
90
90
 
91
- if (hasPropertyKey) {
92
- urlState[schema.name] = urlStrSplitSlashObj[schema.name];
93
- return;
94
- }
91
+ const urlStateParam = hasDeprecatedKey ? schema.deprecated_for : schema.name;
92
+ urlState[urlStateParam] = decodeURIComponent(urlStrSplitSlashObj[schema.name]);
95
93
  });
96
94
 
97
95
  // Add searchParams to urlState
@@ -40,4 +40,18 @@ describe("SelectionObserver", () => {
40
40
  observer._onSelectionChange();
41
41
  expect(handler.callCount).toBe(2);
42
42
  });
43
+
44
+ test('Only fires when selection started in selector', () => {
45
+ const handler = sinon.spy();
46
+ const observer = new SelectionObserver(".text-layer", handler);
47
+ const target = document.createElement("div");
48
+ target.classList.add("text-layer");
49
+
50
+ // stub window.getSelection
51
+ const getSelectionStub = sinon.stub(window, "getSelection");
52
+ getSelectionStub.returns({ toString: () => "test", anchorNode: document.body });
53
+ observer._onSelectionChange();
54
+ expect(handler.callCount).toBe(0);
55
+ expect(observer.selecting).toBe(false);
56
+ });
43
57
  });
@@ -111,6 +111,26 @@ for (const dummyVoice of [dummyVoiceHyphens, dummyVoiceUnderscores]) {
111
111
 
112
112
  expect(getBestBookVoice(voices, 'en', ['en-CA', 'en'])).toBe(voices[0]);
113
113
  });
114
+
115
+ test('choose stored language from localStorage', () => {
116
+ const voices = [
117
+ dummyVoice({lang: "en-US", voiceURI: "English US", default: true}),
118
+ dummyVoice({lang: "en-GB", voiceURI: "English GB"}),
119
+ dummyVoice({lang: "en-CA", voiceURI: "English CA"}),
120
+ ];
121
+ class DummyEngine extends AbstractTTSEngine {
122
+ getVoices() { return voices; }
123
+ }
124
+ const ttsEngine = new DummyEngine({...DUMMY_TTS_ENGINE_OPTS, bookLanguage: 'en'});
125
+ // simulates setting default voice on tts startup
126
+ ttsEngine.updateBestVoice();
127
+ // simulates user choosing a voice that matches the bookLanguage
128
+ // voice will be stored in localStorage
129
+ ttsEngine.setVoice(voices[2].voiceURI);
130
+
131
+ // expecting the voice to be selected by getMatchingStoredVoice and returned as best voice
132
+ expect(getBestBookVoice(voices, 'en', [])).toBe(voices[2]);
133
+ });
114
134
  });
115
135
  }
116
136
 
@@ -19,6 +19,10 @@ describe('UrlPlugin tests', () => {
19
19
  expect(urlPlugin.urlStateToUrlString(urlStateWithQueries)).toBe(expectedUrlFromStateWithQueries);
20
20
  });
21
21
 
22
+ test('encodes page number', () => {
23
+ expect(urlPlugin.urlStateToUrlString({ page: '12/46' })).toBe(`page/12%2F46`);
24
+ });
25
+
22
26
  test('urlStateToUrlString with unknown states in schema', () => {
23
27
  const urlState = { page: 'n7', mode: '1up' };
24
28
  const urlStateWithQueries = { page: 'n7', mode: '1up', q: 'hello', viewer: 'theater', sortBy: 'title_asc' };
@@ -47,6 +51,10 @@ describe('UrlPlugin tests', () => {
47
51
  expect(urlPlugin.urlStringToUrlState(url1)).toEqual({page: 'n7', mode: '1up'});
48
52
  });
49
53
 
54
+ test('decodes page number', () => {
55
+ expect(urlPlugin.urlStringToUrlState('/page/12%2F46')).toStrictEqual({ page: '12/46' });
56
+ });
57
+
50
58
  test('urlStringToUrlState with deprecated_for', () => {
51
59
  const url = '/page/n7/mode/2up/search/hello';
52
60