diff --git a/src/plugins/translate/plugin.translate.js b/src/plugins/translate/plugin.translate.js
index 5037c0596..1676bd612 100644
--- a/src/plugins/translate/plugin.translate.js
+++ b/src/plugins/translate/plugin.translate.js
@@ -3,7 +3,7 @@ import { css, html, LitElement } from 'lit';
import { BookReaderPlugin } from '../../BookReaderPlugin.js';
import { customElement, property, query } from 'lit/decorators.js';
import { TranslationManager } from "./TranslationManager.js";
-import { toISO6391, toNativeName } from '../tts/utils.js';
+import { hasLocalStorage, toISO6391, toNativeName } from '../tts/utils.js';
import { sortBy } from '../../../src/BookReader/utils.js';
import { TextSelectionManager } from '../../../src/util/TextSelectionManager.js';
import '@internetarchive/ia-activity-indicator';
@@ -11,6 +11,28 @@ import '@internetarchive/ia-activity-indicator';
// @ts-ignore
const BookReader = /** @type {typeof import('@/src/BookReader.js').default} */(window.BookReader);
+const RECENT_LANGUAGES_KEY = 'BrTranslateRecentLanguages';
+const MAX_RECENT_LANGUAGES = 3;
+
+/** @returns {string[]} language codes, most recent first */
+export function loadRecentLanguages() {
+ try {
+ const codes = hasLocalStorage() && JSON.parse(localStorage.getItem(RECENT_LANGUAGES_KEY));
+ return Array.isArray(codes) ? codes : [];
+ } catch {
+ return [];
+ }
+}
+
+/** @param {string[]} codes */
+function saveRecentLanguages(codes) {
+ try {
+ if (hasLocalStorage()) localStorage.setItem(RECENT_LANGUAGES_KEY, JSON.stringify(codes));
+ } catch {
+ // Storage full or blocked; recent languages just won't persist
+ }
+}
+
export class TranslatePlugin extends BookReaderPlugin {
options = {
@@ -105,10 +127,27 @@ export class TranslatePlugin extends BookReaderPlugin {
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;
+ this.langToCode = this.getDefaultToLanguage();
this._render();
}
+ /**
+ * The most recently used "To" language, else the first of the browser's languages
+ * we have a model for, else the first available language (English)
+ * @returns {string}
+ */
+ getDefaultToLanguage() {
+ const available = this.translationManager.toLanguages.map(lang => lang.code);
+ const baseLanguage = (code) => code.split('-')[0].toLowerCase();
+ const candidates = [...loadRecentLanguages(), ...(navigator.languages || [navigator.language])];
+ for (const candidate of candidates) {
+ const match = available.find(code => code.toLowerCase() === candidate.toLowerCase())
+ || available.find(code => baseLanguage(code) === baseLanguage(candidate));
+ if (match) return match;
+ }
+ return available[0];
+ }
+
/** @param {HTMLElement} page*/
getParagraphsOnPage = (page) => {
return page ? Array.from(page.querySelectorAll(".BRtextLayer > .BRparagraphElement")) : [];
@@ -383,6 +422,7 @@ export class BrTranslatePanel extends LitElement {
@property({ type: String }) detectedToLang = '';
@property({ type: Boolean }) loadingModel;
@property({ type: Boolean }) sourceLanguageSupported = true;
+ @property({ type: Array }) recentToLanguages = loadRecentLanguages(); // Codes of recently chosen "To" languages, most recent first
@query('#lang-from') langFromDropdown;
@query('#lang-to') langToDropdown;
@@ -422,13 +462,7 @@ export class BrTranslatePanel extends LitElement {
@@ -448,11 +482,11 @@ export class BrTranslatePanel extends LitElement {
@@ -500,6 +534,36 @@ export class BrTranslatePanel extends LitElement {
}
this.loadingModel = true;
this.detectedToLang = event.target.value;
+
+ this.recentToLanguages = [event.target.value, ...this.recentToLanguages.filter(code => code !== event.target.value)]
+ .slice(0, MAX_RECENT_LANGUAGES);
+ saveRecentLanguages(this.recentToLanguages);
+ }
+
+ /**
+ * "To" language options, with recently used languages in their own group at the top
+ */
+ _renderToLanguageOptions() {
+ const option = (lang, selected) => html``;
+ const allLanguages = sortBy(this.toLanguages, (lang) => lang.name.toLowerCase());
+ const recentLanguages = this.recentToLanguages
+ .map(code => this.toLanguages.find(lang => lang.code === code))
+ .filter(Boolean);
+
+ if (!recentLanguages.length) {
+ return allLanguages.map(lang => option(lang, lang.code == this.detectedToLang));
+ }
+
+ // Recent languages also stay in the full list, but are only marked selected in the "Recent" group
+ const selectedIsRecent = recentLanguages.some(lang => lang.code == this.detectedToLang);
+ return html`
+
+
+ `;
}
_getSelectedLang(type) {
diff --git a/tests/jest/plugins/translate/plugin.translate.test.js b/tests/jest/plugins/translate/plugin.translate.test.js
index 81dbd5159..3121bba35 100644
--- a/tests/jest/plugins/translate/plugin.translate.test.js
+++ b/tests/jest/plugins/translate/plugin.translate.test.js
@@ -4,7 +4,8 @@ jest.mock('@internetarchive/bergamot-translator/translator.js', () => ({
BatchTranslator: class {},
}));
-import { BrTranslatePanel } from '@/src/plugins/translate/plugin.translate.js';
+import { render } from 'lit';
+import { BrTranslatePanel, TranslatePlugin, loadRecentLanguages } from '@/src/plugins/translate/plugin.translate.js';
describe('BrTranslatePanel unsupported language warning', () => {
test('shows unsupported language warning message', () => {
@@ -15,3 +16,85 @@ describe('BrTranslatePanel unsupported language warning', () => {
expect(panel._statusWarning()).toBe('Translating from zzzz is not supported');
});
});
+
+describe('BrTranslatePanel recent languages', () => {
+ const toLanguages = [
+ {code: 'en', name: 'English (en)'},
+ {code: 'fr', name: 'français (fr)'},
+ {code: 'de', name: 'Deutsch (de)'},
+ {code: 'ko', name: '한국어 (ko)'},
+ ];
+
+ /** @param {BrTranslatePanel} panel */
+ const renderOptions = (panel) => {
+ const select = document.createElement('select');
+ render(panel._renderToLanguageOptions(), select);
+ return select;
+ };
+
+ beforeEach(() => localStorage.clear());
+
+ test('shows only the full list when there is no history', () => {
+ const panel = new BrTranslatePanel();
+ panel.toLanguages = toLanguages;
+ const select = renderOptions(panel);
+ expect(select.querySelector('optgroup')).toBeNull();
+ expect(select.options.length).toBe(4);
+ });
+
+ test('shows recent languages in their own group, most recent first', () => {
+ localStorage.setItem('BrTranslateRecentLanguages', JSON.stringify(['ko', 'xx', 'fr']));
+ const panel = new BrTranslatePanel();
+ panel.toLanguages = toLanguages;
+ panel.detectedToLang = 'fr';
+ const select = renderOptions(panel);
+ const [recent, all] = select.querySelectorAll('optgroup');
+ expect([...recent.children].map(o => o.value)).toEqual(['ko', 'fr']);
+ expect(all.children.length).toBe(4);
+ expect(select.value).toBe('fr');
+ expect(recent.querySelector('[value=fr]').selected).toBe(true);
+ });
+
+ test('remembers the chosen language, up to three', () => {
+ const panel = new BrTranslatePanel();
+ panel.recentToLanguages = ['de', 'fr', 'ko'];
+ panel._onLangToChange({target: {value: 'fr'}});
+ expect(panel.recentToLanguages).toEqual(['fr', 'de', 'ko']);
+ panel._onLangToChange({target: {value: 'en'}});
+ expect(panel.recentToLanguages).toEqual(['en', 'fr', 'de']);
+ expect(JSON.parse(localStorage.getItem('BrTranslateRecentLanguages'))).toEqual(['en', 'fr', 'de']);
+ });
+
+ test('ignores invalid stored data', () => {
+ localStorage.setItem('BrTranslateRecentLanguages', '{not json');
+ expect(loadRecentLanguages()).toEqual([]);
+ });
+});
+
+describe('TranslatePlugin.getDefaultToLanguage', () => {
+ const plugin = /** @type {any} */ ({
+ translationManager: {toLanguages: [{code: 'en'}, {code: 'fr'}, {code: 'zh-Hans'}]},
+ });
+ const getDefault = (browserLanguages) => {
+ jest.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLanguages);
+ return TranslatePlugin.prototype.getDefaultToLanguage.call(plugin);
+ };
+
+ beforeEach(() => localStorage.clear());
+ afterEach(() => jest.restoreAllMocks());
+
+ test('uses the browser language, matching by base language', () => {
+ expect(getDefault(['fr-CA', 'en'])).toBe('fr');
+ expect(getDefault(['zh-CN'])).toBe('zh-Hans');
+ });
+
+ test('skips browser languages without a model', () => {
+ expect(getDefault(['xx', 'fr'])).toBe('fr');
+ expect(getDefault(['xx'])).toBe('en');
+ });
+
+ test('prefers the most recently used language', () => {
+ localStorage.setItem('BrTranslateRecentLanguages', JSON.stringify(['zh-Hans']));
+ expect(getDefault(['fr'])).toBe('zh-Hans');
+ });
+});