Bug 1879933 - Add a button to change the translation direction in about:translations. r=translations-reviewers,fluent-reviewers,desktop-theme-reviewers,bolsson,nordzilla,dao

Add button. Disabled when the selected languages are equivalent.

Differential Revision: https://phabricator.services.mozilla.com/D217045
This commit is contained in:
Gabriel Lee 2024-09-05 18:24:08 +00:00
Родитель 8000b1bfad
Коммит 3de1b39518
9 изменённых файлов: 426 добавлений и 39 удалений

Просмотреть файл

@ -0,0 +1,8 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="context-fill" fill-opacity="context-fill-opacity">
<!-- This is a duplicate of browser/themes/shared/icons/import-export.svg for use in the about:translations page -->
<path d="M4.197 3.623a.624.624 0 0 1 1.247 0l.005 6.709 2.051 0a.5.5 0 0 1 .353.852L5.037 14l-.423 0-2.816-2.816a.5.5 0 0 1 .353-.852l2.051 0-.005-6.709z"/>
<path d="M11.812 12.377a.624.624 0 0 1-1.25 0l-.005-6.709-2.056 0a.5.5 0 0 1-.354-.852L10.97 2l.424 0 2.823 2.816a.499.499 0 0 1-.354.852l-2.056 0 .005 6.708z"/>
</svg>

После

Ширина:  |  Высота:  |  Размер: 809 B

Просмотреть файл

@ -34,21 +34,8 @@ body {
}
.about-translations-header {
display: flex;
}
.about-translations-header > * {
flex: 1;
display: flex;
max-width: 50%;
}
.about-translations-header-start {
justify-content: start;
}
.about-translations-header-end {
justify-content: end;
display: grid;
grid-template-columns: 1fr max-content 1fr;
}
/* Increase the selector specificity to override the base `select` styles. */
@ -57,10 +44,26 @@ select.about-translations-select {
padding-inline: 10px 20px;
padding-block: 0px;
min-width: 50%;
width: max-content;
margin: 5px;
background-position: right var(--AT-select-arrow-inset) center;
}
select#language-to {
justify-self: end;
}
.language-swap-icon {
width: 20px;
height: 20px;
background-image: url('chrome://global/content/translations/icons/swap-languages.svg');
background-size: contain;
margin: 0 auto;
-moz-context-properties: fill;
fill: currentColor;
transform: rotateY(180deg) rotateZ(90deg);
}
select.about-translations-select:dir(rtl) {
background-position-x: left var(--AT-select-arrow-inset);
}

Просмотреть файл

@ -22,22 +22,21 @@
<main class="about-translations-contents">
<header class="about-translations-header">
<div class="about-translations-header-start">
<select
class="about-translations-select"
id="language-from"
disabled>
<option data-l10n-id="about-translations-detect" value="detect"></option>
</select>
</div>
<div class="about-translations-header-end">
<select
class="about-translations-select"
id="language-to"
disabled>
<option data-l10n-id="about-translations-select" value=""></option>
</select>
</div>
<select
class="about-translations-select"
id="language-from"
disabled>
<option data-l10n-id="about-translations-detect" value="detect"></option>
</select>
<button id="language-swap" data-l10n-id="about-translations-swap-languages" disabled>
<div class="language-swap-icon"></div>
</button>
<select
class="about-translations-select"
id="language-to"
disabled>
<option data-l10n-id="about-translations-select" value=""></option>
</select>
</header>
<main class="about-translations-input">

Просмотреть файл

