commit d31739e1c0e3859874d62909f4bc5314de12ff3a
parent 877fd2e1cbc05facbd0950757494eb3c6f8d904c
Author: Erik Nordin <enordin@mozilla.com>
Date: Wed, 1 Oct 2025 16:07:08 +0000
Bug 1991224 - Resize about:translations textareas on input r=translations-reviewers,gregtatum
This patch implements the ability for the `about:translations`
<textarea> elements to resize their heights and stay in sync
with each other as the source input or translated content
grow beyond the current bounds.
Differential Revision: https://phabricator.services.mozilla.com/D266488
Diffstat:
4 files changed, 289 insertions(+), 2 deletions(-)
diff --git a/toolkit/components/translations/content/about-translations.mjs b/toolkit/components/translations/content/about-translations.mjs
@@ -633,6 +633,7 @@ class AboutTranslations {
this.#updateSourceScriptDirection();
this.#updateTargetScriptDirection();
+ this.#synchronizeTextAreasToMaxContentHeight();
if (sourceTextArea.value) {
this.#displayTranslatingPlaceholder();
@@ -692,6 +693,7 @@ class AboutTranslations {
sourceTextArea.dispatchEvent(new Event("input"));
this.#updateSourceScriptDirection();
+ this.#synchronizeTextAreasToMaxContentHeight();
}
/**
@@ -709,6 +711,7 @@ class AboutTranslations {
}
this.#updateTargetScriptDirection();
+ this.#synchronizeTextAreasToMaxContentHeight();
}
/**
@@ -952,6 +955,7 @@ class AboutTranslations {
onDebounce: async () => {
try {
this.#updateURLFromUI();
+ this.#synchronizeTextAreasToMaxContentHeight();
await this.#maybeUpdateDetectedSourceLanguage();
@@ -1029,12 +1033,60 @@ class AboutTranslations {
// Mark the events so that they show up in the Firefox Profiler. This makes it handy
// to visualize the debouncing behavior.
doEveryTime: () => {
- this.#updateSourceScriptDirection();
+ const sourceText = this.#getSourceText();
performance.mark(
- `Translations: input changed to ${this.#getSourceText().length} code units.`
+ `Translations: input changed to ${sourceText.length} code units.`
);
+
+ if (!sourceText) {
+ this.#setTargetText("");
+ }
+
+ this.#updateSourceScriptDirection();
},
});
+
+ /**
+ * Calculates the heights of the content in both the source and target text areas,
+ * then syncs them both to the maximum calculated content height among the two.
+ */
+ #synchronizeTextAreasToMaxContentHeight() {
+ const { sourceTextArea, targetTextArea } = this.elements;
+
+ // This will be the same for both the source and target text areas.
+ const textAreaRatioBefore =
+ parseFloat(sourceTextArea.style.height) / sourceTextArea.scrollWidth;
+
+ sourceTextArea.style.height = "auto";
+ targetTextArea.style.height = "auto";
+
+ const maxContentHeight = Math.ceil(
+ Math.max(sourceTextArea.scrollHeight, targetTextArea.scrollHeight)
+ );
+ const maxContentHeightPixels = `${maxContentHeight}px`;
+
+ sourceTextArea.style.height = maxContentHeightPixels;
+ targetTextArea.style.height = maxContentHeightPixels;
+
+ const textAreaRatioAfter = maxContentHeight / sourceTextArea.scrollWidth;
+ const ratioDelta = textAreaRatioAfter - textAreaRatioBefore;
+ const changeThreshold = 0.001;
+
+ if (
+ // The text-area heights were not 0px prior to growing.
+ textAreaRatioBefore > changeThreshold &&
+ // The text-area aspect ratio changed beyond typical floating-point error.
+ Math.abs(ratioDelta) > changeThreshold
+ ) {
+ document.dispatchEvent(
+ new CustomEvent("AboutTranslations:TextAreaHeightsChanged", {
+ detail: {
+ textAreaHeights: ratioDelta < 0 ? "decreased" : "increased",
+ },
+ })
+ );
+ }
+ }
}
/**
diff --git a/toolkit/components/translations/tests/browser/browser.toml b/toolkit/components/translations/tests/browser/browser.toml
@@ -41,6 +41,8 @@ skip-if = ["os == 'linux'"] # Bug 1821461
["browser_about_translations_telemetry_open.js"]
+["browser_about_translations_textarea_resize_by_input.js"]
+
["browser_about_translations_url_load.js"]
["browser_about_translations_url_update.js"]
diff --git a/toolkit/components/translations/tests/browser/browser_about_translations_textarea_resize_by_input.js b/toolkit/components/translations/tests/browser/browser_about_translations_textarea_resize_by_input.js
@@ -0,0 +1,226 @@
+/* Any copyright is dedicated to the Public Domain.
+ https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// The German lower-case character "ß" expands to two characters "SS" when capitalized.
+// Our mock translator deterministically capitalizes text for integration tests.
+const largeExpandingInput = `\
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß`;
+
+const halfLargeExpandingInput = `\
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß \
+ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß ß`;
+
+/**
+ * This test case ensures that translating a small input, one that would not
+ * cause the text content to exceed the default text-area height, does not
+ * cause the text area to automatically resize.
+ */
+add_task(async function test_about_translations_no_resize_for_small_input() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs: [
+ { fromLang: "de", toLang: "en" },
+ { fromLang: "en", toLang: "de" },
+ ],
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationRequested,
+ { translationId: 1 },
+ ],
+ [AboutTranslationsTestUtils.Events.ShowTranslatingPlaceholder],
+ ],
+ unexpected: [AboutTranslationsTestUtils.Events.TextAreaHeightsChanged],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("de");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setSourceTextAreaValue("Hello world");
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ { translationId: 1 },
+ ],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "de",
+ targetLanguage: "en",
+ sourceText: "Hello world",
+ });
+
+ await cleanup();
+});
+
+/**
+ * This test case ensures that translating a source text that is larger than the
+ * default source-text-area size will cause it to resize, that producing a translated
+ * output that is larger than the target-text-area will cause it to resize, and that
+ * reducing the size of the source text after it has been expanded will cause it to
+ * return to the default size.
+ */
+add_task(async function test_about_translations_resize_by_input() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs: [
+ { fromLang: "de", toLang: "en" },
+ { fromLang: "en", toLang: "de" },
+ ],
+ });
+
+ info(
+ "The text areas should expand when a large input is pasted as the source."
+ );
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationRequested,
+ { translationId: 1 },
+ ],
+ [AboutTranslationsTestUtils.Events.ShowTranslatingPlaceholder],
+ [
+ AboutTranslationsTestUtils.Events.TextAreaHeightsChanged,
+ {
+ textAreaHeights: "increased",
+ },
+ ],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("de");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setSourceTextAreaValue(
+ largeExpandingInput
+ );
+ }
+ );
+
+ info(
+ "The text areas should expand again if the translated output is taller than the input."
+ );
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ { translationId: 1 },
+ ],
+ [
+ AboutTranslationsTestUtils.Events.TextAreaHeightsChanged,
+ {
+ textAreaHeights: "increased",
+ },
+ ],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "de",
+ targetLanguage: "en",
+ sourceText: largeExpandingInput,
+ });
+
+ info(
+ "The text areas should reduce their size if the content height is reduced."
+ );
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationRequested,
+ { translationId: 2 },
+ ],
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ { translationId: 2 },
+ ],
+ [
+ AboutTranslationsTestUtils.Events.TextAreaHeightsChanged,
+ {
+ textAreaHeights: "decreased",
+ },
+ ],
+ ],
+ unexpected: [
+ AboutTranslationsTestUtils.Events.ShowTranslatingPlaceholder,
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.setSourceTextAreaValue(
+ halfLargeExpandingInput
+ );
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "de",
+ targetLanguage: "en",
+ sourceText: halfLargeExpandingInput,
+ });
+
+ info(
+ "The text areas should reset to default height when all content is removed."
+ );
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TextAreaHeightsChanged,
+ {
+ textAreaHeights: "decreased",
+ },
+ ],
+ ],
+ unexpected: [
+ AboutTranslationsTestUtils.Events.TranslationRequested,
+ AboutTranslationsTestUtils.Events.ShowTranslatingPlaceholder,
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.setSourceTextAreaValue("");
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertSourceTextArea({
+ showsPlaceholder: true,
+ });
+
+ await aboutTranslationsTestUtils.assertTargetTextArea({
+ showsPlaceholder: true,
+ });
+
+ await cleanup();
+});
diff --git a/toolkit/components/translations/tests/browser/shared-head.js b/toolkit/components/translations/tests/browser/shared-head.js
@@ -2816,6 +2816,13 @@ class AboutTranslationsTestUtils {
static TranslationComplete = "AboutTranslations:TranslationComplete";
/**
+ * Event fired when the source/target textarea heights change.
+ *
+ * @type {string}
+ */
+ static TextAreaHeightsChanged = "AboutTranslations:TextAreaHeightsChanged";
+
+ /**
* Event fired when the target text is cleared programmatically.
*
* @type {string}