@internetarchive/bookreader 5.0.0-96 → 5.0.0-97
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/474.js +2 -0
- package/BookReader/474.js.map +1 -0
- package/BookReader/BookReader.css +32 -26
- package/BookReader/BookReader.js +1 -1
- package/BookReader/BookReader.js.map +1 -1
- package/BookReader/bergamot-translator-worker.js +2966 -0
- package/BookReader/bergamot-translator-worker.wasm +0 -0
- package/BookReader/ia-bookreader-bundle.js +1 -1
- package/BookReader/ia-bookreader-bundle.js.map +1 -1
- package/BookReader/images/icon_experiment.svg +1 -0
- package/BookReader/images/translate.svg +1 -0
- package/BookReader/plugins/plugin.experiments.js +1 -1
- package/BookReader/plugins/plugin.experiments.js.map +1 -1
- package/BookReader/plugins/plugin.text_selection.js +1 -1
- package/BookReader/plugins/plugin.text_selection.js.map +1 -1
- package/BookReader/plugins/plugin.translate.js +3 -0
- package/BookReader/plugins/plugin.translate.js.LICENSE.txt +1 -0
- package/BookReader/plugins/plugin.translate.js.map +1 -0
- package/BookReader/plugins/plugin.tts.js +1 -1
- package/BookReader/plugins/plugin.tts.js.map +1 -1
- package/BookReader/plugins/translator-worker.js +2 -0
- package/BookReader/plugins/translator-worker.js.map +1 -0
- package/BookReader/translator-worker.js +475 -0
- package/package.json +6 -3
- package/src/BookNavigator/book-navigator.js +1 -0
- package/src/BookReader/Mode1UpLit.js +6 -1
- package/src/BookReader/Mode2UpLit.js +11 -1
- package/src/BookReader/Navbar/Navbar.js +61 -0
- package/src/BookReader.js +22 -139
- package/src/assets/images/icon_experiment.svg +1 -0
- package/src/assets/images/translate.svg +1 -0
- package/src/css/_BRnav.scss +0 -24
- package/src/css/_BRsearch.scss +0 -5
- package/src/css/_TextSelection.scss +32 -1
- package/src/plugins/plugin.experiments.js +34 -9
- package/src/plugins/plugin.text_selection.js +8 -20
- package/src/plugins/translate/TranslationManager.js +167 -0
- package/src/plugins/translate/plugin.translate.js +414 -0
- package/src/plugins/tts/utils.js +9 -20
- package/src/util/cache.js +20 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { Cache } from '../../util/cache.js';
|
|
3
|
+
import { BatchTranslator } from '@internetarchive/bergamot-translator/translator.js';
|
|
4
|
+
|
|
5
|
+
export const langs = /** @type {{[lang: string]: string}} */ {
|
|
6
|
+
"bg": "Bulgarian",
|
|
7
|
+
"ca": "Catalan",
|
|
8
|
+
"cs": "Czech",
|
|
9
|
+
"nl": "Dutch",
|
|
10
|
+
"en": "English",
|
|
11
|
+
"et": "Estonian",
|
|
12
|
+
"de": "German",
|
|
13
|
+
"fr": "French",
|
|
14
|
+
"is": "Icelandic",
|
|
15
|
+
"it": "Italian",
|
|
16
|
+
"nb": "Norwegian Bokmål",
|
|
17
|
+
"nn": "Norwegian Nynorsk",
|
|
18
|
+
"fa": "Persian",
|
|
19
|
+
"pl": "Polish",
|
|
20
|
+
"pt": "Portuguese",
|
|
21
|
+
"ru": "Russian",
|
|
22
|
+
"es": "Spanish",
|
|
23
|
+
"uk": "Ukrainian",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class TranslationManager {
|
|
27
|
+
/** @type {Cache<{index: string, response: string}>} */
|
|
28
|
+
alreadyTranslated = new Cache(100);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} genericModelInfo
|
|
32
|
+
* @property {string} name
|
|
33
|
+
* @property {number} size
|
|
34
|
+
* @property {number} estimatedCompressedSize
|
|
35
|
+
* @property {any} [qualityModel]
|
|
36
|
+
* @property {string} [expectedSha256Hash]
|
|
37
|
+
* @property {string} [modelType]
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* @type { {[langPair: string] : {model: genericModelInfo, lex: genericModelInfo, vocab: genericModelInfo, quality?: genericModelInfo}} }
|
|
41
|
+
*/
|
|
42
|
+
modelRegistry = {};
|
|
43
|
+
|
|
44
|
+
/** @type {Record<key, {promise: Promise<string>, resolve: function, reject: function}>} */
|
|
45
|
+
currentlyTranslating = {}
|
|
46
|
+
|
|
47
|
+
/** @type {Record<string, string>[]} */
|
|
48
|
+
fromLanguages = [];
|
|
49
|
+
/** @type {Record<string, string>[]} */
|
|
50
|
+
toLanguages = [];
|
|
51
|
+
|
|
52
|
+
/** @type {boolean} */
|
|
53
|
+
active = false;
|
|
54
|
+
|
|
55
|
+
publicPath = '';
|
|
56
|
+
|
|
57
|
+
constructor() {
|
|
58
|
+
//TODO Should default to the book language as the first element
|
|
59
|
+
const enModel = {code: "en", name: "English", type: "prod"};
|
|
60
|
+
this.fromLanguages.push(enModel);
|
|
61
|
+
this.toLanguages.push(enModel);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async initWorker() {
|
|
66
|
+
if (this.initPromise) return this.initPromise;
|
|
67
|
+
this.initPromise = new Promise((resolve, reject) => {
|
|
68
|
+
this._initResolve = resolve;
|
|
69
|
+
this._initReject = reject;
|
|
70
|
+
});
|
|
71
|
+
const registryUrl = "https://cors.archive.org/cors/mozilla-translate-models/";
|
|
72
|
+
const registryJson = await fetch(registryUrl + "registry.json").then(r => r.json());
|
|
73
|
+
for (const language of Object.values(registryJson)) {
|
|
74
|
+
for (const file of Object.values(language)) {
|
|
75
|
+
file.name = registryUrl + file.name;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @type {BatchTranslator} */
|
|
80
|
+
// Arbitrary setting for number of workers, 1 is already quite fast
|
|
81
|
+
// batchSize from 8 -> 4 for improved performance
|
|
82
|
+
this.translator = new BatchTranslator({
|
|
83
|
+
registryUrl: `data:application/json,${encodeURIComponent(JSON.stringify(registryJson))}`,
|
|
84
|
+
workers: 2,
|
|
85
|
+
batchSize: 4,
|
|
86
|
+
workerUrl: this.publicPath + '/translator-worker.js',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const modelType = await this.translator.backing.registry;
|
|
90
|
+
const arr = {}; // unsure if we need to keep track of the files
|
|
91
|
+
for (const obj of Object.values(modelType)) {
|
|
92
|
+
const firstLang = obj['from'];
|
|
93
|
+
const secondLang = obj['to'];
|
|
94
|
+
const fromModelType = obj['files'];
|
|
95
|
+
arr[`${firstLang}${secondLang}`] = fromModelType;
|
|
96
|
+
// Assuming that all of the languages loaded from the registryUrl inside @internetarchive/bergamot-translator/translator.js are prod
|
|
97
|
+
// List of dev models found here https://github.com/mozilla/firefox-translations-models/tree/main/models/base
|
|
98
|
+
// There are also differences between the model types in the repo above here: https://github.com/mozilla/firefox-translations-models?tab=readme-ov-file#firefox-translations-models
|
|
99
|
+
if (firstLang !== "en") {
|
|
100
|
+
this.fromLanguages.push({code: firstLang, name:langs[firstLang], type: "prod"});
|
|
101
|
+
}
|
|
102
|
+
if (secondLang !== "en") {
|
|
103
|
+
this.toLanguages.push({code: secondLang, name:langs[secondLang], type: "prod"});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this._initResolve([this.modelRegistry]);
|
|
107
|
+
return this.initPromise;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Targets the page and paragraph of a text layer to create a translation from the "fromLang" to the "toLang". Tries to force order in translation by using the pageIndex (+1000 if the current page is not visible) and paragraphIndex
|
|
112
|
+
* @param {string} fromLang
|
|
113
|
+
* @param {string} toLang
|
|
114
|
+
* @param {string} pageIndex
|
|
115
|
+
* @param {number} paragraphIndex
|
|
116
|
+
* @param {string} text
|
|
117
|
+
* @param {number} priority
|
|
118
|
+
* @return {Promise<string>} translated text
|
|
119
|
+
*/
|
|
120
|
+
|
|
121
|
+
getTranslation = async (fromLang, toLang, pageIndex, paragraphIndex, text, priority) => {
|
|
122
|
+
this.active = true;
|
|
123
|
+
if (fromLang == toLang || !fromLang || !toLang) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const key = `${fromLang}${toLang}-${pageIndex}:${paragraphIndex}`;
|
|
127
|
+
const cachedEntry = this.alreadyTranslated.entries.find(x => x.index == key);
|
|
128
|
+
|
|
129
|
+
if (cachedEntry) {
|
|
130
|
+
return cachedEntry.response;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (key in this.currentlyTranslating) {
|
|
134
|
+
return this.currentlyTranslating[key].promise;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let _resolve = null;
|
|
138
|
+
let _reject = null;
|
|
139
|
+
const promise = new Promise((res, rej) => {
|
|
140
|
+
_resolve = res;
|
|
141
|
+
_reject = rej;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
this.currentlyTranslating[key] = {
|
|
145
|
+
promise,
|
|
146
|
+
resolve: _resolve,
|
|
147
|
+
reject: _reject,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
if (!text) {
|
|
151
|
+
this.currentlyTranslating[key].reject("No text was provided");
|
|
152
|
+
return promise;
|
|
153
|
+
}
|
|
154
|
+
this.translator.translate({
|
|
155
|
+
to: toLang,
|
|
156
|
+
from: fromLang,
|
|
157
|
+
text: text,
|
|
158
|
+
html: false,
|
|
159
|
+
priority: priority,
|
|
160
|
+
}).then((resp) => {
|
|
161
|
+
const response = resp;
|
|
162
|
+
this.currentlyTranslating[key].resolve(response.target.text);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return promise;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { html, LitElement } from 'lit';
|
|
3
|
+
import { BookReaderPlugin } from '../../BookReaderPlugin.js';
|
|
4
|
+
import { customElement, property } from 'lit/decorators.js';
|
|
5
|
+
import { langs, TranslationManager } from "./TranslationManager.js";
|
|
6
|
+
|
|
7
|
+
// @ts-ignore
|
|
8
|
+
const BookReader = /** @type {typeof import('@/src/BookReader.js').default} */(window.BookReader);
|
|
9
|
+
|
|
10
|
+
export class TranslatePlugin extends BookReaderPlugin {
|
|
11
|
+
|
|
12
|
+
options = {
|
|
13
|
+
enabled: true,
|
|
14
|
+
panelDisclaimerText: "Translations are in alpha",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @type {TranslationManager} */
|
|
18
|
+
translationManager = new TranslationManager();
|
|
19
|
+
|
|
20
|
+
/** @type {Worker}*/
|
|
21
|
+
worker;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Contains the list of languages available to translate to
|
|
25
|
+
* @type {string[]}
|
|
26
|
+
*/
|
|
27
|
+
toLanguages = [];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Current language code that is being translated From
|
|
31
|
+
* @type {!string}
|
|
32
|
+
*/
|
|
33
|
+
langFromCode = "en";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Current language code that is being translated To
|
|
37
|
+
* @type {!string}
|
|
38
|
+
*/
|
|
39
|
+
langToCode;
|
|
40
|
+
/**
|
|
41
|
+
* @type {BrTranslatePanel} _panel - Represents a panel used in the plugin.
|
|
42
|
+
* The specific type and purpose of this panel should be defined based on its usage.
|
|
43
|
+
*/
|
|
44
|
+
_panel;
|
|
45
|
+
|
|
46
|
+
async init() {
|
|
47
|
+
if (!this.options.enabled) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.translationManager.publicPath = this.br.options.imagesBaseURL.replace(/\/+$/, '') + '/..';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {*} ev
|
|
55
|
+
* @param {object} eventProps
|
|
56
|
+
*/
|
|
57
|
+
this.br.on('textLayerRendered', async (_, {pageIndex, pageContainer}) => {
|
|
58
|
+
// Stops invalid models from running, also prevents translation on page load
|
|
59
|
+
// TODO check if model has finished loading or if it exists
|
|
60
|
+
if (!this.translationManager) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (this.translationManager.active) {
|
|
64
|
+
const pageElement = pageContainer.$container[0];
|
|
65
|
+
this.translateRenderedLayer(pageElement);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {*} ev
|
|
71
|
+
* @param {object} eventProps
|
|
72
|
+
*/
|
|
73
|
+
this.br.on('pageVisible', (_, {pageContainerEl}) => {
|
|
74
|
+
if (!this.translationManager.active) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const paragraphEl of pageContainerEl.querySelectorAll('.BRtranslateLayer > .BRparagraphElement')) {
|
|
78
|
+
if (paragraphEl.textContent) {
|
|
79
|
+
this.fitVisiblePage(paragraphEl);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
await this.translationManager.initWorker();
|
|
85
|
+
// Note await above lets _render function properly, since it gives the browser
|
|
86
|
+
// time to render the rest of bookreader, which _render depends on
|
|
87
|
+
this._render();
|
|
88
|
+
|
|
89
|
+
this.langToCode = this.translationManager.toLanguages[0].code;
|
|
90
|
+
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {HTMLElement} page*/
|
|
94
|
+
getParagraphsOnPage = (page) => {
|
|
95
|
+
return page ? Array.from(page.querySelectorAll(".BRtextLayer > .BRparagraphElement")) : [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
translateActivePageContainerElements() {
|
|
99
|
+
const currentlyActiveContainers = this.br.getActivePageContainerElements();
|
|
100
|
+
const visiblePageContainers = currentlyActiveContainers.filter((element) => {
|
|
101
|
+
return element.classList.contains('BRpage-visible');
|
|
102
|
+
});
|
|
103
|
+
const hiddenPageContainers = currentlyActiveContainers.filter((element) => {
|
|
104
|
+
return !element.classList.contains('BRpage-visible');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const page of visiblePageContainers) {
|
|
108
|
+
this.translateRenderedLayer(page, 0);
|
|
109
|
+
}
|
|
110
|
+
for (const loadingPage of hiddenPageContainers) {
|
|
111
|
+
this.translateRenderedLayer(loadingPage, 1000);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @param {HTMLElement} page */
|
|
116
|
+
async translateRenderedLayer(page, priority) {
|
|
117
|
+
if (this.br.mode == this.br.constModeThumb) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pageIndex = page.dataset.index;
|
|
122
|
+
|
|
123
|
+
let pageTranslationLayer;
|
|
124
|
+
if (!page.querySelector('.BRPageLayer.BRtranslateLayer')) {
|
|
125
|
+
pageTranslationLayer = document.createElement('div');
|
|
126
|
+
pageTranslationLayer.classList.add('BRPageLayer', 'BRtranslateLayer');
|
|
127
|
+
pageTranslationLayer.setAttribute('lang', `${this.langToCode}`);
|
|
128
|
+
page.prepend(pageTranslationLayer);
|
|
129
|
+
} else {
|
|
130
|
+
pageTranslationLayer = page.querySelector('.BRPageLayer.BRtranslateLayer');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const textLayerElement = page.querySelector('.BRtextLayer');
|
|
134
|
+
textLayerElement.classList.add('showingTranslation');
|
|
135
|
+
$(pageTranslationLayer).css({
|
|
136
|
+
"width": $(textLayerElement).css("width"),
|
|
137
|
+
"height": $(textLayerElement).css("height"),
|
|
138
|
+
"transform": $(textLayerElement).css("transform"),
|
|
139
|
+
"pointer-events": $(textLayerElement).css("pointer-events"),
|
|
140
|
+
"z-index": 3,
|
|
141
|
+
});
|
|
142
|
+
const paragraphs = this.getParagraphsOnPage(page);
|
|
143
|
+
|
|
144
|
+
paragraphs.forEach(async (paragraph, pidx) => {
|
|
145
|
+
let translatedParagraph = page.querySelector(`[data-translate-index='${pageIndex}-${pidx}']`);
|
|
146
|
+
if (!translatedParagraph) {
|
|
147
|
+
translatedParagraph = document.createElement('p');
|
|
148
|
+
// set data-translate-index on the placeholder
|
|
149
|
+
translatedParagraph.setAttribute('data-translate-index', `${pageIndex}-${pidx}`);
|
|
150
|
+
translatedParagraph.className = 'BRparagraphElement';
|
|
151
|
+
const originalParagraphStyle = paragraphs[pidx];
|
|
152
|
+
const fontSize = `${parseInt($(originalParagraphStyle).css("font-size"))}px`;
|
|
153
|
+
|
|
154
|
+
$(translatedParagraph).css({
|
|
155
|
+
"margin-left": $(originalParagraphStyle).css("margin-left"),
|
|
156
|
+
"margin-top": $(originalParagraphStyle).css("margin-top"),
|
|
157
|
+
"top": $(originalParagraphStyle).css("top"),
|
|
158
|
+
"height": $(originalParagraphStyle).css("height"),
|
|
159
|
+
"width": $(originalParagraphStyle).css("width"),
|
|
160
|
+
"font-size": fontSize,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Note: We'll likely want to switch to using the same logic as
|
|
164
|
+
// TextSelectionPlugin's selection, which allows for e.g. click-to-flip
|
|
165
|
+
// to work simultaneously with text selection.
|
|
166
|
+
translatedParagraph.addEventListener('mousedown', (e) => {
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
e.stopImmediatePropagation();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
translatedParagraph.addEventListener('mouseup', (e) => {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
e.stopImmediatePropagation();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
translatedParagraph.addEventListener('dragstart', (e) =>{
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
});
|
|
179
|
+
pageTranslationLayer.append(translatedParagraph);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (paragraph.textContent.length !== 0) {
|
|
183
|
+
const pagePriority = parseFloat(pageIndex) + priority + pidx;
|
|
184
|
+
const translatedText = await this.translationManager.getTranslation(this.langFromCode, this.langToCode, pageIndex, pidx, paragraph.textContent, pagePriority);
|
|
185
|
+
// prevent duplicate spans from appearing if exists
|
|
186
|
+
translatedParagraph.firstElementChild?.remove();
|
|
187
|
+
|
|
188
|
+
const firstWordSpacing = paragraphs[pidx]?.firstChild?.firstChild;
|
|
189
|
+
const createSpan = document.createElement('span');
|
|
190
|
+
createSpan.className = 'BRlineElement';
|
|
191
|
+
createSpan.textContent = translatedText;
|
|
192
|
+
translatedParagraph.appendChild(createSpan);
|
|
193
|
+
|
|
194
|
+
$(createSpan).css({
|
|
195
|
+
"text-indent": $(firstWordSpacing).css('padding-left'),
|
|
196
|
+
});
|
|
197
|
+
if (page.classList.contains('BRpage-visible')) {
|
|
198
|
+
this.fitVisiblePage(translatedParagraph);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
clearAllTranslations() {
|
|
205
|
+
document.querySelectorAll('.BRtranslateLayer').forEach(el => el.remove());
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {Element} paragEl
|
|
210
|
+
*/
|
|
211
|
+
fitVisiblePage(paragEl) {
|
|
212
|
+
// For some reason, Chrome does not detect the transform property for the translation + text layers
|
|
213
|
+
// Could not get it to fetch the transform value using $().css method
|
|
214
|
+
// Oddly enough the value is retrieved if using .style.transform instead?
|
|
215
|
+
const translateLayerEl = paragEl.parentElement;
|
|
216
|
+
if ($(translateLayerEl).css('transform') == 'none') {
|
|
217
|
+
const pageNumber = paragEl.getAttribute('data-translate-index').split('-')[0];
|
|
218
|
+
/** @type {HTMLElement} selectionTransform */
|
|
219
|
+
const textLayerEl = document.querySelector(`[data-index='${pageNumber}'] .BRtextLayer`);
|
|
220
|
+
$(translateLayerEl).css({'transform': textLayerEl.style.transform});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const originalFontSize = parseInt($(paragEl).css("font-size"));
|
|
224
|
+
let adjustedFontSize = originalFontSize;
|
|
225
|
+
while (paragEl.clientHeight < paragEl.scrollHeight && adjustedFontSize > 0) {
|
|
226
|
+
adjustedFontSize--;
|
|
227
|
+
$(paragEl).css({ "font-size": `${adjustedFontSize}px` });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const textHeight = paragEl.firstElementChild.clientHeight;
|
|
231
|
+
const scrollHeight = paragEl.scrollHeight;
|
|
232
|
+
const fits = textHeight < scrollHeight;
|
|
233
|
+
if (fits) {
|
|
234
|
+
const lines = textHeight / adjustedFontSize;
|
|
235
|
+
// Line heights for smaller paragraphs occasionally need a minor adjustment
|
|
236
|
+
const newLineHeight = scrollHeight / lines;
|
|
237
|
+
$(paragEl).css({
|
|
238
|
+
"line-height" : `${newLineHeight}px`,
|
|
239
|
+
"overflow": "visible",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
handleFromLangChange = async (e) => {
|
|
245
|
+
this.clearAllTranslations();
|
|
246
|
+
const selectedLangFrom = e.detail.value;
|
|
247
|
+
|
|
248
|
+
// Update the from language
|
|
249
|
+
this.langFromCode = selectedLangFrom;
|
|
250
|
+
|
|
251
|
+
// Add 'From' language to 'To' list if not already present
|
|
252
|
+
if (!this.translationManager.toLanguages.some(lang => lang.code === selectedLangFrom)) {
|
|
253
|
+
this.translationManager.toLanguages.push({ code: selectedLangFrom, name: langs[selectedLangFrom] });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Update the 'To' languages list and set the correct 'To' language
|
|
257
|
+
this._panel.toLanguages = this.translationManager.toLanguages;
|
|
258
|
+
|
|
259
|
+
console.log(this.langFromCode, this.langToCode);
|
|
260
|
+
if (this.langFromCode !== this.langToCode) {
|
|
261
|
+
this.translateActivePageContainerElements();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
handleToLangChange = async (e) => {
|
|
266
|
+
this.clearAllTranslations();
|
|
267
|
+
this.langToCode = e.detail.value;
|
|
268
|
+
this.translateActivePageContainerElements();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Update the table of contents based on array of TOC entries.
|
|
274
|
+
*/
|
|
275
|
+
_render() {
|
|
276
|
+
this.br.shell.menuProviders['translate'] = {
|
|
277
|
+
id: 'translate',
|
|
278
|
+
icon: html`<img src='${this.br.options.imagesBaseURL}/translate.svg' width="26"/>`,
|
|
279
|
+
label: 'Translate',
|
|
280
|
+
component: html`<br-translate-panel
|
|
281
|
+
@connected="${e => {
|
|
282
|
+
this._panel = e.target;
|
|
283
|
+
this._panel.fromLanguages = this.translationManager.fromLanguages;
|
|
284
|
+
this._panel.toLanguages = this.translationManager.toLanguages;
|
|
285
|
+
}
|
|
286
|
+
}"
|
|
287
|
+
@langFromChanged="${this.handleFromLangChange}"
|
|
288
|
+
@langToChanged="${this.handleToLangChange}"
|
|
289
|
+
.fromLanguages="${this.translationManager.fromLanguages}"
|
|
290
|
+
.toLanguages="${this.translationManager.toLanguages}"
|
|
291
|
+
.disclaimerMessage="${this.options.panelDisclaimerText}"
|
|
292
|
+
class="translate-panel"
|
|
293
|
+
/>`,
|
|
294
|
+
};
|
|
295
|
+
this.br.shell.updateMenuContents();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
BookReader?.registerPlugin('translate', TranslatePlugin);
|
|
299
|
+
|
|
300
|
+
@customElement('br-translate-panel')
|
|
301
|
+
export class BrTranslatePanel extends LitElement {
|
|
302
|
+
@property({ type: Array }) fromLanguages = []; // List of obj {code, name}
|
|
303
|
+
@property({ type: Array }) toLanguages = []; // List of obj {code, name}
|
|
304
|
+
@property({ type: String }) prevSelectedLang = ''; // Tracks the previous selected language for the "To" dropdown
|
|
305
|
+
@property({ type: String }) disclaimerMessage = '';
|
|
306
|
+
|
|
307
|
+
/** @override */
|
|
308
|
+
createRenderRoot() {
|
|
309
|
+
// Disable shadow DOM; that would require a huge rejiggering of CSS
|
|
310
|
+
return this;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
connectedCallback() {
|
|
314
|
+
super.connectedCallback();
|
|
315
|
+
this.dispatchEvent(new CustomEvent('connected'));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
render() {
|
|
319
|
+
const showPrevLangButton =
|
|
320
|
+
this.prevSelectedLang &&
|
|
321
|
+
(this.prevSelectedLang !== this._getSelectedLang('to') || this._getSelectedLang('from') === this._getSelectedLang('to'));
|
|
322
|
+
|
|
323
|
+
return html`<div class="app">
|
|
324
|
+
<div class="panel panel--from">
|
|
325
|
+
<label>
|
|
326
|
+
From
|
|
327
|
+
<select id="lang-from" name="from" class="lang-select" @change="${this._onLangFromChange}">
|
|
328
|
+
${this.fromLanguages.map(
|
|
329
|
+
lang => html`<option value="${lang.code}">${lang.name}</option>`,
|
|
330
|
+
)}
|
|
331
|
+
</select>
|
|
332
|
+
</label>
|
|
333
|
+
</div>
|
|
334
|
+
<div class="panel panel--to">
|
|
335
|
+
<label>
|
|
336
|
+
To
|
|
337
|
+
<select id="lang-to" name="to" class="lang-select" @change="${this._onLangToChange}">
|
|
338
|
+
${this.toLanguages.map(
|
|
339
|
+
lang => html`<option value="${lang.code}">${lang.name}</option>`,
|
|
340
|
+
)}
|
|
341
|
+
</select>
|
|
342
|
+
</label>
|
|
343
|
+
${showPrevLangButton
|
|
344
|
+
? html`<button class="prev-lang-btn" @click="${this._onPrevLangClick}">
|
|
345
|
+
${this._getLangName(this.prevSelectedLang)}
|
|
346
|
+
</button>`
|
|
347
|
+
: ''}
|
|
348
|
+
</div>
|
|
349
|
+
<div class="footer" id="status"></div>
|
|
350
|
+
<br/>
|
|
351
|
+
<div class="disclaimer" id="disclaimerMessage"> ${this.disclaimerMessage} </div>
|
|
352
|
+
</div>`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_onLangFromChange(event) {
|
|
356
|
+
const langFromChangedEvent = new CustomEvent('langFromChanged', {
|
|
357
|
+
detail: { value: event.target.value },
|
|
358
|
+
bubbles: true,
|
|
359
|
+
composed: true,
|
|
360
|
+
});
|
|
361
|
+
this.dispatchEvent(langFromChangedEvent);
|
|
362
|
+
|
|
363
|
+
// Update the prevSelectedLang if "To" is different from "From"
|
|
364
|
+
if (this._getSelectedLang('to') !== this._getSelectedLang('from')) {
|
|
365
|
+
this.prevSelectedLang = this._getSelectedLang('from');
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_onLangToChange(event) {
|
|
370
|
+
const langToChangedEvent = new CustomEvent('langToChanged', {
|
|
371
|
+
detail: { value: event.target.value },
|
|
372
|
+
bubbles: true,
|
|
373
|
+
composed: true,
|
|
374
|
+
});
|
|
375
|
+
this.dispatchEvent(langToChangedEvent);
|
|
376
|
+
|
|
377
|
+
// Update the prevSelectedLang if "To" is different from "From"
|
|
378
|
+
if (this._getSelectedLang('from') !== event.target.value) {
|
|
379
|
+
this.prevSelectedLang = this._getSelectedLang('from');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
_onPrevLangClick() {
|
|
384
|
+
const prevLang = this.prevSelectedLang;
|
|
385
|
+
if (prevLang == this._getSelectedLang('from')) {
|
|
386
|
+
console.log("_onPrevLangClick: will not change since prevLang is the same as the current 'To' language code");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
this.prevSelectedLang = this._getSelectedLang('to'); // Update prevSelectedLang to current "To" value
|
|
390
|
+
const langToChangedEvent = new CustomEvent('langToChanged', {
|
|
391
|
+
detail: { value: prevLang },
|
|
392
|
+
bubbles: true,
|
|
393
|
+
composed: true,
|
|
394
|
+
});
|
|
395
|
+
this.dispatchEvent(langToChangedEvent);
|
|
396
|
+
|
|
397
|
+
// Update the "To" dropdown to the previous language
|
|
398
|
+
const toDropdown = this.querySelector('#lang-to');
|
|
399
|
+
if (toDropdown) {
|
|
400
|
+
toDropdown.value = prevLang;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_getSelectedLang(type) {
|
|
405
|
+
const dropdown = this.querySelector(`#lang-${type}`);
|
|
406
|
+
return dropdown ? dropdown.value : '';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
_getLangName(code) {
|
|
410
|
+
const lang = [...this.fromLanguages, ...this.toLanguages].find(lang => lang.code === code);
|
|
411
|
+
return lang ? lang.name : '';
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
package/src/plugins/tts/utils.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import langs from 'iso-language-codes
|
|
1
|
+
import langs from 'iso-language-codes';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Use regex to approximate word count in a string
|
|
@@ -24,15 +24,6 @@ export function isAndroid(userAgent = navigator.userAgent) {
|
|
|
24
24
|
* Language code in ISO 639-1 format. e.g. en, fr, zh
|
|
25
25
|
**/
|
|
26
26
|
|
|
27
|
-
/** Each lang is an array, with each index mapping to a different property */
|
|
28
|
-
const COLUMN_TO_LANG_INDEX = {
|
|
29
|
-
'Name': 0,
|
|
30
|
-
'Endonym': 1,
|
|
31
|
-
'ISO 639-1': 2,
|
|
32
|
-
'ISO 639-2/T': 3,
|
|
33
|
-
'ISO 639-2/B': 4,
|
|
34
|
-
};
|
|
35
|
-
|
|
36
27
|
/**
|
|
37
28
|
* @param {string} language in some format
|
|
38
29
|
* @return {ISO6391?}
|
|
@@ -41,24 +32,22 @@ export function toISO6391(language) {
|
|
|
41
32
|
if (!language) return null;
|
|
42
33
|
language = language.toLowerCase();
|
|
43
34
|
|
|
44
|
-
return searchForISO6391(language, ['
|
|
45
|
-
searchForISO6391(language, ['
|
|
46
|
-
searchForISO6391(language, ['
|
|
35
|
+
return searchForISO6391(language, ['iso639_1']) ||
|
|
36
|
+
searchForISO6391(language, ['iso639_2T']) ||
|
|
37
|
+
searchForISO6391(language, ['iso639_2B', 'nativeName', 'name']);
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
/**
|
|
50
41
|
* Searches for the given long in the given columns.
|
|
51
42
|
* @param {string} language
|
|
52
|
-
* @param {Array<keyof
|
|
43
|
+
* @param {Array<keyof import('iso-language-codes').Code>} columnsToSearch
|
|
53
44
|
* @return {ISO6391?}
|
|
54
45
|
*/
|
|
55
46
|
function searchForISO6391(language, columnsToSearch) {
|
|
56
|
-
for (
|
|
57
|
-
for (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (columnValue.split(', ').map(x => x.toLowerCase()).indexOf(language) != -1) {
|
|
61
|
-
return langs[i][COLUMN_TO_LANG_INDEX['ISO 639-1']];
|
|
47
|
+
for (const lang of langs) {
|
|
48
|
+
for (const colName of columnsToSearch) {
|
|
49
|
+
if (lang[colName].split(', ').map(x => x.toLowerCase()).indexOf(language) != -1) {
|
|
50
|
+
return lang.iso639_1;
|
|
62
51
|
}
|
|
63
52
|
}
|
|
64
53
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @template T
|
|
3
|
+
*/
|
|
4
|
+
export class Cache {
|
|
5
|
+
constructor(maxSize = 10) {
|
|
6
|
+
this.maxSize = maxSize;
|
|
7
|
+
/** @type {T[]} */
|
|
8
|
+
this.entries = [];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {T} entry
|
|
13
|
+
*/
|
|
14
|
+
add(entry) {
|
|
15
|
+
if (this.entries.length >= this.maxSize) {
|
|
16
|
+
this.entries.shift();
|
|
17
|
+
}
|
|
18
|
+
this.entries.push(entry);
|
|
19
|
+
}
|
|
20
|
+
}
|