@ -351,6 +351,8 @@ class TranslationsUI {
languageFrom = document.getElementById("language-from");
/** @type {HTMLSelectElement} */
languageTo = document.getElementById("language-to");
/** @type {HTMLButtonElement} */
languageSwap = document.getElementById("language-swap");
/** @type {HTMLTextAreaElement} */
translationFrom = document.getElementById("translation-from");
/** @type {HTMLDivElement} */
@ -396,6 +398,7 @@ class TranslationsUI {
}
this.setupDropdowns();
this.setupTextarea();
this.setupLanguageSwapButton();
}
/**
@ -440,20 +443,75 @@ class TranslationsUI {
this.state.setFromLanguage(this.languageFrom.value);
this.state.setToLanguage(this.languageTo.value);
this.updateOnLanguageChange();
this.languageFrom.addEventListener("input", () => {
await this.updateOnLanguageChange();
this.languageFrom.addEventListener("input", async () => {
this.state.setFromLanguage(this.languageFrom.value);
this.updateOnLanguageChange();
await this.updateOnLanguageChange();
});
this.languageTo.addEventListener("input", () => {
this.languageTo.addEventListener("input", async () => {
this.state.setToLanguage(this.languageTo.value);
this.updateOnLanguageChange();
await this.updateOnLanguageChange();
this.translationTo.setAttribute("lang", this.languageTo.value);
});
}
/**
* Sets up the language swap button, so that when it's clicked, it:
* - swaps the selected source adn target lanauges
* - replaces the text to translate with the previous translation result
*/
setupLanguageSwapButton() {
this.languageSwap.addEventListener("click", async () => {
const translationToValue = this.translationTo.innerText;
const newFromLanguage = this.sanitizeTargetLangTagAsSourceLangTag(
this.state.toLanguage
);
const newToLanguage = this.sanitizeSourceLangTagAsTargetLangTag(
this.state.fromLanguage
);
this.state.setFromLanguage(newFromLanguage);
this.state.setToLanguage(newToLanguage);
this.languageFrom.value = newFromLanguage;
this.languageTo.value = newToLanguage;
await this.updateOnLanguageChange();
this.translationTo.setAttribute("lang", this.languageTo.value);
this.translationFrom.value = translationToValue;
this.state.setMessageToTranslate(translationToValue);
});
}
/**
* Get the target language dropdown option equivalent to the given source language dropdown option.
* `detect` will be converted to `` as `detect` is not a valid option in the target language dropdown
*
* @param {string} sourceLangTag
*/
sanitizeSourceLangTagAsTargetLangTag(sourceLangTag) {
if (sourceLangTag === "detect") {
return "";
}
return sourceLangTag;
}
/**
* Get the source language dropdown option equivalent to the given target language dropdown option.
* `` will be converted to `detect` as `` is not a valid option in the source language dropdown
*
* @param {string} targetLangTag
*/
sanitizeTargetLangTagAsSourceLangTag(targetLangTag) {
if (targetLangTag === "") {
return "detect";
}
return targetLangTag;
}
/**
* Show an info message to the user.
*
@ -516,9 +574,10 @@ class TranslationsUI {
/**
* React to language changes.
*/
updateOnLanguageChange() {
async updateOnLanguageChange() {
this.#updateDropdownLanguages();
this.#updateMessageDirections();
await this.#updateLanguageSwapButton();
}
/**
@ -592,10 +651,49 @@ class TranslationsUI {
}
}
/**
* Disable the language swap button if fromLanguage is equivalent to toLanguage, or if the languages are not a valid option in the opposite direction
*/
async #updateLanguageSwapButton() {
const sourceLanguage = this.state.fromLanguage;
const targetLanguage = this.state.toLanguage;
if (
sourceLanguage ===
this.sanitizeTargetLangTagAsSourceLangTag(targetLanguage)
) {
this.languageSwap.disabled = true;
return;
}
if (this.translationFrom.value && !this.translationTo.innerText) {
this.languageSwap.disabled = true;
return;
}
const supportedLanguages = await this.state.supportedLanguages;
const isSourceLanguageValidAsTargetLanguage =
sourceLanguage === "detect" ||
supportedLanguages.languagePairs.some(
({ toLang }) => toLang === sourceLanguage
);
const isTargetLanguageValidAsSourceLanguage =
targetLanguage === "" ||
supportedLanguages.languagePairs.some(
({ fromLang }) => fromLang === targetLanguage
);
this.languageSwap.disabled =
!isSourceLanguageValidAsTargetLanguage ||
!isTargetLanguageValidAsSourceLanguage;
}
setupTextarea() {
this.state.setMessageToTranslate(this.translationFrom.value);
this.translationFrom.addEventListener("input", () => {
this.state.setMessageToTranslate(this.translationFrom.value);
this.translationFrom.addEventListener("input", async () => {
await this.state.setMessageToTranslate(this.translationFrom.value);
this.#updateLanguageSwapButton();
});
}
@ -603,6 +701,7 @@ class TranslationsUI {
this.translationFrom.disabled = true;
this.languageFrom.disabled = true;
this.languageTo.disabled = true;
this.languageSwap.disabled = true;
}
/**
@ -618,6 +717,7 @@ class TranslationsUI {
this.translationTo.style.visibility = "hidden";
this.translationToBlank.style.visibility = "visible";
}
this.#updateLanguageSwapButton();
}
}

Просмотреть файл

@ -13,6 +13,7 @@ toolkit.jar:
content/global/translations/translations.mjs (content/translations.mjs)
content/global/translations/Translator.mjs (content/Translator.mjs)
content/global/translations/TranslationsTelemetry.sys.mjs (TranslationsTelemetry.sys.mjs)
content/global/translations/icons/swap-languages.svg (content/icons/swap-languages.svg)
# Uncomment this line to test a local build of Bergamot. It will automatically be loaded in.
# content/global/translations/bergamot-translator-worker.wasm (bergamot-translator/thirdparty/build-wasm/bergamot-translator-worker.wasm)

Просмотреть файл

@ -26,6 +26,8 @@ skip-if = ["os == 'linux'"] # Bug 1821461
["browser_about_translations_enabling.js"]
["browser_about_translations_language_swap.js"]
["browser_about_translations_translations.js"]
["browser_translations_actor.js"]

Просмотреть файл

@ -0,0 +1,269 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* Test the language swap behaviour.
*/
add_task(async function test_about_translations_language_swap() {
const { runInPage, cleanup, resolveDownloads } = await openAboutTranslations({
languagePairs: [
{ fromLang: "en", toLang: "fr" },
{ fromLang: "en", toLang: "it" },
{ fromLang: "fr", toLang: "en" },
],
autoDownloadFromRemoteSettings: false,
});
await runInPage(async ({ selectors }) => {
const { document, window } = content;
Cu.waiveXrays(window).DEBOUNCE_DELAY = 5; // Make the timer run faster for tests.
await ContentTaskUtils.waitForCondition(
() => {
return document.body.hasAttribute("ready");
},
"Waiting for the document to be ready.",
100,
200
);
/** @type {HTMLSelectElement} */
const fromSelect = document.querySelector(selectors.fromLanguageSelect);
/** @type {HTMLSelectElement} */
const toSelect = document.querySelector(selectors.toLanguageSelect);
/** @type {HTMLTextAreaElement} */
const translationFrom = document.querySelector(
selectors.translationTextarea
);
/** @type {HTMLButtonElement} */
const swapButton = document.querySelector(selectors.languageSwapButton);
// default option -> default option
is(fromSelect.value, "detect");
is(toSelect.value, "");
is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because from-language is equivalent to to-language
fromSelect.value = "en";
fromSelect.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => fromSelect.value === "en",
"en selected in fromSelect",
100,
200
);
// en -> default option
is(fromSelect.value, "en");
is(toSelect.value, "");
is(swapButton.disabled, false, "The language swap button is enabled");
translationFrom.value = "Translation text number 1.";
translationFrom.dispatchEvent(new Event("input"));
// default option (detect: en) -> default option
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === true,
"The language swap button is disabled",
100,
200
);
is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because swapping would wipe the input
translationFrom.value = "";
translationFrom.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === false,
"The language swap button is enabled",
100,
200
); // re-enabled after the input is cleared
swapButton.dispatchEvent(new Event("click"));
// default option -> en
is(fromSelect.value, "detect");
is(toSelect.value, "en");
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === false,
"The language swap button is enabled",
100,
200
);
is(swapButton.disabled, false, "The language swap button is enabled");
translationFrom.value = "Translation text number 1.";
translationFrom.dispatchEvent(new Event("input"));
// default option (detect: en) -> en
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === true,
"The language swap button is disabled",
100,
200
);
is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because from-language (detected as en) is equivalent to to-language
fromSelect.value = "fr";
fromSelect.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => fromSelect.value === "fr",
"fr selected in fromSelect",
100,
200
);
translationFrom.value = "";
translationFrom.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === false,
"The language swap button is enabled",
100,
200
);
is(swapButton.disabled, false, "The language swap button is enabled"); // both sides are empty
});
await resolveDownloads(1); // fr -> en
await runInPage(async ({ selectors }) => {
const { document, window } = content;
Cu.waiveXrays(window).DEBOUNCE_DELAY = 5;
/** @type {HTMLSelectElement} */
const fromSelect = document.querySelector(selectors.fromLanguageSelect);
/** @type {HTMLSelectElement} */
const toSelect = document.querySelector(selectors.toLanguageSelect);
/** @type {HTMLTextAreaElement} */
const translationFrom = document.querySelector(
selectors.translationTextarea
);
/** @type {HTMLDivElement} */
const translationTo = document.querySelector(selectors.translationResult);
/** @type {HTMLButtonElement} */
const swapButton = document.querySelector(selectors.languageSwapButton);
translationFrom.value = "Translation text number 1.";
translationFrom.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => translationTo.innerText === "TRANSLATION TEXT NUMBER 1. [fr to en]",
"translation from fr to en is complete",
100,
200
);
// fr -> en
is(fromSelect.value, "fr");
is(toSelect.value, "en");
is(translationFrom.value, "Translation text number 1.");
is(translationTo.innerText, "TRANSLATION TEXT NUMBER 1. [fr to en]");
is(swapButton.disabled, false, "The language swap button is enabled");
swapButton.dispatchEvent(new Event("click"));
await ContentTaskUtils.waitForCondition(
() => swapButton.disabled === true,
"The language swap button is disabled",
100,
200
);
is(swapButton.disabled, true, "The language swap button is disabled"); // after the swap, the input is not empty while the output is
});
await resolveDownloads(1); // en -> fr
await runInPage(async ({ selectors }) => {
const { document, window } = content;
Cu.waiveXrays(window).DEBOUNCE_DELAY = 5;
/** @type {HTMLSelectElement} */
const fromSelect = document.querySelector(selectors.fromLanguageSelect);
/** @type {HTMLSelectElement} */
const toSelect = document.querySelector(selectors.toLanguageSelect);
/** @type {HTMLTextAreaElement} */
const translationFrom = document.querySelector(
selectors.translationTextarea
);
/** @type {HTMLDivElement} */
const translationTo = document.querySelector(selectors.translationResult);
/** @type {HTMLButtonElement} */
const swapButton = document.querySelector(selectors.languageSwapButton);
await ContentTaskUtils.waitForCondition(
() =>
translationTo.innerText ===
"TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to fr]",
"translation from en to fr is complete",
100,
200
);
// en -> fr
is(fromSelect.value, "en");
is(toSelect.value, "fr");
is(translationFrom.value, "TRANSLATION TEXT NUMBER 1. [fr to en]");
is(
translationTo.innerText,
"TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to fr]"
); // translating the original fr-to-en translation back to fr
is(swapButton.disabled, false, "The language swap button is enabled");
toSelect.value = "it";
toSelect.dispatchEvent(new Event("input"));
await ContentTaskUtils.waitForCondition(
() => toSelect.value === "it",
"it selected in toSelect",
100,
200
);
is(swapButton.disabled, true, "The language swap button is disabled"); // after the toLanguage change, the input is not empty while the output is
});
await resolveDownloads(1); // en -> it
await runInPage(async ({ selectors }) => {
const { document, window } = content;
Cu.waiveXrays(window).DEBOUNCE_DELAY = 5;
/** @type {HTMLSelectElement} */
const fromSelect = document.querySelector(selectors.fromLanguageSelect);
/** @type {HTMLSelectElement} */
const toSelect = document.querySelector(selectors.toLanguageSelect);
/** @type {HTMLTextAreaElement} */
const translationFrom = document.querySelector(
selectors.translationTextarea
);
/** @type {HTMLDivElement} */
const translationTo = document.querySelector(selectors.translationResult);
/** @type {HTMLButtonElement} */
const swapButton = document.querySelector(selectors.languageSwapButton);
await ContentTaskUtils.waitForCondition(
() =>
translationTo.innerText ===
"TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to it]",
"translation from en to it is complete",
100,
200
);
// en -> it
is(fromSelect.value, "en");
is(toSelect.value, "it");
is(translationFrom.value, "TRANSLATION TEXT NUMBER 1. [fr to en]");
is(
translationTo.innerText,
"TRANSLATION TEXT NUMBER 1. [FR TO EN] [en to it]"
);
is(swapButton.disabled, true, "The language swap button is disabled"); // disabled because to-language (it) is not a valid from-language
});
await cleanup();
});

Просмотреть файл

@ -118,6 +118,7 @@ async function openAboutTranslations({
pageHeader: '[data-l10n-id="about-translations-header"]',
fromLanguageSelect: "select#language-from",
toLanguageSelect: "select#language-to",
languageSwapButton: "button#language-swap",
translationTextarea: "textarea#translation-from",
translationResult: "#translation-to",
translationResultBlank: "#translation-to-blank",

Просмотреть файл

@ -5,6 +5,10 @@
# The title of the about:translations page, referencing the translations feature.
about-translations-title = Translations
about-translations-header = { -translations-brand-name }
# The title attribute for the swap languages button, that swaps the
# source and target languages, reversing the translation direction.
about-translations-swap-languages =
.title = Swap languages
about-translations-results-placeholder = Translation
about-translations-translating-message = Translating…
# Text displayed on from-language dropdown when no language is selected