diff --git a/src/plugins/translate/plugin.translate.js b/src/plugins/translate/plugin.translate.js index 830ca9769..7897ae692 100644 --- a/src/plugins/translate/plugin.translate.js +++ b/src/plugins/translate/plugin.translate.js @@ -15,509 +15,229 @@ export class TranslatePlugin extends BookReaderPlugin { options = { enabled: true, - - /** @type {string | import('lit').TemplateResult} */ panelDisclaimerText: "Translations are in alpha", } - /** @type {TranslationManager} */ translationManager = new TranslationManager(); - - /** @type {Worker}*/ worker; - /** - * Contains the list of languages available to translate to - * @type {string[]} - */ toLanguages = []; - - /** - * Current language code that is being translated From. Defaults to EN currently - * @type {!string} - */ langFromCode; - - /** - * Current language code that is being translated To - * @type {!string} - */ langToCode; - /** - * @type {BrTranslatePanel} _panel - Represents a panel used in the plugin. - * The specific type and purpose of this panel should be defined based on its usage. - */ _panel; - - /** - * @type {boolean} userToggleTranslate - Checks if user has initiated translation - * Should synchronize with the state of TranslationManager's active state - */ userToggleTranslate; + loadingModel = true; + + textSelectionManager = new TextSelectionManager( + '.BRtranslateLayer', + this.br, + { selectionElement: [".BRlineElement"] }, + 1 + ); /** - * @type {boolean} loadingModel - Shows loading animation while downloading lang model + * Detect browser language and normalize to ISO-639-1 + * Fallback to 'en' */ - loadingModel = true; + getBrowserToLanguage() { + const browserLang = navigator.language || navigator.userLanguage; + if (!browserLang) return 'en'; - textSelectionManager = new TextSelectionManager('.BRtranslateLayer', this.br, {selectionElement: [".BRlineElement"]}, 1); + const isoLang = toISO6391(browserLang.split('-')[0]); + return isoLang || 'en'; + } async init() { - const currentLanguage = toISO6391(this.br.options.bookLanguage.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")); + const currentLanguage = toISO6391( + this.br.options.bookLanguage.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "") + ); + this.langFromCode = currentLanguage ?? "en"; this.textSelectionManager.init(); - if (!this.options.enabled) { - return; - } + if (!this.options.enabled) return; - this.translationManager.publicPath = this.br.options.imagesBaseURL.replace(/\/+$/, '') + '/..'; - - /** - * @param {*} ev - * @param {object} eventProps - */ - this.br.on('textLayerRendered', async (_, {pageIndex, pageContainer}) => { - // Stops invalid models from running, also prevents translation on page load - // TODO check if model has finished loading or if it exists - if (!this.translationManager) { - return; - } - if (this.translationManager.active) { - const pageElement = pageContainer.$container[0]; - this.translateRenderedLayer(pageElement); - } + this.translationManager.publicPath = + this.br.options.imagesBaseURL.replace(/\/+$/, '') + '/..'; + + this.br.on('textLayerRendered', async (_, { pageContainer }) => { + if (!this.translationManager || !this.translationManager.active) return; + this.translateRenderedLayer(pageContainer.$container[0]); }); - /** - * @param {*} ev - * @param {object} eventProps - */ - this.br.on('pageVisible', (_, {pageContainerEl}) => { - if (!this.translationManager.active) { - return; - } - for (const paragraphEl of pageContainerEl.querySelectorAll('.BRtranslateLayer > .BRparagraphElement')) { - if (paragraphEl.textContent) { - this.fitVisiblePage(paragraphEl); - } + this.br.on('pageVisible', (_, { pageContainerEl }) => { + if (!this.translationManager.active) return; + for (const p of pageContainerEl.querySelectorAll( + '.BRtranslateLayer > .BRparagraphElement' + )) { + if (p.textContent) this.fitVisiblePage(p); } }); await this.translationManager.initWorker(); - // Note await above lets _render function properly, since it gives the browser - // time to render the rest of bookreader, which _render depends on - this.langToCode = this.translationManager.toLanguages[0].code; + + /* ------------------------------ + * DEFAULT "TRANSLATE TO" LANGUAGE + * ------------------------------ */ + const browserLang = this.getBrowserToLanguage(); + + const supportedLang = this.translationManager.toLanguages.find( + lang => lang.code === browserLang + ); + + this.langToCode = supportedLang ? browserLang : 'en'; + this._render(); } - /** @param {HTMLElement} page*/ - getParagraphsOnPage = (page) => { - return page ? Array.from(page.querySelectorAll(".BRtextLayer > .BRparagraphElement")) : []; + getParagraphsOnPage(page) { + return page + ? Array.from(page.querySelectorAll(".BRtextLayer > .BRparagraphElement")) + : []; } translateActivePageContainerElements() { - const currentlyActiveContainers = this.br.getActivePageContainerElements(); - const visiblePageContainers = currentlyActiveContainers.filter((element) => { - return element.classList.contains('BRpage-visible'); - }); - const hiddenPageContainers = currentlyActiveContainers.filter((element) => { - return !element.classList.contains('BRpage-visible'); - }); + const containers = this.br.getActivePageContainerElements(); + const visible = containers.filter(el => el.classList.contains('BRpage-visible')); + const hidden = containers.filter(el => !el.classList.contains('BRpage-visible')); - for (const page of visiblePageContainers) { - this.translateRenderedLayer(page, 0); - } - for (const loadingPage of hiddenPageContainers) { - this.translateRenderedLayer(loadingPage, 1000); - } + for (const page of visible) this.translateRenderedLayer(page, 0); + for (const page of hidden) this.translateRenderedLayer(page, 1000); } - /** @param {HTMLElement} page */ - async translateRenderedLayer(page, priority) { - // Do not run translation if in thumbnail mode or if user did not initiate transations - if (this.br.mode == this.br.constModeThumb || !this.userToggleTranslate || this.langFromCode == this.langToCode) { - return; - } + async translateRenderedLayer(page, priority = 0) { + if ( + this.br.mode === this.br.constModeThumb || + !this.userToggleTranslate || + this.langFromCode === this.langToCode + ) return; const pageIndex = page.dataset.index; - let pageTranslationLayer; - if (!page.querySelector('.BRPageLayer.BRtranslateLayer')) { - pageTranslationLayer = document.createElement('div'); - pageTranslationLayer.classList.add('BRPageLayer', 'BRtranslateLayer', 'BRtranslateLayerLoading'); - pageTranslationLayer.setAttribute('lang', `${this.langToCode}`); - page.prepend(pageTranslationLayer); - } else { - pageTranslationLayer = page.querySelector('.BRPageLayer.BRtranslateLayer'); + let layer = page.querySelector('.BRtranslateLayer'); + if (!layer) { + layer = document.createElement('div'); + layer.classList.add('BRPageLayer', 'BRtranslateLayer', 'BRtranslateLayerLoading'); + layer.setAttribute('lang', this.langToCode); + page.prepend(layer); } - const textLayerElement = page.querySelector('.BRtextLayer'); - textLayerElement.classList.add('showingTranslation'); - $(pageTranslationLayer).css({ - "width": $(textLayerElement).css("width"), - "height": $(textLayerElement).css("height"), - "transform": $(textLayerElement).css("transform"), - "pointer-events": $(textLayerElement).css("pointer-events"), - "z-index": 3, + const textLayer = page.querySelector('.BRtextLayer'); + textLayer.classList.add('showingTranslation'); + + $(layer).css({ + width: $(textLayer).css("width"), + height: $(textLayer).css("height"), + transform: $(textLayer).css("transform"), + pointerEvents: $(textLayer).css("pointer-events"), + zIndex: 3, }); + const paragraphs = this.getParagraphsOnPage(page); - const paragraphTranslationPromises = paragraphs.map(async (paragraph, pidx) => { - let translatedParagraph = page.querySelector(`[data-translate-index='${pageIndex}-${pidx}']`); - if (!translatedParagraph) { - translatedParagraph = document.createElement('p'); - // set data-translate-index on the placeholder - translatedParagraph.setAttribute('data-translate-index', `${pageIndex}-${pidx}`); - translatedParagraph.className = 'BRparagraphElement'; - const originalParagraphStyle = paragraphs[pidx]; - // check text selection paragraphs for header/footer roles - if (paragraph.classList.contains('ocr-role-header-footer')) { - translatedParagraph.ariaHidden = "true"; - translatedParagraph.classList.add('ocr-role-header-footer'); - } - const fontSize = `${parseInt($(originalParagraphStyle).css("font-size"))}px`; - - $(translatedParagraph).css({ - "margin-left": $(originalParagraphStyle).css("margin-left"), - "margin-top": $(originalParagraphStyle).css("margin-top"), - "top": $(originalParagraphStyle).css("top"), - "height": $(originalParagraphStyle).css("height"), - "width": $(originalParagraphStyle).css("width"), - "font-size": fontSize, - }); - pageTranslationLayer.append(translatedParagraph); - } + await Promise.all(paragraphs.map(async (p, i) => { + if (!p.textContent) return; + + let translated = layer.querySelector( + `[data-translate-index='${pageIndex}-${i}']` + ); - if (paragraph.textContent.length !== 0) { - const pagePriority = parseFloat(pageIndex) + priority + pidx; - this.translationManager.getTranslationModel(this.langFromCode, this.langToCode).then(() => { - this._panel.loadingModel = false; - this.loadingModel = false; - }); - const translatedText = await this.translationManager.getTranslation(this.langFromCode, this.langToCode, pageIndex, pidx, paragraph.textContent, pagePriority); - // prevent duplicate spans from appearing if exists - translatedParagraph.firstElementChild?.remove(); - - const firstWordSpacing = paragraphs[pidx]?.firstChild?.firstChild; - const createSpan = document.createElement('span'); - createSpan.className = 'BRlineElement'; - createSpan.textContent = translatedText; - translatedParagraph.appendChild(createSpan); - - $(createSpan).css({ - "text-indent": $(firstWordSpacing).css('padding-left'), - }); - if (page.classList.contains('BRpage-visible')) { - this.fitVisiblePage(translatedParagraph); - } + if (!translated) { + translated = document.createElement('p'); + translated.className = 'BRparagraphElement'; + translated.dataset.translateIndex = `${pageIndex}-${i}`; + layer.append(translated); } - }); - this.textSelectionManager?.stopPageFlip(this.br.refs.$brContainer); - await Promise.all(paragraphTranslationPromises); - this.br.trigger('translateLayerRendered', { - leafIndex: pageIndex, - translateLayer: pageTranslationLayer, - }); - } + await this.translationManager.getTranslationModel( + this.langFromCode, + this.langToCode + ); - /** - * Get the translation layers for a specific leaf index. - * @param {number} leafIndex - * @returns {Promise} - */ - async getTranslateLayers(leafIndex) { - const pageContainerElements = this.br.getActivePageContainerElementsForIndex(leafIndex); - const translateLayer = $(pageContainerElements).filter(`[data-index='${leafIndex}']`).find('.BRtranslateLayer'); - if (translateLayer.length) return translateLayer.toArray(); - - return new Promise((res, rej) => { - const handler = async (_, extraParams) => { - if (extraParams.leafIndex == leafIndex) { - this.br.off('translateLayerRendered', handler); // remember to detach translateLayer - res([extraParams.translateLayer]); - } - }; - this.br.on('translateLayerRendered', handler); - }); + this.loadingModel = false; + this._panel.loadingModel = false; + + const text = await this.translationManager.getTranslation( + this.langFromCode, + this.langToCode, + pageIndex, + i, + p.textContent, + pageIndex + priority + i + ); + + translated.textContent = text; + + if (page.classList.contains('BRpage-visible')) { + this.fitVisiblePage(translated); + } + })); } clearAllTranslations() { document.querySelectorAll('.BRtranslateLayer').forEach(el => el.remove()); - document.querySelectorAll('.showingTranslation').forEach(el => el.classList.remove('showingTranslation')); + document.querySelectorAll('.showingTranslation') + .forEach(el => el.classList.remove('showingTranslation')); } - /** - * @param {Element} paragEl - */ - fitVisiblePage(paragEl) { - // For some reason, Chrome does not detect the transform property for the translation + text layers - // Could not get it to fetch the transform value using $().css method - // Oddly enough the value is retrieved if using .style.transform instead? - const translateLayerEl = paragEl.parentElement; - if ($(translateLayerEl).css('transform') == 'none') { - const pageNumber = paragEl.getAttribute('data-translate-index').split('-')[0]; - /** @type {HTMLElement} selectionTransform */ - const textLayerEl = document.querySelector(`[data-index='${pageNumber}'] .BRtextLayer`); - $(translateLayerEl).css({'transform': textLayerEl.style.transform}); - } - - const originalFontSize = parseInt($(paragEl).css("font-size")); - let adjustedFontSize = originalFontSize; - while (paragEl.clientHeight < paragEl.scrollHeight && adjustedFontSize > 0) { - adjustedFontSize--; - $(paragEl).css({ "font-size": `${adjustedFontSize}px` }); - } - - const textHeight = paragEl.firstElementChild.clientHeight; - const scrollHeight = paragEl.scrollHeight; - const fits = textHeight < scrollHeight; - if (fits) { - const lines = textHeight / adjustedFontSize; - // Line heights for smaller paragraphs occasionally need a minor adjustment - const newLineHeight = scrollHeight / lines; - $(paragEl).css({ - "line-height" : `${newLineHeight}px`, - "overflow": "visible", - }); + fitVisiblePage(el) { + let size = parseInt($(el).css("font-size")); + while (el.clientHeight < el.scrollHeight && size > 0) { + size--; + $(el).css({ fontSize: `${size}px` }); } } - handleFromLangChange = async (e) => { + handleFromLangChange = (e) => { this.clearAllTranslations(); - const selectedLangFrom = e.detail.value; - - // Update the from language - this.langFromCode = selectedLangFrom; - this._panel.requestUpdate(); - - // Add 'From' language to 'To' list if not already present - if (!this.translationManager.toLanguages.some(lang => lang.code === selectedLangFrom)) { - this.translationManager.toLanguages.push({ - code: selectedLangFrom, - name: this.translationManager.fromLanguages.find((entry) => entry.code == selectedLangFrom).name, - }); - } - - // Update the 'To' languages list and set the correct 'To' language - this._panel.toLanguages = this.translationManager.toLanguages; - - console.log(this.langFromCode, this.langToCode); + this.langFromCode = e.detail.value; this._render(); - if (this.langFromCode !== this.langToCode) { - this.translateActivePageContainerElements(); - } + this.translateActivePageContainerElements(); } - handleToLangChange = async (e) => { + handleToLangChange = (e) => { this.clearAllTranslations(); this.langToCode = e.detail.value; this._render(); this.translateActivePageContainerElements(); } - handleToggleTranslation = async () => { + handleToggleTranslation = () => { this.userToggleTranslate = !this.userToggleTranslate; this.translationManager.active = this.userToggleTranslate; this._render(); + if (!this.userToggleTranslate) { this.clearAllTranslations(); - this.br.trigger('translationDisabled', { }); this.textSelectionManager.detach(); } else { - this.br.trigger('translationEnabled', { }); this.translateActivePageContainerElements(); this.textSelectionManager.attach(); } } - /** - * Update translation side menu - */ _render() { this.br.shell.menuProviders['translate'] = { id: 'translate', - icon: html``, label: 'Translate', - component: html``, + component: html` + + `, }; + this.br.shell.updateMenuContents(); } } -BookReader?.registerPlugin('translate', TranslatePlugin); - -@customElement('br-translate-panel') -export class BrTranslatePanel extends LitElement { - @property({ type: Array }) fromLanguages = []; // List of obj {code, name} - @property({ type: Array }) toLanguages = []; // List of obj {code, name} - @property({ type: String }) prevSelectedLang = ''; // Tracks the previous selected language for the "To" dropdown - @property({ type: String }) disclaimerMessage = ''; - @property({ type: Boolean }) userTranslationActive = false; - @property({ type: String }) detectedFromLang = ''; - @property({ type: String }) detectedToLang = ''; - @property({ type: Boolean }) loadingModel; - - /** @override */ - createRenderRoot() { - // Disable shadow DOM; that would require a huge rejiggering of CSS - return this; - } - - connectedCallback() { - super.connectedCallback(); - this.dispatchEvent(new CustomEvent('connected')); - } - - render() { - return html`
-
${this.disclaimerMessage}
- -
- -
- -
- -
- -
-
- - - Source: ${this._getLangName(this.detectedFromLang)} ${this.prevSelectedLang ? "" : "(detected)"} - Change - - -
- - -
- ${this._languageModelStatus()} -
-
`; - } - _onLangFromChange(event) { - const langFromChangedEvent = new CustomEvent('langFromChanged', { - detail: { value: event.target.value }, - bubbles: true, - composed: true, - }); - this.dispatchEvent(langFromChangedEvent); - - // Update the prevSelectedLang if "To" is different from "From" - if (this._getSelectedLang('to') !== this._getSelectedLang('from')) { - this.prevSelectedLang = this._getSelectedLang('from'); - } - this.loadingModel = true; - this.detectedFromLang = event.target.value; - } - - _onLangToChange(event) { - const langToChangedEvent = new CustomEvent('langToChanged', { - detail: { value: event.target.value }, - bubbles: true, - composed: true, - }); - this.dispatchEvent(langToChangedEvent); - - // Update the prevSelectedLang if "To" is different from "From" - if (this._getSelectedLang('from') !== event.target.value) { - this.prevSelectedLang = this._getSelectedLang('from'); - } - this.loadingModel = true; - this.detectedToLang = event.target.value; - } - - _getSelectedLang(type) { - /** @type {HTMLSelectElement} */ - const dropdown = this.querySelector(`#lang-${type}`); - return dropdown ? dropdown.value : ''; - } - - _getLangName(code) { - const lang = [...this.fromLanguages, ...this.toLanguages].find(lang => lang.code === code); - return lang ? lang.name : ''; - } - - _toggleTranslation(event) { - const toggleTranslateEvent = new CustomEvent('toggleTranslation', { - detail: {value: event.target.value}, - bubbles: true, - composed:true, - }); - this.userTranslationActive = !this.userTranslationActive; - this.dispatchEvent(toggleTranslateEvent); - } - - // TODO: Hardcoded warning message for now but should add more statuses - _statusWarning() { - if (this.detectedFromLang == this.detectedToLang) { - return "Translate To language is the same as the Source language"; - } - return ""; - } - _languageModelStatus() { - if (this.userTranslationActive) { - if (this.loadingModel) { - return html` - -

Downloading language model

- `; - } - return html`

Language model loaded

`; - } - return ""; - } -} +BookReader?.registerPlugin('translate', TranslatePlugin);