Greasy Fork is available in English.
使用 Ctrl+L 立即翻译所选文本。支持所有语言并自动检测所选语言,将其翻译为浏览器的默认语言。简单、快速、高效。
当前为
// ==UserScript==
// @name Ultimate Text Selection Translator – Instantly Translate Any Selected Text
// @name:fr Ultimate Text Selection Translator – Traduisez instantanément n'importe quel texte sélectionné
// @name:es Ultimate Text Selection Translator – Traduce instantáneamente cualquier texto seleccionado
// @name:de Ultimate Text Selection Translator – Übersetzen Sie jeden ausgewählten Text sofort
// @name:ru Ultimate Text Selection Translator – Мгновенно переводите любой выделенный текст
// @name:zh-CN Ultimate Text Selection Translator – 立即翻译任何选定的文本
// @name:zh-TW Ultimate Text Selection Translator – 立即翻譯任何選定的文本
// @name:ja Ultimate Text Selection Translator – 選択したテキストを即座に翻訳
// @name:pt Ultimate Text Selection Translator – Traduza instantaneamente qualquer texto selecionado
// @name:it Ultimate Text Selection Translator – Traduci istantaneamente qualsiasi testo selezionato
// @name:ar Ultimate Text Selection Translator – ترجمة فورية لأي نص محدد
// @name:be Ultimate Text Selection Translator – Імгненна перакладайце любы выбраны тэкст
// @name:bg Ultimate Text Selection Translator – Незабавен превод на всеки избран текст
// @name:cs Ultimate Text Selection Translator – Okamžitě přeložte jakýkoli vybraný text
// @name:da Ultimate Text Selection Translator – Oversæt øjeblikkeligt enhver valgt tekst
// @name:el Ultimate Text Selection Translator – Μεταφράστε άμεσα οποιοδήποτε επιλεγμένο κείμενο
// @name:eo Ultimate Text Selection Translator – Tuj Traduku Iun Elektitan Tekston
// @name:fi Ultimate Text Selection Translator – Käännä välittömästi kaikki valitut tekstit
// @name:he Ultimate Text Selection Translator – תרגם באופן מיידי כל טקסט שנבחר
// @name:hr Ultimate Text Selection Translator – Trenutačno prevedite bilo koji odabrani tekst
// @name:hu Ultimate Text Selection Translator – Azonnal lefordíthatja a kiválasztott szöveget
// @name:id Ultimate Text Selection Translator – Terjemahkan Teks yang Dipilih Secara Instan
// @name:ka Ultimate Text Selection Translator – მყისიერად თარგმნეთ ნებისმიერი არჩეული ტექსტი
// @name:ko Ultimate Text Selection Translator – 선택한 텍스트를 즉시 번역하세요
// @name:mr Ultimate Text Selection Translator – कोणताही निवडलेला मजकूर त्वरित अनुवादित करा
// @name:nl Ultimate Text Selection Translator – Vertaal onmiddellijk elke geselecteerde tekst
// @name:nb Ultimate Text Selection Translator – Oversett alle valgt tekst umiddelbart
// @name:pl Ultimate Text Selection Translator – Natychmiast przetłumacz dowolny zaznaczony tekst
// @name:pt-BR Ultimate Text Selection Translator – Traduza instantaneamente qualquer texto selecionado
// @name:ro Ultimate Text Selection Translator – Traduceți instantaneu orice text selectat
// @name:sk Ultimate Text Selection Translator – Okamžite preložte akýkoľvek vybraný text
// @name:sr Ultimate Text Selection Translator – Одмах преведите било који одабрани текст
// @name:sv Ultimate Text Selection Translator – Översätt direkt valfri text
// @name:th Ultimate Text Selection Translator – แปลข้อความที่เลือกทันที
// @name:tr Ultimate Text Selection Translator – Seçilen Metni Anında Çevir
// @name:ug Ultimate Text Selection Translator – تاللانغان تېكىستنى دەرھال تەرجىمە قىلىڭ
// @name:uk Ultimate Text Selection Translator – Миттєво перекладіть будь-який виділений текст
// @name:vi Ultimate Text Selection Translator – Dịch ngay lập tức mọi văn bản đã chọn
// @name:fr-CA Ultimate Text Selection Translator – Traduisez instantanément n'importe quel texte sélectionné
// @name:ckb Ultimate Text Selection Translator – Her Nivîsarek Hilbijartî tavilê Wergerîne
// @name:es-419 Ultimate Text Selection Translator – Traduce instantáneamente cualquier texto seleccionado
// @description Translate selected text instantly using Ctrl+L. Supports all languages and automatically detects the selected language, translating it into your browser's default language. Simple, fast, and efficient.
// @description:fr Traduisez instantanément le texte sélectionné à l’aide de Ctrl+L. Prend en charge toutes les langues et détecte automatiquement la langue sélectionnée, la traduisant dans la langue par défaut de votre navigateur. Simple, rapide et efficace.
// @description:es Traduce el texto seleccionado al instante usando Ctrl+L. Admite todos los idiomas y detecta automáticamente el idioma seleccionado, traduciéndolo al idioma predeterminado de su navegador. Sencillo, rápido y eficiente.
// @description:de Übersetzen Sie den ausgewählten Text sofort mit Strg+L. Unterstützt alle Sprachen, erkennt die ausgewählte Sprache automatisch und übersetzt sie in die Standardsprache Ihres Browsers. Einfach, schnell und effizient.
// @description:ru Мгновенно переводите выделенный текст с помощью Ctrl+L. Поддерживает все языки и автоматически определяет выбранный язык, переводя его на язык вашего браузера по умолчанию. Просто, быстро и эффективно.
// @description:zh-CN 使用 Ctrl+L 立即翻译所选文本。支持所有语言并自动检测所选语言,将其翻译为浏览器的默认语言。简单、快速、高效。
// @description:zh-TW 使用 Ctrl+L 立即翻譯所選文本。支持所有語言並自動檢測所選語言,將其翻譯為瀏覽器的默認語言。簡單、快速、高效。
// @description:ja Ctrl+L を使用して、選択したテキストを即座に翻訳します。すべての言語をサポートし、選択した言語を自動的に検出し、ブラウザのデフォルト言語に翻訳します。シンプル、高速、効率的です。
// @description:pt Traduza o texto selecionado instantaneamente usando Ctrl+L. Suporta todos os idiomas e detecta automaticamente o idioma selecionado, traduzindo-o para o idioma padrão do seu navegador. Simples, rápido e eficiente.
// @description:it Traduci istantaneamente il testo selezionato utilizzando Ctrl+L. Supporta tutte le lingue e rileva automaticamente la lingua selezionata, traducendola nella lingua predefinita del tuo browser. Semplice, veloce ed efficiente.
// @description:ar ترجمة النص المحدد على الفور باستخدام Ctrl+L. يدعم جميع اللغات ويكتشف اللغة المحددة تلقائيًا، ويترجمها إلى اللغة الافتراضية للمتصفح الخاص بك. بسيطة وسريعة وفعالة.
// @description:be Перакладзіце вылучаны тэкст імгненна, выкарыстоўваючы Ctrl+L. Падтрымлівае ўсе мовы і аўтаматычна вызначае выбраную мову, перакладаючы яе на мову вашага браўзера па змаўчанні. Проста, хутка і эфектыўна.
// @description:bg Превеждайте незабавно избрания текст с помощта на Ctrl+L. Поддържа всички езици и автоматично открива избрания език, превеждайки го на езика по подразбиране на вашия браузър. Просто, бързо и ефективно.
// @description:cs Okamžitě přeložte vybraný text pomocí Ctrl+L. Podporuje všechny jazyky a automaticky detekuje vybraný jazyk a překládá jej do výchozího jazyka vašeho prohlížeče. Jednoduché, rychlé a efektivní.
// @description:da Oversæt valgt tekst øjeblikkeligt ved hjælp af Ctrl+L. Understøtter alle sprog og registrerer automatisk det valgte sprog og oversætter det til din browsers standardsprog. Enkel, hurtig og effektiv.
// @description:el Μεταφράστε το επιλεγμένο κείμενο άμεσα χρησιμοποιώντας Ctrl+L. Υποστηρίζει όλες τις γλώσσες και εντοπίζει αυτόματα την επιλεγμένη γλώσσα, μεταφράζοντάς την στην προεπιλεγμένη γλώσσα του προγράμματος περιήγησής σας. Απλό, γρήγορο και αποτελεσματικό.
// @description:eo Traduku elektitan tekston tuj uzante Ctrl+L. Subtenas ĉiujn lingvojn kaj aŭtomate detektas la elektitan lingvon, tradukante ĝin al la defaŭlta lingvo de via retumilo. Simpla, rapida kaj efika.
// @description:fi Käännä valittu teksti välittömästi painamalla Ctrl+L. Tukee kaikkia kieliä ja tunnistaa automaattisesti valitun kielen kääntäen sen selaimesi oletuskielelle. Yksinkertaista, nopeaa ja tehokasta.
// @description:he תרגם טקסט נבחר באופן מיידי באמצעות Ctrl+L. תומך בכל השפות ומזהה אוטומטית את השפה הנבחרת, ומתרגם אותה לשפת ברירת המחדל של הדפדפן שלך. פשוט, מהיר ויעיל.
// @description:hr Trenutačno prevedite odabrani tekst pomoću Ctrl+L. Podržava sve jezike i automatski otkriva odabrani jezik, prevodeći ga na zadani jezik vašeg preglednika. Jednostavno, brzo i učinkovito.
// @description:hu A kijelölt szöveget azonnal lefordíthatja a Ctrl+L billentyűkombinációval. Támogatja az összes nyelvet, és automatikusan felismeri a kiválasztott nyelvet, lefordítva azt a böngésző alapértelmezett nyelvére. Egyszerű, gyors és hatékony.
// @description:id Terjemahkan teks yang dipilih secara instan menggunakan Ctrl+L. Mendukung semua bahasa dan secara otomatis mendeteksi bahasa yang dipilih, menerjemahkannya ke bahasa default browser Anda. Sederhana, cepat, dan efisien.
// @description:ka შერჩეული ტექსტის თარგმნა მყისიერად Ctrl+L-ის გამოყენებით. მხარს უჭერს ყველა ენას და ავტომატურად ამოიცნობს არჩეულ ენას, თარგმნის მას თქვენი ბრაუზერის ნაგულისხმევ ენაზე. მარტივი, სწრაფი და ეფექტური.
// @description:ko Ctrl+L을 사용하여 선택한 텍스트를 즉시 번역하세요. 모든 언어를 지원하고 선택한 언어를 자동으로 감지하여 브라우저의 기본 언어로 번역합니다. 간단하고 빠르며 효율적입니다.
// @description:mr Ctrl+L वापरून निवडलेल्या मजकुराचे झटपट भाषांतर करा. सर्व भाषांना सपोर्ट करते आणि निवडलेली भाषा आपोआप ओळखते, ती तुमच्या ब्राउझरच्या डीफॉल्ट भाषेत अनुवादित करते. साधे, जलद आणि कार्यक्षम.
// @description:nl Vertaal geselecteerde tekst onmiddellijk met Ctrl+L. Ondersteunt alle talen en detecteert automatisch de geselecteerde taal en vertaalt deze naar de standaardtaal van uw browser. Eenvoudig, snel en efficiënt.
// @description:nb Oversett valgt tekst umiddelbart ved å bruke Ctrl+L. Støtter alle språk og oppdager automatisk det valgte språket, og oversetter det til nettleserens standardspråk. Enkelt, raskt og effektivt.
// @description:pl Przetłumacz zaznaczony tekst natychmiast, używając Ctrl+L. Obsługuje wszystkie języki i automatycznie wykrywa wybrany język, tłumacząc go na domyślny język Twojej przeglądarki. Prosto, szybko i skutecznie.
// @description:pt-BR Traduza o texto selecionado instantaneamente usando Ctrl+L. Suporta todos os idiomas e detecta automaticamente o idioma selecionado, traduzindo-o para o idioma padrão do seu navegador. Simples, rápido e eficiente.
// @description:ro Traduceți textul selectat instantaneu folosind Ctrl+L. Acceptă toate limbile și detectează automat limba selectată, traducând-o în limba implicită a browserului. Simplu, rapid și eficient.
// @description:sk Preložte vybraný text okamžite pomocou Ctrl+L. Podporuje všetky jazyky a automaticky rozpozná vybraný jazyk a preloží ho do predvoleného jazyka prehliadača. Jednoduché, rýchle a efektívne.
// @description:sr Одмах преведите изабрани текст користећи Цтрл+Л. Подржава све језике и аутоматски детектује изабрани језик, преводећи га на подразумевани језик вашег претраживача. Једноставно, брзо и ефикасно.
// @description:sv Översätt markerad text direkt med Ctrl+L. Stöder alla språk och upptäcker automatiskt det valda språket och översätter det till din webbläsares standardspråk. Enkelt, snabbt och effektivt.
// @description:th แปลข้อความที่เลือกทันทีโดยใช้ Ctrl+L รองรับทุกภาษาและตรวจจับภาษาที่เลือกโดยอัตโนมัติ โดยแปลเป็นภาษาเริ่มต้นของเบราว์เซอร์ของคุณ ง่าย รวดเร็ว และมีประสิทธิภาพ
// @description:tr Seçilen metni Ctrl+L tuşlarını kullanarak anında çevirin. Tüm dilleri destekler ve seçilen dili otomatik olarak algılayarak tarayıcınızın varsayılan diline çevirir. Basit, hızlı ve verimli.
// @description:ug تاللانغان تېكىستنى دەرھال CTRL + L ئارقىلىق تەرجىمە قىلىڭ. بارلىق تىللارنى قوللايدۇ ھەمدە تاللانغان تىلنى ئاپتوماتىك چەكلىنىدۇ, ئۇنى توركۆرگۈنىڭ سۈكۈتتىكى ھالەتتە تەرجىمە قىلىدۇ. ئاددىي, تېز ۋە ئۈنۈملۈك.
// @description:uk Миттєво перекладіть виділений текст за допомогою Ctrl+L. Підтримує всі мови та автоматично визначає вибрану мову, перекладаючи її на мову вашого браузера за умовчанням. Просто, швидко та ефективно.
// @description:vi Dịch văn bản đã chọn ngay lập tức bằng Ctrl+L. Hỗ trợ tất cả các ngôn ngữ và tự động phát hiện ngôn ngữ đã chọn, dịch ngôn ngữ đó sang ngôn ngữ mặc định của trình duyệt của bạn. Đơn giản, nhanh chóng và hiệu quả.
// @description:fr-CA Traduisez instantanément le texte sélectionné à l’aide de Ctrl+L. Prend en charge toutes les langues et détecte automatiquement la langue sélectionnée, la traduisant dans la langue par défaut de votre navigateur. Simple, rapide et efficace.
// @description:ckb Nivîsara hilbijartî yekser bi karanîna Ctrl+L wergerîne. Hemî zimanan piştgirî dike û bixweber zimanê hilbijartî tespît dike, wî werdigerîne zimanê xwerû yê geroka we. Hêsan, zû û bikêrhatî.
// @description:es-419 Traduce instantáneamente el texto seleccionado usando Ctrl+L. Admite todos los idiomas y detecta automáticamente el idioma seleccionado, traduciéndolo al idioma predeterminado de tu navegador. Simple, rápido y eficiente.
// @namespace https://github.com/DREwX-code
// @author Dℝ∃wX
// @copyright 2025 Dℝ∃wX
// @license Apache-2.0
// @require https://update.greasyfork.icu/scripts/556911/1754127/UTST%20Translation%20Library.js
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @connect translate.googleapis.com
// @match *://*/*
// @run-at document-start
// @version 1.4.0
// @icon https://raw.githubusercontent.com/DREwX-code/Ultimate-Text-Selection-Translator/refs/heads/main/assets/icons/Icon_Translate_Script.png
// @tag translation
// @tag text selection
// @tag translate
// @tag google translate
// @tag shortcut
// @tag productivity
// @tag accessibility
// @tag language
// @tag multilingual
// ==/UserScript==
/*
Copyright 2025-2026 Dℝ∃wX
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
(function () {
'use strict';
function bootstrap() {
GM_addStyle(`
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');
#closeButton:hover svg {
stroke: #ff4d4d !important;
filter: drop-shadow(0 0 4px rgba(255, 77, 77, 0.5));
transform: scale(1.1);
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes utst-shimmer {
0% { background-position: -468px 0; }
100% { background-position: 468px 0; }
}
.utst-loading {
position: relative !important;
overflow: hidden !important;
pointer-events: none !important;
}
.utst-loading::after {
content: "" !important;
position: absolute !important;
inset: 0 !important;
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0) 100%) !important;
background-size: 468px 100% !important;
animation: utst-shimmer 1.5s infinite linear !important;
z-index: 5 !important;
}
.utst-panel-light .utst-loading::after {
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(0,0,0,0.05) 50%, rgba(255,255,255,0) 100%) !important;
}
.utst-loading-overlay {
position: absolute !important;
inset: 0 !important;
background: rgba(0, 0, 0, 0.2) !important;
backdrop-filter: blur(2px) !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
border-radius: 10px !important;
z-index: 10 !important;
pointer-events: none !important;
opacity: 0 !important;
transition: opacity 0.3s ease !important;
}
.utst-loading-active .utst-loading-overlay {
opacity: 1 !important;
}
.utst-loading-shimmer {
width: 100% !important;
height: 100% !important;
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0) 100%) !important;
background-size: 468px 100% !important;
animation: utst-shimmer 1.5s infinite linear !important;
}
.utst-panel-light .utst-loading-shimmer {
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(0,0,0,0.06) 50%, rgba(255,255,255,0) 100%) !important;
}
.utst-scroll {
scrollbar-width: thin !important;
scrollbar-color: rgba(100, 149, 237, 0.5) rgba(0, 0, 0, 0.1) !important;
}
.utst-scroll::-webkit-scrollbar {
width: 6px !important;
height: 6px !important;
}
.utst-scroll::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05) !important;
border-radius: 3px !important;
}
.utst-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15) !important;
border-radius: 3px !important;
border: 1px solid rgba(255, 255, 255, 0.05) !important;
}
.utst-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3) !important;
}
#utstSelectionBubble {
position: absolute;
z-index: 2147483647;
display: flex;
align-items: center;
gap: 0;
height: 40px;
padding: 0 6px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(25, 25, 35, 0.85); /* Dark semi-transparent */
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #fff;
opacity: 0;
transform: translateY(-8px) scale(0.95);
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
font-family: 'Roboto', sans-serif;
}
#utstSelectionBubble.utst-visible {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
#utstSelectionBubbleClose {
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.7);
background: transparent;
font-size: 16px;
font-weight: 500;
line-height: 1;
transition: all 0.2s ease;
cursor: pointer;
user-select: none;
margin-right: 2px;
}
#utstSelectionBubbleClose:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
transform: rotate(90deg);
}
#utstSelectionBubbleDivider {
width: 1px;
height: 20px;
margin: 0 6px;
background: rgba(255, 255, 255, 0.2);
}
#utstSelectionBubbleAction {
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
padding: 0;
background: transparent;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
#utstSelectionBubbleAction svg {
width: 18px;
height: 18px;
color: #d8e8ff;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
}
#utstSelectionBubbleAction:hover {
background: rgba(255, 255, 255, 0.15);
transform: scale(1.1);
}
#speakTooltip .utst-speak-option:hover {
background: rgba(255,255,255,0.12);
}
#fullscreenSwap {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
#fullscreenSwap:hover,
#fullscreenSwap:active {
background: transparent !important;
box-shadow: none !important;
}
#utstBubbleCloseMenu {
position: absolute;
left: 0;
top: calc(100% + 10px);
display: none;
flex-direction: column;
min-width: 180px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(30, 30, 40, 0.95);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
overflow: hidden;
animation: utstFadeIn 0.2s ease;
}
@keyframes utstFadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
#utstBubbleCloseMenu.utst-open {
display: flex;
}
.utst-bubble-menu-btn {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.9);
text-align: left;
font-size: 13px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.15s ease;
font-family: inherit;
}
.utst-bubble-menu-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.utst-bubble-settings {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#utstTranslationBox #settingsHeader {
padding: 4px 8px;
border-radius: 10px;
background: #222b3f;
border: 1px solid rgba(255, 255, 255, 0.08);
right: 8px;
z-index: 14;
}
#utstTranslationBox #settingsPanel {
position: absolute;
top: 62px;
left: 8px;
right: 8px;
bottom: 10px;
z-index: 13;
margin: 0;
min-width: 0 !important;
max-width: none !important;
min-height: 0 !important;
max-height: none !important;
overflow-y: auto;
border-radius: 10px;
background: transparent;
}
#utstTranslationBox #translatorPanel {
transition: filter 0.18s ease, opacity 0.18s ease;
}
#utstTranslationBox #translationTextWrap,
#fullscreenPanel #fullscreenTargetWrap {
position: relative;
}
.utst-modern-loader {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: linear-gradient(135deg, rgba(12, 20, 36, 0.7) 0%, rgba(16, 28, 50, 0.62) 100%);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
opacity: 0;
pointer-events: none;
transform: scale(0.985);
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 9;
}
.utst-modern-loader.is-active {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.utst-modern-loader__card {
display: flex;
align-items: center;
gap: 10px;
min-width: 170px;
max-width: calc(100% - 20px);
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(8, 14, 28, 0.64);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.28);
}
.utst-modern-loader__ring {
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #7bb1ff;
animation: utstLoaderSpin 0.8s linear infinite;
flex: none;
}
.utst-modern-loader[data-mode="language"] .utst-modern-loader__ring {
border-top-color: #4fd0a9;
}
.utst-modern-loader__body {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 105px;
}
.utst-modern-loader__title {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.2px;
color: rgba(245, 248, 255, 0.95);
line-height: 1.2;
white-space: nowrap;
}
.utst-modern-loader__line {
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.14) 0%, rgba(255, 255, 255, 0.4) 48%, rgba(255, 255, 255, 0.14) 100%);
background-size: 180% 100%;
animation: utstLoaderShimmer 1.1s linear infinite;
}
@keyframes utstLoaderSpin {
to { transform: rotate(360deg); }
}
@keyframes utstLoaderShimmer {
from { background-position: 180% 0; }
to { background-position: -80% 0; }
}
#utstTranslationBox.utst-settings-open #translatorPanel {
filter: blur(4px) saturate(0.9);
opacity: 0.34;
pointer-events: none;
user-select: none;
}
.utst-toggle-row {
display: flex;
align-items: center;
gap: 10px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
margin-bottom: 10px;
user-select: none;
cursor: pointer;
}
.utst-toggle-row input[type="checkbox"] {
appearance: none;
width: 36px;
height: 20px;
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
position: relative;
cursor: pointer;
transition: background 0.2s;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.utst-toggle-row input[type="checkbox"]::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
.utst-toggle-row input[type="checkbox"]:checked {
background: #4a90e2;
border-color: #4a90e2;
}
.utst-toggle-row input[type="checkbox"]:checked::after {
transform: translateX(16px);
}
.utst-blacklist-controls {
display: flex;
gap: 8px;
margin-top: 8px;
}
.utst-blacklist-input {
flex: 1;
min-width: 0;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.2);
color: #fff;
font-size: 12px;
font-family: inherit;
transition: border-color 0.2s;
}
.utst-blacklist-input:focus {
outline: none;
border-color: #4a90e2;
}
.utst-blacklist-add {
border: none;
border-radius: 8px;
background: #4a90e2;
color: #fff;
font-size: 12px;
font-weight: 600;
padding: 0 12px;
cursor: pointer;
transition: background 0.2s;
}
.utst-blacklist-add:hover {
background: #357abd;
}
.utst-blacklist-list {
margin-top: 10px;
max-height: 120px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.15);
}
.utst-blacklist-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
transition: background 0.1s;
}
.utst-blacklist-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.utst-blacklist-item + .utst-blacklist-item {
margin-top: 4px;
}
.utst-blacklist-remove {
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
width: 20px;
height: 20px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.utst-blacklist-remove:hover {
background: rgba(255, 77, 77, 0.2);
color: #ff4d4d;
}
.utst-blacklist-empty {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
padding: 4px;
text-align: center;
}
html.utst-theme-blue #utstSelectionBubble {
/* Muted deep blue, inspired by the panel but less saturated/flashy */
background: linear-gradient(135deg, rgba(30, 30, 47, 0.96) 0%, rgba(35, 35, 52, 0.96) 100%);
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 25px rgba(10, 14, 28, 0.5);
}
html.utst-theme-blue #utstSelectionBubbleDivider {
background: rgba(255, 255, 255, 0.2);
}
html.utst-theme-blue #utstSelectionBubbleAction svg,
html.utst-theme-blue #utstSelectionBubbleClose {
color: #e0e6ff;
}
html.utst-theme-dark #utstSelectionBubble {
background: linear-gradient(135deg, rgba(18, 18, 18, 0.96) 0%, rgba(28, 28, 28, 0.96) 100%) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.6) !important;
}
html.utst-theme-dark #utstSelectionBubbleDivider {
background: rgba(255, 255, 255, 0.15) !important;
}
html.utst-theme-dark #utstSelectionBubbleAction svg,
html.utst-theme-dark #utstSelectionBubbleClose {
color: #d0d0d0 !important;
}
html.utst-theme-dark #utstTranslationBox {
/* True neutral dark, removing blue tint */
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%) !important;
border-color: rgba(255,255,255,0.08) !important;
}
html.utst-theme-dark #utstTranslationBox #dragHandle {
background: linear-gradient(120deg, #1a1a1a, #252525) !important;
}
html.utst-theme-dark #fullscreenPanel {
background: linear-gradient(135deg, #121212 0%, #1e1e1e 100%) !important;
border-color: rgba(255,255,255,0.08) !important;
}
html.utst-theme-blue #utstTranslationBox #settingsHeader {
background: #222b3f !important;
border-color: rgba(139, 177, 255, 0.28) !important;
}
html.utst-theme-blue #utstTranslationBox #settingsPanel {
background: transparent !important;
}
html.utst-theme-dark #utstTranslationBox #settingsHeader {
background: #1a1a1a !important;
border-color: rgba(255, 255, 255, 0.14) !important;
}
html.utst-theme-dark #utstTranslationBox #settingsPanel {
background: transparent !important;
}
html.utst-theme-light #utstSelectionBubble {
/* Softer, less blinding white - slightly grey/blue tinted off-white */
background: linear-gradient(135deg, #f0f2f5 0%, #e1e4e8 100%) !important;
border-color: rgba(0, 0, 0, 0.1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
}
html.utst-theme-light #utstSelectionBubbleDivider {
background: rgba(0, 0, 0, 0.1) !important;
}
html.utst-theme-light #utstSelectionBubbleAction svg,
html.utst-theme-light #utstSelectionBubbleClose {
color: #4a5568 !important; /* Dark grey-blue */
}
html.utst-theme-light #utstBubbleCloseMenu {
background: rgba(255, 255, 255, 0.98) !important;
border-color: rgba(0, 0, 0, 0.1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
html.utst-theme-light .utst-bubble-menu-btn {
color: #2d3748 !important;
}
html.utst-theme-light .utst-bubble-menu-btn:hover {
background: rgba(0, 0, 0, 0.05) !important;
}
html.utst-theme-light #utstTranslationBox {
/* Softer light theme background */
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
color: #1a202c !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12) !important;
}
html.utst-theme-light #utstTranslationBox #dragHandle {
background: linear-gradient(120deg, #edf2f7, #e2e8f0) !important;
color: #4a5568 !important;
box-shadow: inset 0 -1px 0 rgba(0,0,0,0.05) !important;
}
html.utst-theme-light #utstTranslationBox #dragHandle > div {
background: rgba(74, 85, 104, 0.45) !important;
}
/* Ensure ALL icons in the box are dark in light theme */
html.utst-theme-light #utstTranslationBox svg {
stroke: #4a5568;
}
/* Keep specific icon colors if needed, e.g. close button might be red */
html.utst-theme-light #utstTranslationBox #closeButton svg {
stroke: #ef4444 !important;
}
html.utst-theme-light #utstTranslationBox #settingsButton svg path {
stroke: #4a5568 !important;
}
html.utst-theme-light #utstTranslationBox #translatorPanel *,
html.utst-theme-light #utstTranslationBox #settingsPanel *,
html.utst-theme-light #utstTranslationBox #settingsHeader *,
html.utst-theme-light #fullscreenPanel * {
color: #2d3748 !important;
}
html.utst-theme-light #utstTranslationBox #translationText {
background: #f7fafc !important;
border: 1px solid #e2e8f0 !important;
color: #1a202c !important;
}
html.utst-theme-light .utst-modern-loader {
background: linear-gradient(135deg, rgba(241, 245, 249, 0.78) 0%, rgba(226, 232, 240, 0.78) 100%) !important;
}
html.utst-theme-light .utst-modern-loader__card {
background: rgba(255, 255, 255, 0.9) !important;
border-color: rgba(148, 163, 184, 0.45) !important;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12) !important;
}
html.utst-theme-light .utst-modern-loader__ring {
border-color: rgba(71, 85, 105, 0.2) !important;
border-top-color: #2563eb !important;
}
html.utst-theme-light .utst-modern-loader[data-mode="language"] .utst-modern-loader__ring {
border-top-color: #0f9f6e !important;
}
html.utst-theme-light .utst-modern-loader__title {
color: #1e293b !important;
}
html.utst-theme-light .utst-modern-loader__line {
background: linear-gradient(90deg, rgba(30, 41, 59, 0.08) 0%, rgba(37, 99, 235, 0.28) 50%, rgba(30, 41, 59, 0.08) 100%) !important;
}
html.utst-theme-light #utstTranslationBox select,
html.utst-theme-light #utstTranslationBox input {
background: #ffffff !important;
border: 1px solid #cbd5e0 !important;
color: #2d3748 !important;
}
html.utst-theme-light #utstTranslationBox .utst-toggle-row input[type="checkbox"] {
background: #d9e1ec !important;
border: 1px solid #b8c4d6 !important;
}
html.utst-theme-light #utstTranslationBox .utst-toggle-row input[type="checkbox"]::after {
background: #ffffff !important;
}
html.utst-theme-light #utstTranslationBox .utst-toggle-row input[type="checkbox"]:checked {
background: #4a90e2 !important;
border-color: #4a90e2 !important;
}
html.utst-theme-light #utstTranslationBox #bubbleBlacklistList {
background: #ffffff !important;
border-color: #e2e8f0 !important;
}
html.utst-theme-light #utstTranslationBox .utst-blacklist-item {
background: #f7fafc !important;
color: #2d3748 !important;
}
html.utst-theme-light #utstTranslationBox .utst-blacklist-empty {
color: #a0aec0 !important;
}
html.utst-theme-light #utstTranslationBox .utst-blacklist-remove {
background: #edf2f7 !important;
color: #718096 !important;
}
html.utst-theme-light #utstTranslationBox #settingsPanel #bubbleBlacklistAdd {
color: #ffffff !important;
}
html.utst-theme-light #utstTranslationBox #settingsPanel #bubbleBlacklistAdd:hover {
color: #ffffff !important;
}
html.utst-theme-light #utstTranslationBox #settingsHeader {
background: #ffffff !important;
border-color: rgba(148, 163, 184, 0.45) !important;
}
html.utst-theme-light #utstTranslationBox #settingsPanel {
background: transparent !important;
}
html.utst-theme-light #utstTranslationBox #panelThemeTrigger {
background: #ffffff !important;
border: 1px solid #94a3b8 !important;
color: #2d3748 !important;
}
html.utst-theme-light #utstTranslationBox #panelThemePanel {
background: #ffffff !important;
border: 1px solid #cbd5e0 !important;
}
html.utst-theme-light #utstTranslationBox .utst-bubble-settings {
border-top-color: rgba(74, 85, 104, 0.28) !important;
}
html.utst-theme-light #utstTranslationBox #speakTooltip {
background: #ffffff !important;
border: 1px solid #e2e8f0 !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.05) !important;
}
html.utst-theme-light #utstTranslationBox #speakTooltip .utst-speak-option:hover {
background: rgba(45, 92, 190, 0.14) !important;
color: #1f3f73 !important;
}
html.utst-theme-blue #utstTranslationBox #speakTooltip {
background: rgba(20, 36, 64, 0.98) !important;
border: 1px solid rgba(139, 177, 255, 0.34) !important;
box-shadow: 0 10px 24px rgba(6, 15, 35, 0.48) !important;
}
html.utst-theme-blue #utstTranslationBox #speakTooltip .utst-speak-option:hover {
background: rgba(120, 165, 255, 0.22) !important;
color: #e9f1ff !important;
}
html.utst-theme-light #fullscreenOverlay {
background: rgba(0, 0, 0, 0.65) !important;
backdrop-filter: blur(8px) !important;
}
html.utst-theme-light #fullscreenPanel {
background: linear-gradient(135deg, #ffffff 0%, #f7f9fc 100%) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 20px 50px rgba(0,0,0,0.1) !important;
}
html.utst-theme-light #fullscreenPanel svg {
stroke: #4a5568;
}
html.utst-theme-light #fullscreenPanel #fullscreenClose svg {
stroke: #ef4444 !important;
}
html.utst-theme-light #fullscreenPanel #fullscreenSourceCopy,
html.utst-theme-light #fullscreenPanel #fullscreenSourceSpeak,
html.utst-theme-light #fullscreenPanel #fullscreenTargetCopy,
html.utst-theme-light #fullscreenPanel #fullscreenTargetSpeak {
background: #ffffff !important;
border: 1px solid #cbd5e0 !important;
}
html.utst-theme-light #fullscreenPanel #fullscreenSourceCopy:hover,
html.utst-theme-light #fullscreenPanel #fullscreenSourceSpeak:hover,
html.utst-theme-light #fullscreenPanel #fullscreenTargetCopy:hover,
html.utst-theme-light #fullscreenPanel #fullscreenTargetSpeak:hover {
background: #f8fafc !important;
border-color: #94a3b8 !important;
}
html.utst-theme-light #fullscreenPanel textarea,
html.utst-theme-light #fullscreenPanel input,
html.utst-theme-light #fullscreenPanel button[id$="LangTrigger"] {
background: #ffffff !important;
border: 1px solid #cbd5e0 !important;
color: #2d3748 !important;
}
html.utst-theme-light #fullscreenPanel [id$="LangPanel"] {
background: #ffffff !important;
border: 1px solid #e2e8f0 !important;
box-shadow: 0 10px 15px rgba(0,0,0,0.05) !important;
}
`);
const translationLibrary = (typeof window !== 'undefined' ? window.TraductionOutilTranslator : null)
|| (typeof globalThis !== 'undefined' ? globalThis.TraductionOutilTranslator : null);
if (!translationLibrary || !translationLibrary.languageNames) {
console.error('[Ultimate Translator] Missing TraductionOutilTranslator language library.');
return;
}
const browserLang = navigator.language.split('-')[0];
const languageNames = translationLibrary.languageNames;
const englishLangNames = languageNames.en || {};
const supportedUiLanguages = Array.isArray(translationLibrary.supportedUiLanguages) && translationLibrary.supportedUiLanguages.length
? translationLibrary.supportedUiLanguages
: Object.keys(languageNames);
const storedToolLangPref = GM_getValue('defaultToolLang', 'browser');
const normalizedToolLangPref = (storedToolLangPref === 'browser' || supportedUiLanguages.includes(storedToolLangPref))
? storedToolLangPref
: 'browser';
if (normalizedToolLangPref !== storedToolLangPref) {
GM_setValue('defaultToolLang', normalizedToolLangPref);
}
function resolveUiLang(preference) {
if (preference === 'browser') {
return languageNames[browserLang] ? browserLang : 'en';
}
return languageNames[preference] ? preference : (languageNames[browserLang] ? browserLang : 'en');
}
let toolLanguagePreference = normalizedToolLangPref;
const uiLang = resolveUiLang(toolLanguagePreference);
let langNames = languageNames[uiLang];
let errors = langNames.errors;
let tooltips = langNames.tooltips;
let dragHandleLabel = langNames.dragHandleLabel || languageNames.en.dragHandleLabel;
let overlayLabels = langNames.overlay || languageNames.en.overlay;
let settingsTitle = langNames.settingsTitle || languageNames.en.settingsTitle;
let settingsDefaultLabel = langNames.settingsDefaultLabel || languageNames.en.settingsDefaultLabel;
let settingsToolLabel = langNames.settingsToolLabel || languageNames.en.settingsToolLabel;
const languages = [
{ code: 'auto', name: englishLangNames.auto || langNames.auto },
{ code: 'en', name: englishLangNames.en || 'English' },
{ code: 'fr', name: englishLangNames.fr || 'French' },
{ code: 'es', name: englishLangNames.es || 'Spanish' },
{ code: 'de', name: englishLangNames.de || 'German' },
{ code: 'it', name: englishLangNames.it || 'Italian' },
{ code: 'pt', name: englishLangNames.pt || 'Portuguese' },
{ code: 'ru', name: englishLangNames.ru || 'Russian' },
{ code: 'zh-CN', name: englishLangNames['zh-CN'] || 'Chinese (Simplified)' },
{ code: 'ja', name: englishLangNames.ja || 'Japanese' },
{ code: 'navigator', name: englishLangNames.navigator || 'Browser language' }
];
const googleTranslateLanguages = {
'af': 'Afrikaans',
'sq': 'Albanian',
'am': 'Amharic',
'ar': 'Arabic',
'hy': 'Armenian',
'az': 'Azerbaijani',
'eu': 'Basque',
'be': 'Belarusian',
'bn': 'Bengali',
'bs': 'Bosnian',
'bg': 'Bulgarian',
'ca': 'Catalan',
'ceb': 'Cebuano',
'ny': 'Chichewa',
'zh-CN': 'Chinese (Simplified)',
'zh-TW': 'Chinese (Traditional)',
'co': 'Corsican',
'hr': 'Croatian',
'cs': 'Czech',
'da': 'Danish',
'nl': 'Dutch',
'en': 'English',
'eo': 'Esperanto',
'et': 'Estonian',
'tl': 'Filipino',
'fi': 'Finnish',
'fr': 'French',
'gl': 'Galician',
'ka': 'Georgian',
'de': 'German',
'el': 'Greek',
'gu': 'Gujarati',
'ht': 'Haitian Creole',
'ha': 'Hausa',
'haw': 'Hawaiian',
'he': 'Hebrew',
'hi': 'Hindi',
'hmn': 'Hmong',
'hu': 'Hungarian',
'is': 'Icelandic',
'ig': 'Igbo',
'id': 'Indonesian',
'ga': 'Irish',
'it': 'Italian',
'ja': 'Japanese',
'jw': 'Javanese',
'kn': 'Kannada',
'kk': 'Kazakh',
'km': 'Khmer',
'rw': 'Kinyarwanda',
'ko': 'Korean',
'ku': 'Kurdish',
'ky': 'Kyrgyz',
'lo': 'Lao',
'la': 'Latin',
'lv': 'Latvian',
'lt': 'Lithuanian',
'lb': 'Luxembourgish',
'mk': 'Macedonian',
'mg': 'Malagasy',
'ms': 'Malay',
'ml': 'Malayalam',
'mt': 'Maltese',
'mi': 'Maori',
'mr': 'Marathi',
'mn': 'Mongolian',
'my': 'Myanmar',
'ne': 'Nepali',
'no': 'Norwegian',
'or': 'Odia',
'ps': 'Pashto',
'fa': 'Persian',
'pl': 'Polish',
'pt': 'Portuguese',
'pa': 'Punjabi',
'ro': 'Romanian',
'ru': 'Russian',
'sm': 'Samoan',
'gd': 'Scots Gaelic',
'sr': 'Serbian',
'st': 'Sesotho',
'sn': 'Shona',
'sd': 'Sindhi',
'si': 'Sinhala',
'sk': 'Slovak',
'sl': 'Slovenian',
'so': 'Somali',
'es': 'Spanish',
'su': 'Sundanese',
'sw': 'Swahili',
'sv': 'Swedish',
'tg': 'Tajik',
'ta': 'Tamil',
'tt': 'Tatar',
'te': 'Telugu',
'th': 'Thai',
'tr': 'Turkish',
'tk': 'Turkmen',
'uk': 'Ukrainian',
'ur': 'Urdu',
'ug': 'Uyghur',
'uz': 'Uzbek',
'vi': 'Vietnamese',
'cy': 'Welsh',
'xh': 'Xhosa',
'yi': 'Yiddish',
'yo': 'Yoruba',
'zu': 'Zulu'
};
const defaultTargetLang = languages.some(lang => lang.code === browserLang && lang.code !== 'auto') ? browserLang : 'en';
const commonFavoriteTargetLangs = ['en', 'fr', 'es', 'de', 'it', 'pt', 'ru', 'zh-CN', 'ja'];
const favoriteTargetLangs = ['navigator'];
if (googleTranslateLanguages[browserLang] && !favoriteTargetLangs.includes(browserLang)) {
favoriteTargetLangs.push(browserLang);
}
commonFavoriteTargetLangs.forEach(code => {
if (!favoriteTargetLangs.includes(code)) {
favoriteTargetLangs.push(code);
}
});
const sortedGoogleLanguageEntries = Object.entries(googleTranslateLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB));
function getLanguageLabel(code) {
if (code === 'auto') {
return langNames.auto || englishLangNames.auto || 'Detect language';
}
if (code === 'navigator') {
return englishLangNames.navigator || 'Browser language';
}
return englishLangNames[code] || googleTranslateLanguages[code] || code;
}
function buildTargetLanguageOptions(includeNavigator = false) {
const favorites = favoriteTargetLangs
.filter(code => code === 'navigator' ? includeNavigator : googleTranslateLanguages[code])
.map(code => {
const optionValue = code === 'navigator' ? 'navigator' : code;
return `<option value="${optionValue}">${getLanguageLabel(optionValue)}</option>`;
})
.join('');
const favoriteCodes = new Set(favoriteTargetLangs.filter(code => code !== 'navigator'));
const others = sortedGoogleLanguageEntries
.filter(([code]) => !favoriteCodes.has(code))
.map(([code, name]) => `<option value="${code}">${name}</option>`)
.join('');
const parts = [];
if (favorites) {
parts.push(favorites);
}
if (others) {
if (favorites) {
parts.push('<option value="" disabled>--------------------</option>');
}
parts.push(others);
}
return parts.join('');
}
function getToolLanguageLabel(code) {
if (code === 'browser') {
return englishLangNames.navigator || 'Browser language';
}
return englishLangNames[code] || languageNames.en[code] || code;
}
function buildToolLanguageOptionsHtml() {
return ['browser', ...supportedUiLanguages]
.map(code => `<option value="${code}">${getToolLanguageLabel(code)}</option>`)
.join('');
}
function buildSourceLanguageOptionsHtml() {
const entries = Object.entries(googleTranslateLanguages)
.sort(([, a], [, b]) => a.localeCompare(b));
const options = entries
.map(([code, name]) => `<option value="${code}">${name}</option>`)
.join('');
return `<option value="auto">${langNames.auto}</option>${options}`;
}
const toolLanguageOptionsHtml = buildToolLanguageOptionsHtml();
let sourceLanguageOptionsHtml = buildSourceLanguageOptionsHtml();
const targetLanguageOptionsHtml = buildTargetLanguageOptions(true);
const translationBox = document.createElement('div');
translationBox.id = 'utstTranslationBox';
translationBox.style.cssText = `
position: absolute;
background: linear-gradient(135deg, #1e1e2f 0%, #2a2a4a 100%);
color: #ffffff;
padding: 20px;
padding-top: 40px;
border-radius: 12px;
z-index: 9999;
display: none;
min-width: 370px;
max-width: 420px;
min-height: 200px;
max-height: 260px;
overflow-y: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.3s ease;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
`;
document.body.appendChild(translationBox);
translationBox.innerHTML = `
<div id="dragHandle" style="position:absolute; top:0; left:0; right:0; height:28px; background: linear-gradient(120deg, #3a3a3f, #4b4b52); border-radius: 12px 12px 0 0; cursor: move; display:flex; align-items:center; gap:8px; padding:0 12px; color:#e5e5e5; font-size:12px; font-weight:600; letter-spacing:0.3px; box-shadow: inset 0 -1px 0 rgba(255,255,255,0.08); user-select: none;">
<div style="width:44px; height:4px; border-radius:4px; background:rgba(255,255,255,0.4);"></div>
<span style="opacity:0.9;">${dragHandleLabel}</span>
</div>
<div style="
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: #ff4d4d;
font-size: 18px;
font-weight: bold;
cursor: pointer;
line-height: 1;">
<div id="closeButton" style="cursor: pointer;" title="Fermer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ff4d4d" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</div>
<div id="settingsHeader" style="position: absolute; top: 34px; left: 8px; display:none; align-items: center; gap: 8px; cursor: default;">
<div id="backButton" style="cursor: pointer;" title="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</div>
<span id="settingsHeaderTitle" style="color:#fff; font-size:14px; font-weight:600; letter-spacing:0.3px;">${settingsTitle}</span>
</div>
<div id="translatorPanel">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;user-select: none;">
<select id="sourceLang" style="background: rgba(255, 255, 255, 0.1); color: #fff; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; padding: 6px; font-size: 13px; cursor: pointer;">
<option value="auto">Detect language</option>
${Object.entries(googleTranslateLanguages).map(([code, name]) =>
`<option value="${code}">${name}</option>`).join('')}
</select>
<span style="color: #a0a0c0; margin: 0 8px;">→</span>
<select id="targetLang"
style="background: rgba(255, 255, 255, 0.1); color: #fff; border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 6px; padding: 6px; font-size: 13px; cursor: pointer;">
${targetLanguageOptionsHtml}
</select>
</div>
<div id="translationTextWrap" style="position:relative;">
<div id="translationText"
style="background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 8px; min-height: 110px; height: 110px; max-height: 110px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; overflow-y: auto;">
</div>
<div id="utstPanelLoading" class="utst-modern-loader" data-mode="translate" aria-hidden="true" style="border-radius:8px;">
<div class="utst-modern-loader__card">
<div class="utst-modern-loader__ring"></div>
<div class="utst-modern-loader__body">
<div id="utstPanelLoadingTitle" class="utst-modern-loader__title">${overlayLabels.translate}...</div>
<div class="utst-modern-loader__line"></div>
</div>
</div>
</div>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 12px; gap: 10px; margin-bottom: 12px;">
<div id="speakButton" style="position: relative; cursor: pointer;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
<div id="speakTooltip"
style="display: none; position: absolute; bottom: 100%; right: 0; background: rgba(0, 0, 0, 0.8); color: #fff; padding: 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; z-index: 10000;">
<div id="speakTranslated" class="utst-speak-option" style="padding: 6px 10px; cursor: pointer; border-radius:3px; transition: background 0.15s ease, color 0.15s ease;">${tooltips.listenTranslated}</div>
<div id="speakOriginal" class="utst-speak-option" style="padding: 6px 10px; cursor: pointer; border-radius:3px; transition: background 0.15s ease, color 0.15s ease;">${tooltips.listenOriginal}</div>
</div>
</div>
<div id="copyButton" style="cursor: pointer;" title="Copy translation">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
<div id="fullscreenToggle" style="cursor: pointer;" title="${overlayLabels.open}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</div>
<div id="settingsButton" style="cursor: pointer;" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 15.5C13.933 15.5 15.5 13.933 15.5 12C15.5 10.067 13.933 8.5 12 8.5C10.067 8.5 8.5 10.067 8.5 12C8.5 13.933 10.067 15.5 12 15.5Z"
stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path
d="M19.4 15C19.2669 15.3016 19.2272 15.6362 19.286 15.9606C19.3448 16.285 19.4995 16.5843 19.73 16.82L19.79 16.88C19.976 17.0657 20.1235 17.2863 20.2241 17.5291C20.3248 17.7719 20.3766 18.0322 20.3766 18.295C20.3766 18.5578 20.3248 18.8181 20.2241 19.0609C20.1235 19.3037 19.976 19.5243 19.79 19.71C19.6043 19.896 19.3837 20.0435 19.1409 20.1441C18.8981 20.2448 18.6378 20.2966 18.375 20.2966C18.1122 20.2966 17.8519 20.2448 17.6091 20.1441C17.3663 20.0435 17.1457 19.896 16.96 19.71L16.9 19.65C16.6643 19.4195 16.365 19.2648 16.0406 19.206C15.7162 19.1472 15.3816 19.1869 15.08 19.32C14.7842 19.4468 14.532 19.6572 14.3543 19.9255C14.1766 20.1938 14.0813 20.5082 14.08 20.83V21C14.08 21.5304 13.8693 22.0391 13.4942 22.4142C13.1191 22.7893 12.6104 23 12.08 23C11.5496 23 11.0409 22.7893 10.6658 22.4142C10.2907 22.0391 10.08 21.5304 10.08 21V20.91C10.0723 20.579 9.96512 20.258 9.77251 19.9887C9.5799 19.7194 9.31074 19.5143 9 19.4C8.69838 19.2669 8.36381 19.2272 8.03941 19.286C7.71502 19.3448 7.41568 19.4995 7.18 19.73L7.12 19.79C6.93425 19.976 6.71368 20.1235 6.47088 20.2241C6.22808 20.3248 5.96783 20.3766 5.705 20.3766C5.44217 20.3766 5.18192 20.3248 4.93912 20.2241C4.69632 20.1235 4.47575 19.976 4.29 19.79C4.10405 19.6043 3.95653 19.3837 3.85588 19.1409C3.75523 18.8981 3.70343 18.6378 3.70343 18.375C3.70343 18.1122 3.75523 17.8519 3.85588 17.6091C3.95653 17.3663 4.10405 17.1457 4.29 16.96L4.35 16.9C4.58054 16.6643 4.73519 16.365 4.794 16.0406C4.85282 15.7162 4.81312 15.3816 4.68 15.08C4.55324 14.7842 4.34276 14.532 4.07447 14.3543C3.80618 14.1766 3.49179 14.0813 3.17 14.08H3C2.46957 14.08 1.96086 13.8693 1.58579 13.4942C1.21071 13.1191 1 12.6104 1 12.08C1 11.5496 1.21071 11.0409 1.58579 10.6658C1.96086 10.2907 2.46957 10.08 3 10.08H3.09C3.42099 10.0723 3.742 9.96512 4.0113 9.77251C4.28059 9.5799 4.48572 9.31074 4.6 9C4.73312 8.69838 4.77282 8.36381 4.714 8.03941C4.65519 7.71502 4.50054 7.41568 4.27 7.18L4.21 7.12C4.02405 6.93425 3.87653 6.71368 3.77588 6.47088C3.67523 6.22808 3.62343 5.96783 3.62343 5.705C3.62343 5.44217 3.67523 5.18192 3.77588 4.93912C3.87653 4.69632 4.02405 4.47575 4.21 4.29C4.39575 4.10405 4.61632 3.95653 4.85912 3.85588C5.10192 3.75523 5.36217 3.70343 5.625 3.70343C5.88783 3.70343 6.14808 3.75523 6.39088 3.85588C6.63368 3.95653 6.85425 4.10405 7.04 4.29L7.1 4.35C7.33568 4.58054 7.63502 4.73519 7.95941 4.794C8.28381 4.85282 8.61838 4.81312 8.92 4.68H9C9.29577 4.55324 9.54802 4.34276 9.72569 4.07447C9.90337 3.80618 9.99872 3.49179 10 3.17V3C10 2.46957 10.2107 1.96086 10.5858 1.58579C10.9609 1.21071 11.4696 1 12 1C12.5304 1 13.0391 1.21071 13.4142 1.58579C13.7893 1.96086 14 2.46957 14 3V3.09C14.0013 3.41179 14.0966 3.72618 14.2743 3.99447C14.452 4.26276 14.7042 4.47324 15 4.6C15.3016 4.73312 15.6362 4.77282 15.9606 4.714C16.285 4.65519 16.5843 4.50054 16.82 4.27L16.88 4.21C17.0657 4.02405 17.2863 3.87653 17.5291 3.77588C17.7719 3.67523 18.0322 3.62343 18.295 3.62343C18.5578 3.62343 18.8181 3.67523 19.0609 3.77588C19.3037 3.87653 19.5243 4.02405 19.71 4.21C19.896 4.39575 20.0435 4.61632 20.1441 4.85912C20.2448 5.10192 20.2966 5.36217 20.2966 5.625C20.2966 5.88783 20.2448 6.14808 20.1441 6.39088C20.0435 6.63368 19.896 6.85425 19.71 7.04L19.65 7.1C19.4195 7.33568 19.2648 7.63502 19.206 7.95941C19.1472 8.28381 19.1869 8.61838 19.32 8.92V9C19.4468 9.29577 19.6572 9.54802 19.9255 9.72569C20.1938 9.90337 20.5082 9.99872 20.83 10H21C21.5304 10 22.0391 10.2107 22.4142 10.5858C22.7893 10.9609 23 11.4696 23 12C23 12.5304 22.7893 13.0391 22.4142 13.4142C22.0391 13.7893 21.5304 14 21 14H20.91C20.5882 14.0013 20.2738 14.0966 20.0055 14.2743C19.7372 14.452 19.5268 14.7042 19.4 15Z"
stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>
</div>
</div>
</div>
<div id="settingsPanel" style="display:none; padding:31px 20px 20px; min-height:176px; max-height:200px; min-width:370px; max-width:370px; overflow-y:auto; box-sizing:border-box;">
<label for="defaultTranslateLang" style="color:#fff; font-size:14px; display:block; margin-bottom:4px;">
${settingsDefaultLabel}
</label>
<select id="defaultTranslateLang" style="display:block; width:100%; max-width:260px; margin:0 auto; padding:5px 6px; border-radius:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.2); font-size:13px; cursor: pointer;">
${targetLanguageOptionsHtml}
</select>
<label for="toolLanguage" style="color:#fff; font-size:14px; display:block; margin:12px 0 4px;">
${settingsToolLabel}
</label>
<select id="toolLanguage" style="display:block; width:100%; max-width:260px; margin:0 auto; padding:5px 6px; border-radius:6px; background:rgba(255,255,255,0.1); color:#fff; border:1px solid rgba(255,255,255,0.2); font-size:13px; cursor: pointer;">
${toolLanguageOptionsHtml}
</select>
<label for="panelTheme" style="color:#fff; font-size:14px; display:block; margin:12px 0 4px;">
${langNames.settingsThemeLabel}
</label>
<div id="panelThemePicker" style="position:relative; width:100%; max-width:260px; margin:0 auto;">
<button id="panelThemeTrigger" type="button" style="display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; padding:6px 10px; border-radius:8px; background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); color:#fff; cursor:pointer; font-size:12px;">
<span id="panelThemeCurrent">${langNames.themes.blue}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<div id="panelThemePanel" style="display:none; position:absolute; top:calc(100% + 6px); right:0; left:0; width:100%; max-height:220px; border-radius:10px; padding:8px; z-index:2147483646;">
<div id="panelThemeGrid" style="display:grid; grid-template-columns:1fr; gap:6px;"></div>
</div>
</div>
<select id="panelTheme" style="display:none;">
<option value="blue">${langNames.themes.blue}</option>
<option value="dark">${langNames.themes.dark}</option>
<option value="light">${langNames.themes.light}</option>
</select>
<div class="utst-bubble-settings">
<label class="utst-toggle-row" for="selectionBubbleEnabled">
<input id="selectionBubbleEnabled" type="checkbox" />
<span>${langNames.settingsBubbleLabel}</span>
</label>
<label for="bubbleBlacklistInput" style="color:#fff; font-size:13px; display:block; margin-bottom:4px;">
${langNames.settingsBlacklistLabel}
</label>
<div class="utst-blacklist-controls">
<input id="bubbleBlacklistInput" class="utst-blacklist-input" type="text" placeholder="example.com" />
<button id="bubbleBlacklistAdd" class="utst-blacklist-add" type="button">${langNames.settingsBlacklistAdd}</button>
</div>
<div id="bubbleBlacklistList" class="utst-blacklist-list utst-scroll"></div>
</div>
</div>
`;
translationBox.classList.add("utst-scroll");
const fullscreenOverlay = document.createElement('div');
fullscreenOverlay.id = 'fullscreenOverlay';
fullscreenOverlay.style.cssText = `
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(8px);
z-index: 10001;
padding: 18px;
`;
fullscreenOverlay.innerHTML = `
<div id="fullscreenPanel" style="width: min(1100px, 95vw); min-height: 40vh; background: linear-gradient(135deg, #1e1e2f 0%, #2a2a4a 100%); color: #fff; border-radius: 14px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 12px 32px rgba(0,0,0,0.45); padding: 22px 22px 16px; position: relative;">
<div style="display:flex; align-items:center; justify-content: space-between; margin-bottom: 14px;">
<div id="fullscreenTitle" style="font-size:16px; font-weight:700; letter-spacing:0.4px; color:#e7e9ff; cursor: default;">${overlayLabels.title}</div>
<div id="fullscreenClose" style="cursor:pointer; width:26px; height:26px; display:flex; align-items:center; justify-content:center; border-radius:8px; transition: background 0.15s ease;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ff6b6b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
</div>
<div style="display:flex; gap: 16px; min-height: 280px; flex-wrap: wrap;">
<div style="flex:1; min-width:280px; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
<label id="fullscreenSourceLabel" style="color:#cfd3ff; font-size:13px; font-weight:600; letter-spacing:0.2px;">${overlayLabels.source}</label>
<div id="fullscreenSourcePicker" style="position:relative;">
<button id="fullscreenSourceLangTrigger" style="display:flex; align-items:center; gap:6px; padding:6px 10px; border-radius:8px; background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); color:#fff; cursor:pointer; font-size:12px;">
<span id="fullscreenSourceLangCurrent">${langNames.auto}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<div id="fullscreenSourceLangPanel" style="display:none; position:absolute; top:110%; right:0; width:280px; max-height:260px; background: rgba(30,30,47,0.98); border:1px solid rgba(255,255,255,0.12); box-shadow:0 10px 24px rgba(0,0,0,0.35); border-radius:10px; padding:8px; z-index:10002;">
<input id="fullscreenSourceLangSearch" placeholder="${langNames.navigator}" style="width:100%; max-width:100%; box-sizing:border-box; padding:8px 10px; border-radius:8px; border:1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.08); color:#fff; font-size:13px; outline:none;" />
<div id="fullscreenSourceLangGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; max-height:190px; overflow-y:auto; padding-top:8px;"></div>
</div>
</div>
</div>
<select id="fullscreenSourceLang" style="display:none;">${sourceLanguageOptionsHtml}</select>
<textarea id="fullscreenSource" style="flex:1; min-height:200px; padding:12px; border-radius:10px; border:1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06); color:#fff; font-size:14px; line-height:1.5; resize: vertical; outline:none; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);"></textarea>
<div style="display:flex; gap:8px; margin-top:6px;">
<div id="fullscreenSourceCopy" style="width:38px; height:38px; border-radius:9px; border:1px solid rgba(255,255,255,0.16); display:flex; align-items:center; justify-content:center; cursor:pointer; background: rgba(255,255,255,0.06);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
<div id="fullscreenSourceSpeak" style="width:38px; height:38px; border-radius:9px; border:1px solid rgba(255,255,255,0.16); display:flex; align-items:center; justify-content:center; cursor:pointer; background: rgba(255,255,255,0.06);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
<div id="fullscreenSwap" title="Swap" style="align-self:center; width:40px; height:40px; border-radius:10px; background: transparent; border:none; box-shadow:none; display:flex; align-items:center; justify-content:center; cursor:pointer; transition: transform 0.2s ease;">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"></polyline>
<line x1="3" y1="5" x2="21" y2="5"></line>
<polyline points="7 23 3 19 7 15"></polyline>
<line x1="21" y1="19" x2="3" y2="19"></line>
</svg>
</div>
<div style="flex:1; min-width:280px; display:flex; flex-direction:column; gap:8px;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
<label id="fullscreenTargetLabel" style="color:#cfd3ff; font-size:13px; font-weight:600; letter-spacing:0.2px;">${overlayLabels.target}</label>
<div id="fullscreenTargetPicker" style="position:relative;">
<button id="fullscreenTargetLangTrigger" style="display:flex; align-items:center; gap:6px; padding:6px 10px; border-radius:8px; background: rgba(255,255,255,0.08); border:1px solid rgba(255,255,255,0.14); color:#fff; cursor:pointer; font-size:12px;">
<span id="fullscreenTargetLangCurrent">${getLanguageLabel(defaultTargetLang)}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<div id="fullscreenTargetLangPanel" style="display:none; position:absolute; top:110%; right:0; width:280px; max-height:260px; background: rgba(30,30,47,0.98); border:1px solid rgba(255,255,255,0.12); box-shadow:0 10px 24px rgba(0,0,0,0.35); border-radius:10px; padding:8px; z-index:10002;">
<input id="fullscreenTargetLangSearch" placeholder="${langNames.navigator}" style="width:100%; max-width:100%; box-sizing:border-box; padding:8px 10px; border-radius:8px; border:1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.08); color:#fff; font-size:13px; outline:none;" />
<div id="fullscreenTargetLangGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; max-height:190px; overflow-y:auto; padding-top:8px;"></div>
</div>
</div>
</div>
<select id="fullscreenTargetLang" style="display:none;">${targetLanguageOptionsHtml}</select>
<div id="fullscreenTargetWrap" style="position:relative; flex:1; min-height:200px;">
<textarea id="fullscreenTarget" style="width:100%; height:100%; min-height:200px; padding:12px; border-radius:10px; border:1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06); color:#fff; font-size:14px; line-height:1.5; resize: vertical; outline:none; box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);"></textarea>
<div id="utstFullscreenLoading" class="utst-modern-loader" data-mode="translate" aria-hidden="true" style="border-radius:10px;">
<div class="utst-modern-loader__card">
<div class="utst-modern-loader__ring"></div>
<div class="utst-modern-loader__body">
<div id="utstFullscreenLoadingTitle" class="utst-modern-loader__title">${overlayLabels.translate}...</div>
<div class="utst-modern-loader__line"></div>
</div>
</div>
</div>
</div>
<div style="display:flex; gap:8px; margin-top:6px;">
<div id="fullscreenTargetCopy" style="width:38px; height:38px; border-radius:9px; border:1px solid rgba(255,255,255,0.16); display:flex; align-items:center; justify-content:center; cursor:pointer; background: rgba(255,255,255,0.06);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</div>
<div id="fullscreenTargetSpeak" style="width:38px; height:38px; border-radius:9px; border:1px solid rgba(255,255,255,0.16); display:flex; align-items:center; justify-content:center; cursor:pointer; background: rgba(255,255,255,0.06);">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
</div>
</div>
`;
fullscreenOverlay.classList.add("utst-scroll");
document.body.appendChild(fullscreenOverlay);
const selectionBubble = document.createElement('div');
selectionBubble.id = 'utstSelectionBubble';
selectionBubble.innerHTML = `
<button id="utstSelectionBubbleClose" type="button" title="${langNames.bubble.closeTitle}" aria-label="${langNames.bubble.closeTitle}">×</button>
<div id="utstSelectionBubbleDivider" aria-hidden="true"></div>
<button id="utstSelectionBubbleAction" type="button" title="${langNames.bubble.translateTitle}" aria-label="${langNames.bubble.translateTitle}">
<svg viewBox="0 0 24 24" aria-hidden="true" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 8l6 6"></path>
<path d="M4 14l6-6 2-3"></path>
<path d="M2 5h12"></path>
<path d="M7 2h1"></path>
<path d="M22 22l-5-10-5 10"></path>
<path d="M14 18h6"></path>
</svg>
</button>
<div id="utstBubbleCloseMenu">
<button id="utstBubbleHideSite" class="utst-bubble-menu-btn" type="button">${langNames.bubble.hideSite}</button>
<button id="utstBubbleHideGlobal" class="utst-bubble-menu-btn" type="button">${langNames.bubble.hideGlobal}</button>
</div>
`;
document.body.appendChild(selectionBubble);
const selectionBubbleClose = selectionBubble.querySelector('#utstSelectionBubbleClose');
const selectionBubbleAction = selectionBubble.querySelector('#utstSelectionBubbleAction');
const bubbleCloseMenu = selectionBubble.querySelector('#utstBubbleCloseMenu');
const bubbleHideSiteButton = selectionBubble.querySelector('#utstBubbleHideSite');
const bubbleHideGlobalButton = selectionBubble.querySelector('#utstBubbleHideGlobal');
const BOX_W = 420;
const BOX_H = 260;
const MARGIN = 10;
function placeBoxAtSelection(fallbackPosition) {
const sel = window.getSelection();
if (!sel || !sel.rangeCount) {
if (fallbackPosition && Number.isFinite(fallbackPosition.x) && Number.isFinite(fallbackPosition.y)) {
const { left, top } = clampBoxPosition(fallbackPosition.x, fallbackPosition.y + MARGIN);
translationBox.style.left = `${left}px`;
translationBox.style.top = `${top}px`;
}
return;
}
const rect = sel.getRangeAt(0).getBoundingClientRect();
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
let left = rect.left + scrollX;
const topBelow = rect.bottom + scrollY + MARGIN;
const topAbove = rect.top + scrollY - BOX_H - MARGIN;
const vpLeft = scrollX + MARGIN;
const vpRight = scrollX + window.innerWidth - MARGIN;
const vpBottom = scrollY + window.innerHeight - MARGIN;
if (left + BOX_W > vpRight) left = vpRight - BOX_W;
if (left < vpLeft) left = vpLeft;
let top;
if (topBelow + BOX_H <= vpBottom) {
top = topBelow;
} else {
top = Math.max(topAbove, scrollY + MARGIN);
}
translationBox.style.left = `${left}px`;
translationBox.style.top = `${top}px`;
}
const dragHandle = translationBox.querySelector('#dragHandle');
let isDragging = false;
let dragStartMouseX = 0;
let dragStartMouseY = 0;
let dragStartLeft = 0;
let dragStartTop = 0;
let previousUserSelect = '';
function clampBoxPosition(left, top) {
const width = translationBox.offsetWidth || BOX_W;
const height = translationBox.offsetHeight || BOX_H;
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
const minLeft = scrollX + MARGIN;
const maxLeft = scrollX + window.innerWidth - width - MARGIN;
const minTop = scrollY + MARGIN;
const maxTop = scrollY + window.innerHeight - height - MARGIN;
return {
left: Math.min(Math.max(minLeft, left), maxLeft),
top: Math.min(Math.max(minTop, top), maxTop)
};
}
window.addEventListener('resize', () => {
if (translationBox.style.display === 'block') placeBoxAtSelection();
});
if (dragHandle) {
dragHandle.addEventListener('mousedown', (e) => {
isDragging = true;
const rect = translationBox.getBoundingClientRect();
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
dragStartMouseX = e.clientX;
dragStartMouseY = e.clientY;
dragStartLeft = parseFloat(translationBox.style.left) || rect.left + scrollX;
dragStartTop = parseFloat(translationBox.style.top) || rect.top + scrollY;
previousUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
});
}
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const newLeft = dragStartLeft + (e.clientX - dragStartMouseX);
const newTop = dragStartTop + (e.clientY - dragStartMouseY);
const { left, top } = clampBoxPosition(newLeft, newTop);
translationBox.style.left = `${left}px`;
translationBox.style.top = `${top}px`;
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
document.body.style.userSelect = previousUserSelect;
});
const sourceLangSelect = translationBox.querySelector('#sourceLang');
const targetLangSelect = translationBox.querySelector('#targetLang');
const translationText = translationBox.querySelector('#translationText');
const panelLoadingOverlay = translationBox.querySelector('#utstPanelLoading');
const panelLoadingTitle = translationBox.querySelector('#utstPanelLoadingTitle');
const speakButton = translationBox.querySelector('#speakButton');
const speakTooltip = translationBox.querySelector('#speakTooltip');
const speakTranslated = translationBox.querySelector('#speakTranslated');
const speakOriginal = translationBox.querySelector('#speakOriginal');
const copyButton = translationBox.querySelector('#copyButton');
const settingsButton = translationBox.querySelector('#settingsButton');
const backButton = translationBox.querySelector('#backButton');
const defaultTranslateLangSelect = translationBox.querySelector('#defaultTranslateLang');
const toolLanguageSelect = translationBox.querySelector('#toolLanguage');
const panelThemeSelect = translationBox.querySelector('#panelTheme');
const panelThemeTrigger = translationBox.querySelector('#panelThemeTrigger');
const panelThemeCurrent = translationBox.querySelector('#panelThemeCurrent');
const panelThemePanel = translationBox.querySelector('#panelThemePanel');
const panelThemeGrid = translationBox.querySelector('#panelThemeGrid');
const selectionBubbleEnabledCheckbox = translationBox.querySelector('#selectionBubbleEnabled');
const bubbleBlacklistInput = translationBox.querySelector('#bubbleBlacklistInput');
const bubbleBlacklistAddButton = translationBox.querySelector('#bubbleBlacklistAdd');
const bubbleBlacklistList = translationBox.querySelector('#bubbleBlacklistList');
const defaultTranslateLangLabel = translationBox.querySelector('label[for="defaultTranslateLang"]');
const toolLanguageLabel = translationBox.querySelector('label[for="toolLanguage"]');
const panelThemeLabel = translationBox.querySelector('label[for="panelTheme"]');
const bubbleToggleLabel = translationBox.querySelector('label[for="selectionBubbleEnabled"] span');
const bubbleBlacklistLabel = translationBox.querySelector('label[for="bubbleBlacklistInput"]');
const sourceAutoOption = sourceLangSelect.querySelector('option[value="auto"]');
const settingsHeader = translationBox.querySelector('#settingsHeader');
const settingsHeaderTitle = translationBox.querySelector('#settingsHeaderTitle');
const fullscreenTitleEl = fullscreenOverlay.querySelector('#fullscreenTitle');
const fullscreenClose = fullscreenOverlay.querySelector('#fullscreenClose');
const fullscreenSourceLangSelect = fullscreenOverlay.querySelector('#fullscreenSourceLang');
const fullscreenTargetLangSelect = fullscreenOverlay.querySelector('#fullscreenTargetLang');
const fullscreenSourceLangCurrent = fullscreenOverlay.querySelector('#fullscreenSourceLangCurrent');
const fullscreenTargetLangCurrent = fullscreenOverlay.querySelector('#fullscreenTargetLangCurrent');
const fullscreenSourceLangSearch = fullscreenOverlay.querySelector('#fullscreenSourceLangSearch');
const fullscreenTargetLangSearch = fullscreenOverlay.querySelector('#fullscreenTargetLangSearch');
const fullscreenSourceLangGrid = fullscreenOverlay.querySelector('#fullscreenSourceLangGrid');
const fullscreenTargetLangGrid = fullscreenOverlay.querySelector('#fullscreenTargetLangGrid');
const fullscreenSourceLangPanel = fullscreenOverlay.querySelector('#fullscreenSourceLangPanel');
const fullscreenTargetLangPanel = fullscreenOverlay.querySelector('#fullscreenTargetLangPanel');
const fullscreenSourceLangTrigger = fullscreenOverlay.querySelector('#fullscreenSourceLangTrigger');
const fullscreenTargetLangTrigger = fullscreenOverlay.querySelector('#fullscreenTargetLangTrigger');
const fullscreenSourceLabel = fullscreenOverlay.querySelector('#fullscreenSourceLabel');
const fullscreenTargetLabel = fullscreenOverlay.querySelector('#fullscreenTargetLabel');
const fullscreenSwap = fullscreenOverlay.querySelector('#fullscreenSwap');
const fullscreenSource = fullscreenOverlay.querySelector('#fullscreenSource');
const fullscreenTarget = fullscreenOverlay.querySelector('#fullscreenTarget');
const fullscreenLoadingOverlay = fullscreenOverlay.querySelector('#utstFullscreenLoading');
const fullscreenLoadingTitle = fullscreenOverlay.querySelector('#utstFullscreenLoadingTitle');
const fullscreenSourceCopy = fullscreenOverlay.querySelector('#fullscreenSourceCopy');
const fullscreenSourceSpeak = fullscreenOverlay.querySelector('#fullscreenSourceSpeak');
const fullscreenTargetCopy = fullscreenOverlay.querySelector('#fullscreenTargetCopy');
const fullscreenTargetSpeak = fullscreenOverlay.querySelector('#fullscreenTargetSpeak');
const fullscreenToggle = translationBox.querySelector('#fullscreenToggle');
sourceLangSelect.value = 'auto';
const inlineLanguagePanels = [];
let fullscreenSwapRotation = 0;
let currentSelectedText = '';
let currentTranslatedText = '';
let detectedSourceLang = 'auto';
let currentResolvedTargetLang = browserLang;
let fullscreenTranslateTimer = null;
let fullscreenTranslateReason = 'translate';
let selectionBubbleUpdateTimer = null;
let bubbleSelectedText = '';
let bubbleSelectionPosition = null;
let isSelectingPointer = false;
let panelTranslateRequestId = 0;
let fullscreenTranslateRequestId = 0;
let fullscreenScrollLocked = false;
let fullscreenScrollTop = 0;
let prevHtmlOverflow = '';
let prevHtmlOverscrollBehavior = '';
let prevBodyOverflow = '';
let prevBodyPosition = '';
let prevBodyTop = '';
let prevBodyLeft = '';
let prevBodyWidth = '';
let prevBodyOverscrollBehavior = '';
let prevBodyTouchAction = '';
const BUBBLE_ENABLED_KEY = 'selectionBubbleEnabled';
const BUBBLE_BLACKLIST_KEY = 'selectionBubbleBlacklist';
const PANEL_THEME_KEY = 'panelTheme';
const currentSiteHost = normalizeHostname(window.location.hostname || window.location.host || '');
let selectionBubbleEnabled = GM_getValue(BUBBLE_ENABLED_KEY, true) !== false;
let selectionBubbleBlacklist = loadBubbleBlacklist();
let currentPanelTheme = normalizePanelTheme(GM_getValue(PANEL_THEME_KEY, 'blue'));
function normalizePanelTheme(value) {
return value === 'dark' || value === 'light' ? value : 'blue';
}
function getIconDefaultStrokeColor() {
return currentPanelTheme === 'light' ? '#4a5568' : '#ffffff';
}
const COPY_FEEDBACK_STROKE = 'rgb(64 130 243)';
function lockPageScrollForFullscreen() {
if (fullscreenScrollLocked) return;
const scrollY = window.scrollY || window.pageYOffset || 0;
fullscreenScrollTop = scrollY;
prevHtmlOverflow = document.documentElement.style.overflow;
prevHtmlOverscrollBehavior = document.documentElement.style.overscrollBehavior;
prevBodyOverflow = document.body.style.overflow;
prevBodyPosition = document.body.style.position;
prevBodyTop = document.body.style.top;
prevBodyLeft = document.body.style.left;
prevBodyWidth = document.body.style.width;
prevBodyOverscrollBehavior = document.body.style.overscrollBehavior;
prevBodyTouchAction = document.body.style.touchAction;
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.overscrollBehavior = 'none';
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.left = '0';
document.body.style.width = '100%';
document.body.style.overscrollBehavior = 'none';
document.body.style.touchAction = 'none';
fullscreenScrollLocked = true;
}
function unlockPageScrollForFullscreen() {
if (!fullscreenScrollLocked) return;
document.documentElement.style.overflow = prevHtmlOverflow;
document.documentElement.style.overscrollBehavior = prevHtmlOverscrollBehavior;
document.body.style.overflow = prevBodyOverflow;
document.body.style.position = prevBodyPosition;
document.body.style.top = prevBodyTop;
document.body.style.left = prevBodyLeft;
document.body.style.width = prevBodyWidth;
document.body.style.overscrollBehavior = prevBodyOverscrollBehavior;
document.body.style.touchAction = prevBodyTouchAction;
window.scrollTo(0, fullscreenScrollTop);
fullscreenScrollLocked = false;
}
function resolveTargetLanguageValue(value, fallback = defaultTargetLang) {
let lang = value || fallback;
if (lang === 'navigator') return browserLang;
if (!lang || lang === 'auto') lang = fallback;
if (lang === 'navigator') return browserLang;
return lang || browserLang;
}
function resolveSourceSpeechLanguage(sourceValue) {
if (sourceValue && sourceValue !== 'auto') return sourceValue;
if (detectedSourceLang && detectedSourceLang !== 'auto') return detectedSourceLang;
if (sourceLangSelect && sourceLangSelect.value && sourceLangSelect.value !== 'auto') return sourceLangSelect.value;
return browserLang;
}
function resolveTargetSpeechLanguage(targetValue, fallback = currentResolvedTargetLang) {
return resolveTargetLanguageValue(targetValue, fallback || browserLang);
}
function getDetectedSourceLanguageLabel() {
if (!detectedSourceLang || detectedSourceLang === 'auto') return '';
return getLanguageLabel(detectedSourceLang);
}
function getFullscreenSourceCurrentLabel(code) {
if (code === 'auto') {
const detectedLabel = getDetectedSourceLanguageLabel();
return detectedLabel ? `${langNames.auto} (${detectedLabel})` : (langNames.auto || 'Detect language');
}
return getLanguageLabel(code);
}
function updateFullscreenSourceCurrentLabel() {
if (!fullscreenSourceLangCurrent || !fullscreenSourceLangSelect) return;
const sourceCode = fullscreenSourceLangSelect.value || 'auto';
fullscreenSourceLangCurrent.textContent = getFullscreenSourceCurrentLabel(sourceCode);
}
function updateFullscreenTargetCurrentLabel() {
if (!fullscreenTargetLangCurrent || !fullscreenTargetLangSelect) return;
const targetCode = fullscreenTargetLangSelect.value || defaultTargetLang;
fullscreenTargetLangCurrent.textContent = getLanguageLabel(targetCode);
}
function ensureFullscreenTargetLanguageValid(preferred) {
if (!fullscreenTargetLangSelect) return resolveTargetLanguageValue(preferred, defaultTargetLang);
const candidate = resolveTargetLanguageValue(preferred, defaultTargetLang);
return ensureSelectValue(fullscreenTargetLangSelect, candidate);
}
function getLoaderTitleByMode(mode = 'translate') {
const translateLabel = (overlayLabels && overlayLabels.translate) || (langNames.overlay && langNames.overlay.translate) || 'Translate';
if (mode === 'language') {
return `${translateLabel}...`;
}
return `${translateLabel}...`;
}
function syncLoadingTitles() {
if (panelLoadingTitle) panelLoadingTitle.textContent = getLoaderTitleByMode(panelLoadingOverlay && panelLoadingOverlay.dataset.mode ? panelLoadingOverlay.dataset.mode : 'translate');
if (fullscreenLoadingTitle) fullscreenLoadingTitle.textContent = getLoaderTitleByMode(fullscreenLoadingOverlay && fullscreenLoadingOverlay.dataset.mode ? fullscreenLoadingOverlay.dataset.mode : 'translate');
}
function setLoaderState(loaderEl, titleEl, active, mode = 'translate') {
if (!loaderEl) return;
loaderEl.dataset.mode = mode === 'language' ? 'language' : 'translate';
loaderEl.classList.toggle('is-active', !!active);
loaderEl.setAttribute('aria-hidden', active ? 'false' : 'true');
if (titleEl) {
titleEl.textContent = getLoaderTitleByMode(loaderEl.dataset.mode);
}
}
function setPanelLoading(active, mode = 'translate') {
setLoaderState(panelLoadingOverlay, panelLoadingTitle, active, mode);
}
function setFullscreenLoading(active, mode = 'translate') {
setLoaderState(fullscreenLoadingOverlay, fullscreenLoadingTitle, active, mode);
}
function runPanelTranslation(text, sourceLang, targetLang, callback, position, loadingMode = 'translate') {
const requestId = ++panelTranslateRequestId;
setPanelLoading(true, loadingMode);
translateText(text, sourceLang, targetLang, (translation, pos, resolvedTargetLang) => {
if (requestId !== panelTranslateRequestId) return;
setPanelLoading(false, loadingMode);
callback(translation, pos, resolvedTargetLang);
}, position);
}
function applyPanelTheme(theme, { persist = false } = {}) {
const normalizedTheme = normalizePanelTheme(theme);
currentPanelTheme = normalizedTheme;
if (persist) {
GM_setValue(PANEL_THEME_KEY, normalizedTheme);
}
document.documentElement.classList.remove('utst-theme-blue', 'utst-theme-dark', 'utst-theme-light');
document.documentElement.classList.add(`utst-theme-${normalizedTheme}`);
if (panelThemeSelect) {
panelThemeSelect.value = normalizedTheme;
}
updateThemePickerCurrentLabel();
refreshLanguagePanelTheme();
}
function getThemeDisplayLabel(themeValue) {
const normalized = normalizePanelTheme(themeValue);
const localizedThemes = langNames.themes || languageNames.en.themes || {};
return localizedThemes[normalized] || normalized;
}
function updateThemePickerCurrentLabel() {
if (!panelThemeCurrent) return;
const selected = panelThemeSelect ? normalizePanelTheme(panelThemeSelect.value || currentPanelTheme) : currentPanelTheme;
panelThemeCurrent.textContent = getThemeDisplayLabel(selected);
}
function renderThemePickerOptions() {
if (!panelThemeGrid || !panelThemeSelect || !panelThemePanel) return;
const style = getLanguagePanelThemeStyles();
applyLanguagePanelContainerTheme(panelThemePanel, null);
const selected = normalizePanelTheme(panelThemeSelect.value || currentPanelTheme);
const isLightTheme = currentPanelTheme === 'light';
const options = ['blue', 'dark', 'light'];
panelThemeGrid.innerHTML = options.map((value) => {
const active = value === selected;
const activeBorder = isLightTheme ? '#2d5cbe' : style.buttonActiveBorder;
const idleBorder = isLightTheme ? '#94a3b8' : style.buttonBorder;
const activeShadow = isLightTheme
? 'inset 0 0 0 1px rgba(38,61,104,0.28), 0 0 0 1px rgba(38,61,104,0.18)'
: (style.buttonActiveShadow || 'none');
return `<button type="button" data-theme="${value}" style="
padding:6px 8px;
text-align:left;
border-radius:8px;
border:1px solid ${active ? activeBorder : idleBorder};
background:${active ? style.buttonActiveBg : style.buttonBg};
color:${active && style.buttonActiveColor ? style.buttonActiveColor : style.buttonColor};
font-weight:${active ? (style.buttonActiveWeight || 600) : (style.buttonWeight || 500)};
box-shadow:${active ? activeShadow : 'none'};
cursor:pointer;
font-size:12px;
transition:background 0.15s ease, border 0.15s ease;
">${getThemeDisplayLabel(value)}</button>`;
}).join('');
panelThemeGrid.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.getAttribute('data-theme') || 'blue';
panelThemeSelect.value = normalizePanelTheme(theme);
applyPanelTheme(panelThemeSelect.value, { persist: true });
if (panelThemePanel) panelThemePanel.style.display = 'none';
});
});
}
function getLanguagePanelThemeStyles() {
if (currentPanelTheme === 'light') {
return {
panelBg: 'rgba(255,255,255,0.98)',
panelBorder: 'rgba(36,58,99,0.18)',
panelShadow: '0 10px 24px rgba(18,27,44,0.18)',
searchBg: 'rgba(255,255,255,0.96)',
searchBorder: 'rgba(38,61,104,0.2)',
searchColor: '#203150',
buttonBg: 'rgba(45,92,190,0.06)',
buttonBorder: 'rgba(38,61,104,0.2)',
buttonColor: '#203150',
buttonActiveBg: 'rgba(45,92,190,0.22)',
buttonActiveBorder: 'rgba(38,61,104,0.5)',
buttonActiveColor: '#16386c',
buttonWeight: 500,
buttonActiveWeight: 650,
buttonActiveShadow: 'inset 0 0 0 1px rgba(38,61,104,0.12)'
};
}
if (currentPanelTheme === 'dark') {
return {
panelBg: 'rgba(18,18,18,0.98)',
panelBorder: 'rgba(255,255,255,0.08)',
panelShadow: '0 10px 24px rgba(0,0,0,0.45)',
searchBg: 'rgba(255,255,255,0.06)',
searchBorder: 'rgba(255,255,255,0.12)',
searchColor: '#f0f0f0',
buttonBg: 'rgba(255,255,255,0.03)',
buttonBorder: 'rgba(255,255,255,0.1)',
buttonColor: '#f0f0f0',
buttonActiveBg: 'rgba(255,255,255,0.14)',
buttonActiveBorder: 'rgba(255,255,255,0.32)',
buttonWeight: 500,
buttonActiveWeight: 600
};
}
return {
panelBg: 'rgba(20,36,64,0.98)',
panelBorder: 'rgba(139,177,255,0.34)',
panelShadow: '0 10px 24px rgba(6,15,35,0.48)',
searchBg: 'rgba(120,165,255,0.12)',
searchBorder: 'rgba(139,177,255,0.34)',
searchColor: '#e9f1ff',
buttonBg: 'rgba(120,165,255,0.11)',
buttonBorder: 'rgba(139,177,255,0.28)',
buttonColor: '#e9f1ff',
buttonActiveBg: 'rgba(120,165,255,0.28)',
buttonActiveBorder: 'rgba(173,201,255,0.62)',
buttonWeight: 500,
buttonActiveWeight: 600
};
}
function applyLanguagePanelContainerTheme(panelEl, searchEl) {
if (!panelEl) return;
const style = getLanguagePanelThemeStyles();
panelEl.style.background = style.panelBg;
panelEl.style.border = `1px solid ${style.panelBorder}`;
panelEl.style.boxShadow = style.panelShadow;
if (searchEl) {
searchEl.style.background = style.searchBg;
searchEl.style.border = `1px solid ${style.searchBorder}`;
searchEl.style.color = style.searchColor;
}
}
function renderInlineLanguageGridForTheme(panel, selectEl) {
if (!panel || !selectEl) return;
const searchEl = panel.querySelector('.inlineLangSearch');
const gridEl = panel.querySelector('.inlineLangGrid');
if (!gridEl) return;
const shouldRender = panel.style.display === 'block' || gridEl.childElementCount > 0;
if (!shouldRender) return;
const opts = Array.from(selectEl.options)
.filter(o => !o.disabled)
.map(o => ({ value: o.value, label: o.textContent || o.value }));
renderLanguageGrid(gridEl, searchEl, selectEl, null, panel, opts);
}
function refreshLanguagePanelTheme() {
applyLanguagePanelContainerTheme(fullscreenSourceLangPanel, fullscreenSourceLangSearch);
applyLanguagePanelContainerTheme(fullscreenTargetLangPanel, fullscreenTargetLangSearch);
applyLanguagePanelContainerTheme(panelThemePanel, null);
if (fullscreenSourceLangGrid && fullscreenSourceLangSelect
&& (fullscreenSourceLangPanel.style.display === 'block' || fullscreenSourceLangGrid.childElementCount > 0)) {
renderLanguageGrid(fullscreenSourceLangGrid, fullscreenSourceLangSearch, fullscreenSourceLangSelect, fullscreenSourceLangCurrent, fullscreenSourceLangPanel);
}
if (fullscreenTargetLangGrid && fullscreenTargetLangSelect
&& (fullscreenTargetLangPanel.style.display === 'block' || fullscreenTargetLangGrid.childElementCount > 0)) {
renderLanguageGrid(fullscreenTargetLangGrid, fullscreenTargetLangSearch, fullscreenTargetLangSelect, fullscreenTargetLangCurrent, fullscreenTargetLangPanel);
}
if (panelThemePanel && panelThemeGrid && (panelThemePanel.style.display === 'block' || panelThemeGrid.childElementCount > 0)) {
renderThemePickerOptions();
}
inlineLanguagePanels.forEach(({ panel }) => {
const searchEl = panel.querySelector('.inlineLangSearch');
applyLanguagePanelContainerTheme(panel, searchEl);
});
inlineLanguagePanels.forEach(({ panel, selectEl }) => {
renderInlineLanguageGridForTheme(panel, selectEl);
});
}
function normalizeHostname(value) {
if (value == null) return '';
let host = String(value).trim().toLowerCase();
if (!host) return '';
host = host.replace(/^\*\./, '');
if (host.includes('://')) {
try {
host = new URL(host).hostname.toLowerCase();
} catch (e) {
host = host.split('://').pop();
}
}
host = host.split('/')[0].split('?')[0].split('#')[0].split(':')[0];
host = host.replace(/^www\./, '');
return host;
}
function loadBubbleBlacklist() {
const stored = GM_getValue(BUBBLE_BLACKLIST_KEY, []);
const list = Array.isArray(stored)
? stored
: typeof stored === 'string'
? stored.split(',').map(v => v.trim())
: [];
const normalized = [...new Set(list.map(normalizeHostname).filter(Boolean))];
GM_setValue(BUBBLE_BLACKLIST_KEY, normalized);
return normalized;
}
function persistBubbleBlacklist() {
GM_setValue(BUBBLE_BLACKLIST_KEY, selectionBubbleBlacklist);
}
function persistSelectionBubbleEnabled() {
GM_setValue(BUBBLE_ENABLED_KEY, selectionBubbleEnabled);
}
function isCurrentSiteBlacklisted() {
if (!currentSiteHost) return false;
return selectionBubbleBlacklist.some(site => currentSiteHost === site || currentSiteHost.endsWith(`.${site}`));
}
function canShowSelectionBubble() {
return selectionBubbleEnabled && !isCurrentSiteBlacklisted();
}
function getSelectionContext() {
const sel = window.getSelection();
if (!sel || !sel.rangeCount || sel.isCollapsed) return null;
const text = sel.toString().trim();
if (!text) return null;
const range = sel.getRangeAt(0);
let rect = null;
try {
if (sel.focusNode) {
const focusRange = document.createRange();
focusRange.setStart(sel.focusNode, sel.focusOffset);
focusRange.setEnd(sel.focusNode, sel.focusOffset);
const focusRect = focusRange.getBoundingClientRect();
if (focusRect && (focusRect.width || focusRect.height)) {
rect = focusRect;
}
}
} catch (e) {
rect = null;
}
if (!rect) {
const clientRects = range.getClientRects();
if (clientRects && clientRects.length) {
rect = clientRects[clientRects.length - 1];
}
}
if (!rect) {
rect = range.getBoundingClientRect();
}
if (!rect || (!rect.width && !rect.height)) return null;
return {
text,
rect,
position: {
x: rect.right + window.scrollX,
y: rect.bottom + window.scrollY
}
};
}
function hideBubbleCloseMenu() {
if (!bubbleCloseMenu) return;
bubbleCloseMenu.classList.remove('utst-open');
}
function hideSelectionBubble() {
selectionBubble.classList.remove('utst-visible');
bubbleSelectedText = '';
bubbleSelectionPosition = null;
hideBubbleCloseMenu();
}
function positionSelectionBubble(rect) {
if (!rect) return;
const bubbleWidth = selectionBubble.offsetWidth || 120;
const bubbleHeight = selectionBubble.offsetHeight || 38;
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
const minLeft = scrollX + MARGIN;
const maxLeft = scrollX + window.innerWidth - bubbleWidth - MARGIN;
const belowTop = rect.bottom + scrollY + 8;
const aboveTop = rect.top + scrollY - bubbleHeight - 8;
const maxTop = scrollY + window.innerHeight - bubbleHeight - MARGIN;
const minTop = scrollY + MARGIN;
const anchorLeft = rect.right + scrollX - (bubbleWidth / 2);
let top = belowTop;
if (top > maxTop) {
top = Math.max(minTop, aboveTop);
}
const left = Math.min(Math.max(anchorLeft, minLeft), maxLeft);
selectionBubble.style.left = `${left}px`;
selectionBubble.style.top = `${Math.min(Math.max(top, minTop), maxTop)}px`;
}
function isSelectionInsideTool() {
const sel = window.getSelection();
if (!sel) return false;
const anchor = sel.anchorNode;
const focus = sel.focusNode;
const nodes = [anchor, focus].filter(Boolean);
return nodes.some(node => {
const el = node.nodeType === 1 ? node : node.parentElement;
return el && (translationBox.contains(el) || fullscreenOverlay.contains(el) || selectionBubble.contains(el));
});
}
function updateSelectionBubble() {
if (isSelectingPointer) {
hideSelectionBubble();
return;
}
if (translationBox.style.display === 'block' || fullscreenOverlay.style.display === 'flex') {
hideSelectionBubble();
return;
}
if (!canShowSelectionBubble() || isSelectionInsideTool()) {
hideSelectionBubble();
return;
}
const context = getSelectionContext();
if (!context) {
hideSelectionBubble();
return;
}
bubbleSelectedText = context.text;
bubbleSelectionPosition = context.position;
positionSelectionBubble(context.rect);
selectionBubble.classList.add('utst-visible');
}
function scheduleSelectionBubbleUpdate(delay = 20) {
if (selectionBubbleUpdateTimer) clearTimeout(selectionBubbleUpdateTimer);
selectionBubbleUpdateTimer = setTimeout(() => {
selectionBubbleUpdateTimer = null;
updateSelectionBubble();
}, delay);
}
function renderBubbleBlacklist() {
if (!bubbleBlacklistList) return;
if (!selectionBubbleBlacklist.length) {
bubbleBlacklistList.innerHTML = `<div class="utst-blacklist-empty">${langNames.settingsBlacklistEmpty}</div>`;
return;
}
bubbleBlacklistList.innerHTML = selectionBubbleBlacklist
.map(site => `
<div class="utst-blacklist-item">
<span>${site}</span>
<button class="utst-blacklist-remove" type="button" data-site="${site}" title="Remove">×</button>
</div>
`)
.join('');
bubbleBlacklistList.querySelectorAll('.utst-blacklist-remove').forEach(btn => {
btn.addEventListener('click', () => {
const site = normalizeHostname(btn.getAttribute('data-site') || '');
if (!site) return;
selectionBubbleBlacklist = selectionBubbleBlacklist.filter(entry => entry !== site);
persistBubbleBlacklist();
renderBubbleBlacklist();
scheduleSelectionBubbleUpdate(0);
});
});
}
function syncSelectionBubbleSettingsUi() {
const bubbleLabels = (langNames && langNames.bubble) || (languageNames.en && languageNames.en.bubble) || {};
const hideOnLabel = bubbleLabels.hideOn || 'Hide on';
const hideSiteLabel = bubbleLabels.hideSite || 'Hide on this site';
const hideGlobalLabel = bubbleLabels.hideGlobal || 'Hide globally';
const closeTitleLabel = bubbleLabels.closeTitle || 'Hide selection bubble';
const translateTitleLabel = bubbleLabels.translateTitle || 'Translate selected text';
if (selectionBubbleEnabledCheckbox) {
selectionBubbleEnabledCheckbox.checked = !!selectionBubbleEnabled;
}
if (selectionBubbleClose) {
selectionBubbleClose.title = closeTitleLabel;
selectionBubbleClose.setAttribute('aria-label', closeTitleLabel);
}
if (selectionBubbleAction) {
selectionBubbleAction.title = translateTitleLabel;
selectionBubbleAction.setAttribute('aria-label', translateTitleLabel);
}
if (bubbleHideSiteButton) {
bubbleHideSiteButton.textContent = currentSiteHost ? `${hideOnLabel} ${currentSiteHost}` : hideSiteLabel;
}
if (bubbleHideGlobalButton) {
bubbleHideGlobalButton.textContent = hideGlobalLabel;
}
renderBubbleBlacklist();
}
function getSelectedText() {
return window.getSelection().toString().trim();
}
function ensureSelectValue(selectEl, lang) {
if (selectEl.querySelector(`option[value="${lang}"]`)) {
selectEl.value = lang;
return lang;
}
selectEl.value = defaultTargetLang;
return defaultTargetLang;
}
function getSavedTargetLanguage() {
const saved = GM_getValue('defaultTranslateLang', defaultTargetLang);
if (!targetLangSelect.querySelector(`option[value="${saved}"]`)) {
GM_setValue('defaultTranslateLang', defaultTargetLang);
return defaultTargetLang;
}
return saved;
}
function persistDefaultTargetLanguage(lang) {
const valueToPersist = defaultTranslateLangSelect.querySelector(`option[value="${lang}"]`)
? lang
: defaultTargetLang;
GM_setValue('defaultTranslateLang', valueToPersist);
return valueToPersist;
}
function applyToolLanguage(preference, { persist = false } = {}) {
const normalizedSelection = (preference === 'browser' || supportedUiLanguages.includes(preference))
? preference
: 'browser';
if (persist) {
GM_setValue('defaultToolLang', normalizedSelection);
}
const previousErrors = errors;
toolLanguagePreference = normalizedSelection;
const newUiLang = resolveUiLang(normalizedSelection);
langNames = languageNames[newUiLang];
errors = langNames.errors;
tooltips = langNames.tooltips;
dragHandleLabel = langNames.dragHandleLabel || languageNames.en.dragHandleLabel;
overlayLabels = langNames.overlay || languageNames.en.overlay;
settingsTitle = langNames.settingsTitle || languageNames.en.settingsTitle;
settingsDefaultLabel = langNames.settingsDefaultLabel || languageNames.en.settingsDefaultLabel;
settingsToolLabel = langNames.settingsToolLabel || languageNames.en.settingsToolLabel;
const settingsThemeLabel = langNames.settingsThemeLabel || languageNames.en.settingsThemeLabel || 'Theme:';
const settingsBubbleLabel = langNames.settingsBubbleLabel || languageNames.en.settingsBubbleLabel || 'Selection Bubble';
const settingsBlacklistLabel = langNames.settingsBlacklistLabel || languageNames.en.settingsBlacklistLabel || 'Blacklist';
const settingsBlacklistAdd = langNames.settingsBlacklistAdd || languageNames.en.settingsBlacklistAdd || 'Add';
if (settingsHeaderTitle) settingsHeaderTitle.textContent = settingsTitle;
if (defaultTranslateLangLabel) defaultTranslateLangLabel.textContent = settingsDefaultLabel;
if (toolLanguageLabel) toolLanguageLabel.textContent = settingsToolLabel;
if (panelThemeLabel) panelThemeLabel.textContent = settingsThemeLabel;
if (bubbleToggleLabel) bubbleToggleLabel.textContent = settingsBubbleLabel;
if (bubbleBlacklistLabel) bubbleBlacklistLabel.textContent = settingsBlacklistLabel;
if (bubbleBlacklistAddButton) bubbleBlacklistAddButton.textContent = settingsBlacklistAdd;
if (settingsButton) settingsButton.title = settingsTitle;
if (sourceAutoOption) sourceAutoOption.textContent = langNames.auto;
if (speakTranslated) speakTranslated.textContent = tooltips.listenTranslated;
if (speakOriginal) speakOriginal.textContent = tooltips.listenOriginal;
const dragLabelEl = translationBox.querySelector('#dragHandle span');
if (dragLabelEl) dragLabelEl.textContent = dragHandleLabel;
if (fullscreenTitleEl) fullscreenTitleEl.textContent = overlayLabels.title;
if (fullscreenSourceLabel) fullscreenSourceLabel.textContent = overlayLabels.source;
if (fullscreenTargetLabel) fullscreenTargetLabel.textContent = overlayLabels.target;
if (fullscreenToggle) fullscreenToggle.title = overlayLabels.open;
if (fullscreenSourceLangSearch) fullscreenSourceLangSearch.placeholder = langNames.navigator;
if (fullscreenTargetLangSearch) fullscreenTargetLangSearch.placeholder = langNames.navigator;
syncLoadingTitles();
if (fullscreenSourceLangSelect) {
const prev = fullscreenSourceLangSelect.value || 'auto';
sourceLanguageOptionsHtml = buildSourceLanguageOptionsHtml();
fullscreenSourceLangSelect.innerHTML = sourceLanguageOptionsHtml;
fullscreenSourceLangSelect.value = fullscreenSourceLangSelect.querySelector(`option[value="${prev}"]`) ? prev : 'auto';
}
if (fullscreenTargetLangSelect) {
const prev = fullscreenTargetLangSelect.value || defaultTargetLang;
const refreshedTargetOptionsOverlay = buildTargetLanguageOptions(true);
fullscreenTargetLangSelect.innerHTML = refreshedTargetOptionsOverlay;
ensureFullscreenTargetLanguageValid(prev);
}
updateFullscreenSourceCurrentLabel();
updateFullscreenTargetCurrentLabel();
if (toolLanguageSelect) {
toolLanguageSelect.innerHTML = buildToolLanguageOptionsHtml();
toolLanguageSelect.value = normalizedSelection;
}
if (panelThemeSelect) {
const blueOption = panelThemeSelect.querySelector('option[value="blue"]');
const darkOption = panelThemeSelect.querySelector('option[value="dark"]');
const lightOption = panelThemeSelect.querySelector('option[value="light"]');
const localizedThemes = langNames.themes || languageNames.en.themes || {};
if (blueOption) blueOption.textContent = localizedThemes.blue || 'Blue';
if (darkOption) darkOption.textContent = localizedThemes.dark || 'Dark';
if (lightOption) lightOption.textContent = localizedThemes.light || 'Light';
}
updateThemePickerCurrentLabel();
renderThemePickerOptions();
inlineLanguagePanels.forEach(({ panel }) => {
const searchEl = panel.querySelector('.inlineLangSearch');
if (searchEl) searchEl.placeholder = langNames.navigator;
});
if (translationText && previousErrors && translationText.textContent === previousErrors.noText) {
translationText.textContent = errors.noText;
}
syncSelectionBubbleSettingsUi();
scheduleSelectionBubbleUpdate(0);
const currentTargetValue = targetLangSelect.value;
const savedDefaultValue = GM_getValue('defaultTranslateLang', defaultTargetLang);
const refreshedTargetOptions = buildTargetLanguageOptions(true);
targetLangSelect.innerHTML = refreshedTargetOptions;
ensureSelectValue(targetLangSelect, currentTargetValue);
defaultTranslateLangSelect.innerHTML = refreshedTargetOptions;
ensureSelectValue(defaultTranslateLangSelect, savedDefaultValue);
}
const initialTargetLang = getSavedTargetLanguage();
ensureSelectValue(targetLangSelect, initialTargetLang);
ensureSelectValue(defaultTranslateLangSelect, initialTargetLang);
currentResolvedTargetLang = initialTargetLang === 'navigator' ? browserLang : initialTargetLang;
if (toolLanguageSelect) {
toolLanguageSelect.value = toolLanguagePreference;
}
defaultTranslateLangSelect.addEventListener('change', () => {
stopSpeaking();
const persisted = persistDefaultTargetLanguage(defaultTranslateLangSelect.value);
ensureSelectValue(targetLangSelect, persisted);
currentResolvedTargetLang = persisted === 'navigator' ? browserLang : persisted;
handleLanguageChange();
});
if (toolLanguageSelect) {
toolLanguageSelect.addEventListener('change', () => {
const selected = toolLanguageSelect.value || 'browser';
const normalizedSelection = (selected === 'browser' || supportedUiLanguages.includes(selected)) ? selected : 'browser';
applyToolLanguage(normalizedSelection, { persist: true });
});
}
if (panelThemeSelect) {
panelThemeSelect.addEventListener('change', () => {
applyPanelTheme(panelThemeSelect.value || 'blue', { persist: true });
});
}
if (panelThemeTrigger) {
panelThemeTrigger.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const isOpen = panelThemePanel && panelThemePanel.style.display === 'block';
hideInlinePanels();
hideLanguagePanels();
if (!panelThemePanel) return;
if (isOpen) {
panelThemePanel.style.display = 'none';
return;
}
renderThemePickerOptions();
panelThemePanel.style.display = 'block';
});
}
if (selectionBubbleEnabledCheckbox) {
selectionBubbleEnabledCheckbox.addEventListener('change', () => {
selectionBubbleEnabled = !!selectionBubbleEnabledCheckbox.checked;
persistSelectionBubbleEnabled();
hideSelectionBubble();
scheduleSelectionBubbleUpdate(0);
});
}
if (bubbleBlacklistAddButton) {
const addBlacklistSite = () => {
const normalized = normalizeHostname(bubbleBlacklistInput ? bubbleBlacklistInput.value : '');
if (!normalized) return;
if (!selectionBubbleBlacklist.includes(normalized)) {
selectionBubbleBlacklist.push(normalized);
selectionBubbleBlacklist.sort((a, b) => a.localeCompare(b));
persistBubbleBlacklist();
renderBubbleBlacklist();
}
if (bubbleBlacklistInput) bubbleBlacklistInput.value = '';
hideSelectionBubble();
scheduleSelectionBubbleUpdate(0);
};
bubbleBlacklistAddButton.addEventListener('click', addBlacklistSite);
if (bubbleBlacklistInput) {
bubbleBlacklistInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addBlacklistSite();
}
});
}
}
selectionBubble.addEventListener('mousedown', (e) => {
e.preventDefault();
});
document.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
if (selectionBubble.contains(e.target) || translationBox.contains(e.target) || fullscreenOverlay.contains(e.target)) return;
isSelectingPointer = true;
hideSelectionBubble();
}, true);
if (selectionBubbleClose) {
selectionBubbleClose.addEventListener('click', (e) => {
e.stopPropagation();
if (!bubbleCloseMenu) return;
const isOpen = bubbleCloseMenu.classList.contains('utst-open');
bubbleCloseMenu.classList.toggle('utst-open', !isOpen);
});
}
if (bubbleHideSiteButton) {
bubbleHideSiteButton.addEventListener('click', (e) => {
e.stopPropagation();
if (currentSiteHost && !selectionBubbleBlacklist.includes(currentSiteHost)) {
selectionBubbleBlacklist.push(currentSiteHost);
selectionBubbleBlacklist.sort((a, b) => a.localeCompare(b));
persistBubbleBlacklist();
}
hideSelectionBubble();
syncSelectionBubbleSettingsUi();
scheduleSelectionBubbleUpdate(0);
});
}
if (bubbleHideGlobalButton) {
bubbleHideGlobalButton.addEventListener('click', (e) => {
e.stopPropagation();
selectionBubbleEnabled = false;
persistSelectionBubbleEnabled();
syncSelectionBubbleSettingsUi();
hideSelectionBubble();
});
}
if (selectionBubbleAction) {
selectionBubbleAction.addEventListener('click', (e) => {
e.stopPropagation();
const text = (bubbleSelectedText || getSelectedText() || '').trim();
const pos = bubbleSelectionPosition
? { x: bubbleSelectionPosition.x, y: bubbleSelectionPosition.y }
: null;
openTranslationPanelForText(text, pos);
});
}
applyPanelTheme(currentPanelTheme);
applyToolLanguage(toolLanguagePreference);
syncSelectionBubbleSettingsUi();
scheduleSelectionBubbleUpdate(0);
function splitSentences(text) {
const regex = /(\.\s+|\.\n|\.)/;
let parts = text.split(regex);
let sentences = [];
let currentSentence = '';
for (let i = 0; i < parts.length; i++) {
currentSentence += parts[i];
if (parts[i].match(regex) || i === parts.length - 1) {
if (currentSentence.trim()) {
sentences.push(currentSentence.trim());
}
currentSentence = '';
}
}
return sentences.length ? sentences : [text];
}
function translateSentence(text, sourceLang, targetLang, callback) {
if (!text.trim()) {
callback(text, null);
return;
}
const match = text.match(/([\s\S]*?)(?:(\.\s+|\.\n|\.)|$)/);
const textToTranslate = match ? (match[1] || text) : text;
const delimiter = match && match[2] ? match[2] : '';
function chunkBySize(s, size = 1000) {
const out = [];
for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));
return out;
}
let sentences = splitSentences(text).flatMap(seg =>
seg.length > 1000 ? chunkBySize(seg) : [seg]
);
GM_xmlhttpRequest({
method: 'GET',
url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sourceLang}&tl=${targetLang}&dt=t&q=${encodeURIComponent(textToTranslate.trim())}`,
onload: function (response) {
try {
const data = JSON.parse(response.responseText);
let detected = sourceLang;
if (sourceLang === 'auto') {
if (data[2]) {
detected = data[2];
} else if (data[8] && data[8][0] && data[8][0][0]) {
detected = data[8][0][0];
} else {
detected = '';
}
}
const translation = (data && data[0] && data[0][0] && data[0][0][0])
? data[0][0][0] + delimiter
: '' + delimiter;
callback(translation, detected || null);
} catch (e) {
callback(errors.translation + delimiter, null);
}
},
onerror: function () {
callback(errors.connection + delimiter, null);
}
});
}
function translateText(text, sourceLang, targetLang, callback, position) {
if (!text) {
callback(errors.noText, position, null);
return;
}
if (!sourceLang || sourceLang === '') sourceLang = 'auto';
let resolvedTargetLang = targetLang;
if (resolvedTargetLang === 'navigator') {
resolvedTargetLang = browserLang;
}
if (!resolvedTargetLang || resolvedTargetLang === '') {
let fallback = getSavedTargetLanguage();
if (fallback === 'navigator') fallback = browserLang;
resolvedTargetLang = fallback || defaultTargetLang;
}
const sentences = splitSentences(text);
let translatedSentences = [];
let completed = 0;
let runDetectedLang = null;
sentences.forEach((sentence, index) => {
translateSentence(sentence, sourceLang, resolvedTargetLang, (translation, detected) => {
translatedSentences[index] = translation;
if (!runDetectedLang && detected && googleTranslateLanguages[detected]) {
runDetectedLang = detected;
}
completed++;
if (completed === sentences.length) {
if (runDetectedLang && sourceLangSelect.querySelector(`option[value="${runDetectedLang}"]`)) {
sourceLangSelect.value = runDetectedLang;
detectedSourceLang = runDetectedLang;
} else {
sourceLangSelect.value = 'auto';
detectedSourceLang = 'auto';
}
updateFullscreenSourceCurrentLabel();
const fullTranslation = translatedSentences.join('');
callback(fullTranslation, position, resolvedTargetLang);
}
});
});
}
let currentSpeakerId = null;
let speechPlaying = false;
let activeSpeechAudio = null;
let activeSpeechAudioUrl = null;
let speechQueue = [];
let speechFetchRequest = null;
let speechRequestToken = 0;
const SPEECH_ACTIVE_STROKE = COPY_FEEDBACK_STROKE;
function setSpeakButtonVisualState(buttonEl, active) {
if (!buttonEl) return;
const svg = buttonEl.querySelector('svg');
if (svg) {
svg.style.stroke = active ? SPEECH_ACTIVE_STROKE : '';
}
}
function updateSpeechIconState() {
const panelActive = speechPlaying && currentSpeakerId && currentSpeakerId.startsWith('panel-');
const sourceActive = speechPlaying && currentSpeakerId === 'fs-source';
const targetActive = speechPlaying && currentSpeakerId === 'fs-target';
setSpeakButtonVisualState(speakButton, panelActive);
setSpeakButtonVisualState(fullscreenSourceSpeak, sourceActive);
setSpeakButtonVisualState(fullscreenTargetSpeak, targetActive);
}
function normalizeSpeechLangTag(langTag) {
return (langTag || '').toLowerCase().replace(/_/g, '-').trim();
}
function clearActiveSpeechAudio() {
if (activeSpeechAudio) {
activeSpeechAudio.onended = null;
activeSpeechAudio.onerror = null;
activeSpeechAudio.pause();
activeSpeechAudio.src = '';
activeSpeechAudio = null;
}
if (activeSpeechAudioUrl) {
URL.revokeObjectURL(activeSpeechAudioUrl);
activeSpeechAudioUrl = null;
}
}
function stopSpeaking() {
speechRequestToken += 1;
if (speechFetchRequest && typeof speechFetchRequest.abort === 'function') {
speechFetchRequest.abort();
}
speechFetchRequest = null;
speechQueue = [];
clearActiveSpeechAudio();
speechPlaying = false;
currentSpeakerId = null;
updateSpeechIconState();
}
function normalizeGoogleTtsLang(langCode) {
let normalized = normalizeSpeechLangTag(langCode);
if (!normalized || normalized === 'auto' || normalized === 'navigator') {
normalized = normalizeSpeechLangTag(browserLang || 'en');
}
if (normalized === 'zh-cn' || normalized === 'zh-sg') return 'zh-CN';
if (normalized === 'zh-tw' || normalized === 'zh-hk') return 'zh-TW';
if (normalized === 'pt-br') return 'pt-BR';
return normalized;
}
function getGoogleTtsLanguageCandidates(langCode) {
const normalized = normalizeGoogleTtsLang(langCode);
const candidates = [normalized];
const base = normalized.split('-')[0];
if (base && !candidates.includes(base)) candidates.push(base);
return candidates.filter(Boolean);
}
function splitTextForGoogleTts(text, maxChunkLength = 180) {
const normalized = (text || '').replace(/\s+/g, ' ').trim();
if (!normalized) return [];
if (normalized.length <= maxChunkLength) return [normalized];
const chunks = [];
const sentences = normalized.match(/[^.!?]+[.!?]*/g) || [normalized];
sentences.forEach((sentenceRaw) => {
const sentence = sentenceRaw.trim();
if (!sentence) return;
if (sentence.length <= maxChunkLength) {
chunks.push(sentence);
return;
}
const words = sentence.split(' ');
let current = '';
words.forEach((word) => {
if (!word) return;
if (word.length > maxChunkLength) {
if (current) {
chunks.push(current);
current = '';
}
for (let i = 0; i < word.length; i += maxChunkLength) {
chunks.push(word.slice(i, i + maxChunkLength));
}
return;
}
const next = current ? `${current} ${word}` : word;
if (next.length > maxChunkLength) {
if (current) chunks.push(current);
current = word;
} else {
current = next;
}
});
if (current) chunks.push(current);
});
return chunks.filter(Boolean);
}
function finishSpeechPlayback(requestToken) {
if (requestToken !== speechRequestToken) return;
speechFetchRequest = null;
speechQueue = [];
clearActiveSpeechAudio();
speechPlaying = false;
currentSpeakerId = null;
updateSpeechIconState();
}
function fetchGoogleTtsChunk(chunkText, langCandidates, requestToken, done) {
if (!chunkText || !langCandidates.length || requestToken !== speechRequestToken) {
done(null);
return;
}
const tryCandidate = (index) => {
if (requestToken !== speechRequestToken) {
done(null);
return;
}
if (index >= langCandidates.length) {
done(null);
return;
}
const candidateLang = langCandidates[index];
const url = `https://translate.googleapis.com/translate_tts?client=gtx&ie=UTF-8&tl=${encodeURIComponent(candidateLang)}&q=${encodeURIComponent(chunkText)}`;
speechFetchRequest = GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
onload: (response) => {
speechFetchRequest = null;
if (requestToken !== speechRequestToken) {
done(null);
return;
}
const status = Number(response.status) || 0;
const hasAudio = response.response && response.response.byteLength > 0;
if (status >= 200 && status < 300 && hasAudio) {
done(new Blob([response.response], { type: 'audio/mpeg' }));
return;
}
tryCandidate(index + 1);
},
onerror: () => {
speechFetchRequest = null;
if (requestToken !== speechRequestToken) {
done(null);
return;
}
tryCandidate(index + 1);
}
});
};
tryCandidate(0);
}
function playSpeechChunkAt(index, requestToken, langCandidates) {
if (requestToken !== speechRequestToken) return;
if (!speechQueue.length || index >= speechQueue.length) {
finishSpeechPlayback(requestToken);
return;
}
fetchGoogleTtsChunk(speechQueue[index], langCandidates, requestToken, (audioBlob) => {
if (requestToken !== speechRequestToken) return;
if (!audioBlob) {
finishSpeechPlayback(requestToken);
return;
}
clearActiveSpeechAudio();
activeSpeechAudioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(activeSpeechAudioUrl);
activeSpeechAudio = audio;
audio.onended = () => {
playSpeechChunkAt(index + 1, requestToken, langCandidates);
};
audio.onerror = () => {
playSpeechChunkAt(index + 1, requestToken, langCandidates);
};
const playPromise = audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
playSpeechChunkAt(index + 1, requestToken, langCandidates);
});
}
});
}
function speak(text, lang, speakerId = null) {
const value = (text || '').trim();
if (!value) return;
const sameSpeaker = speechPlaying && speakerId && speakerId === currentSpeakerId;
if (sameSpeaker) {
stopSpeaking();
return;
}
stopSpeaking();
speechQueue = splitTextForGoogleTts(value);
if (!speechQueue.length) return;
const requestToken = speechRequestToken;
const langCandidates = getGoogleTtsLanguageCandidates(lang);
currentSpeakerId = speakerId;
speechPlaying = true;
updateSpeechIconState();
playSpeechChunkAt(0, requestToken, langCandidates);
}
function openTranslationPanelForText(selectedText, selectionPosition) {
stopSpeaking();
sourceLangSelect.value = 'auto';
detectedSourceLang = 'auto';
const translatorPanel = document.getElementById('translatorPanel');
const settingsPanel = document.getElementById('settingsPanel');
if (translatorPanel) translatorPanel.style.display = 'block';
if (settingsPanel) settingsPanel.style.display = 'none';
if (settingsHeader) settingsHeader.style.display = 'none';
translationBox.classList.remove('utst-settings-open');
hideSelectionBubble();
hideBubbleCloseMenu();
const text = (selectedText || '').trim();
if (!text) {
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
panelTranslateRequestId++;
setPanelLoading(false);
translationText.textContent = errors.noText;
translationBox.style.display = 'block';
translationBox.style.left = `${scrollX + window.innerWidth / 2 - 150}px`;
translationBox.style.top = `${scrollY + window.innerHeight / 2 - 50}px`;
translationBox.style.opacity = '1';
translationBox.style.transform = 'translateY(0)';
return;
}
currentSelectedText = text;
const savedTargetLang = getSavedTargetLanguage();
const targetLangForSession = ensureSelectValue(targetLangSelect, savedTargetLang);
ensureSelectValue(defaultTranslateLangSelect, savedTargetLang);
const fallbackPosition = selectionPosition && Number.isFinite(selectionPosition.x) && Number.isFinite(selectionPosition.y)
? selectionPosition
: { x: 0, y: 0 };
translationText.textContent = '';
placeBoxAtSelection(fallbackPosition);
translationBox.style.display = 'block';
translationBox.style.opacity = '1';
translationBox.style.transform = 'translateY(0)';
runPanelTranslation(text, 'auto', targetLangForSession, (translation, pos, resolvedTargetLang) => {
currentTranslatedText = translation;
translationText.textContent = translation;
currentResolvedTargetLang = resolvedTargetLang || currentResolvedTargetLang;
placeBoxAtSelection(pos || fallbackPosition);
hideSelectionBubble();
}, fallbackPosition, 'translate');
}
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key.toLowerCase() === 'l' && !e.altKey && !e.metaKey && !e.shiftKey) {
e.preventDefault();
const context = getSelectionContext();
openTranslationPanelForText(context ? context.text : '', context ? context.position : null);
}
});
function handleLanguageChange() {
stopSpeaking();
const targetVal = targetLangSelect.value;
currentResolvedTargetLang = targetVal === 'navigator' ? browserLang : targetVal;
const sourceVal = sourceLangSelect.value;
if (sourceVal !== 'auto') {
detectedSourceLang = sourceVal;
}
if (currentSelectedText) {
runPanelTranslation(currentSelectedText, sourceVal, targetVal, (translation, pos, resolvedTargetLang) => {
currentTranslatedText = translation;
translationText.textContent = translation;
currentResolvedTargetLang = resolvedTargetLang || currentResolvedTargetLang;
}, { x: parseFloat(translationBox.style.left), y: parseFloat(translationBox.style.top) }, 'language');
}
}
sourceLangSelect.addEventListener('change', handleLanguageChange);
targetLangSelect.addEventListener('change', () => {
ensureSelectValue(targetLangSelect, targetLangSelect.value);
handleLanguageChange();
});
speakButton.addEventListener('mouseenter', () => {
speakTooltip.style.display = 'block';
});
speakButton.addEventListener('mouseleave', () => {
speakTooltip.style.display = 'none';
});
speakButton.addEventListener('click', (e) => {
if (speakTooltip && speakTooltip.contains(e.target)) return;
if (speechPlaying && currentSpeakerId && currentSpeakerId.startsWith('panel-')) {
e.preventDefault();
e.stopPropagation();
stopSpeaking();
speakTooltip.style.display = 'none';
}
});
speakTranslated.addEventListener('click', (e) => {
e.stopPropagation();
if (currentTranslatedText) {
const langForSpeech = resolveTargetSpeechLanguage(targetLangSelect ? targetLangSelect.value : currentResolvedTargetLang, currentResolvedTargetLang);
speak(currentTranslatedText, langForSpeech, 'panel-translated');
}
});
speakOriginal.addEventListener('click', (e) => {
e.stopPropagation();
if (currentSelectedText) {
speak(currentSelectedText, resolveSourceSpeechLanguage(sourceLangSelect ? sourceLangSelect.value : 'auto'), 'panel-original');
}
});
copyButton.addEventListener('click', () => {
if (currentTranslatedText) {
navigator.clipboard.writeText(currentTranslatedText);
copyButton.querySelector('svg').style.stroke = COPY_FEEDBACK_STROKE;
setTimeout(() => {
copyButton.querySelector('svg').style.stroke = getIconDefaultStrokeColor();
}, 1000);
}
});
function openFullscreenOverlay() {
hideSelectionBubble();
hideBubbleCloseMenu();
lockPageScrollForFullscreen();
fullscreenOverlay.style.display = 'flex';
fullscreenSource.value = currentSelectedText || '';
fullscreenTarget.value = currentTranslatedText || '';
if (fullscreenSourceLangSelect) {
const srcVal = sourceLangSelect ? sourceLangSelect.value : 'auto';
fullscreenSourceLangSelect.value = fullscreenSourceLangSelect.querySelector(`option[value="${srcVal}"]`) ? srcVal : 'auto';
}
if (fullscreenTargetLangSelect) {
const tgtVal = targetLangSelect ? targetLangSelect.value : defaultTargetLang;
ensureFullscreenTargetLanguageValid(tgtVal);
}
updateFullscreenSourceCurrentLabel();
updateFullscreenTargetCurrentLabel();
hideLanguagePanels();
refreshLanguagePanelTheme();
renderLanguageGrid(fullscreenSourceLangGrid, fullscreenSourceLangSearch, fullscreenSourceLangSelect, fullscreenSourceLangCurrent, fullscreenSourceLangPanel);
renderLanguageGrid(fullscreenTargetLangGrid, fullscreenTargetLangSearch, fullscreenTargetLangSelect, fullscreenTargetLangCurrent, fullscreenTargetLangPanel);
scheduleFullscreenTranslate(0, 'translate');
}
function closeFullscreenOverlay() {
fullscreenTranslateRequestId++;
setFullscreenLoading(false);
fullscreenOverlay.style.display = 'none';
unlockPageScrollForFullscreen();
stopSpeaking();
scheduleSelectionBubbleUpdate(0);
}
function translateInFullscreen(reason = 'translate') {
const text = fullscreenSource.value.trim();
const target = fullscreenTargetLangSelect
? ensureFullscreenTargetLanguageValid(fullscreenTargetLangSelect.value)
: (targetLangSelect ? targetLangSelect.value : defaultTargetLang);
const srcLang = fullscreenSourceLangSelect ? fullscreenSourceLangSelect.value || 'auto' : 'auto';
const requestId = ++fullscreenTranslateRequestId;
setFullscreenLoading(true, reason === 'language' ? 'language' : 'translate');
translateText(text, srcLang, target, (translation, pos, resolvedTargetLang) => {
if (requestId !== fullscreenTranslateRequestId) return;
setFullscreenLoading(false, reason === 'language' ? 'language' : 'translate');
fullscreenTarget.value = translation;
currentResolvedTargetLang = resolvedTargetLang || currentResolvedTargetLang;
updateFullscreenSourceCurrentLabel();
updateFullscreenTargetCurrentLabel();
}, { x: 0, y: 0 });
}
function scheduleFullscreenTranslate(delay = 250, reason = 'translate') {
if (fullscreenTranslateTimer) clearTimeout(fullscreenTranslateTimer);
fullscreenTranslateReason = reason === 'language' ? 'language' : 'translate';
fullscreenTranslateTimer = setTimeout(() => {
fullscreenTranslateTimer = null;
translateInFullscreen(fullscreenTranslateReason);
}, delay);
}
function hideLanguagePanels() {
if (fullscreenSourceLangPanel) fullscreenSourceLangPanel.style.display = 'none';
if (fullscreenTargetLangPanel) fullscreenTargetLangPanel.style.display = 'none';
}
function renderLanguageGrid(gridEl, searchEl, selectEl, currentLabelEl, panelEl, customOptions) {
if (!gridEl || !selectEl) return;
const style = getLanguagePanelThemeStyles();
applyLanguagePanelContainerTheme(panelEl, searchEl);
const query = (searchEl && searchEl.value || '').toLowerCase();
const btns = [];
const firstUsableOption = Array.from(selectEl.options || []).find(opt => !opt.disabled && opt.value);
const hasAutoOption = !!selectEl.querySelector('option[value="auto"]');
const current = selectEl.value || (hasAutoOption ? 'auto' : (firstUsableOption ? firstUsableOption.value : defaultTargetLang));
const pushBtn = (code, name) => {
const active = code === current;
btns.push(`<button data-code="${code}" style="
padding:6px 8px;
text-align:left;
border-radius:8px;
border:1px solid ${active ? style.buttonActiveBorder : style.buttonBorder};
background:${active ? style.buttonActiveBg : style.buttonBg};
color:${active && style.buttonActiveColor ? style.buttonActiveColor : style.buttonColor};
font-weight:${active ? (style.buttonActiveWeight || 600) : (style.buttonWeight || 500)};
box-shadow:${active ? (style.buttonActiveShadow || 'none') : 'none'};
cursor:pointer;
font-size:12px;
transition:background 0.15s ease, border 0.15s ease;
">${name}</button>`);
};
if (customOptions && customOptions.length) {
customOptions.forEach(({ value, label }) => {
const code = value;
const name = label;
if (query && !name.toLowerCase().includes(query) && !code.toLowerCase().includes(query)) return;
pushBtn(code, name);
});
} else {
const entries = Array.from(selectEl.options)
.filter(option => !option.disabled && option.value)
.map(option => [option.value, option.textContent || getLanguageLabel(option.value)]);
entries.forEach(([code, name]) => {
if (query && !name.toLowerCase().includes(query) && !code.toLowerCase().includes(query)) return;
pushBtn(code, name);
});
}
gridEl.innerHTML = btns.join('');
gridEl.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const code = btn.getAttribute('data-code');
if (!code || !selectEl.querySelector(`option[value="${code}"]`)) return;
stopSpeaking();
selectEl.value = code;
if (selectEl === fullscreenTargetLangSelect) {
const validTarget = ensureFullscreenTargetLanguageValid(code);
currentResolvedTargetLang = resolveTargetLanguageValue(validTarget, currentResolvedTargetLang || defaultTargetLang);
} else if (selectEl === fullscreenSourceLangSelect && code !== 'auto') {
detectedSourceLang = code;
}
if (currentLabelEl === fullscreenSourceLangCurrent) {
updateFullscreenSourceCurrentLabel();
} else if (currentLabelEl === fullscreenTargetLangCurrent) {
updateFullscreenTargetCurrentLabel();
} else if (currentLabelEl) {
currentLabelEl.textContent = getLanguageLabel(code);
}
renderLanguageGrid(gridEl, searchEl, selectEl, currentLabelEl, panelEl);
if (panelEl) panelEl.style.display = 'none';
if (selectEl === fullscreenTargetLangSelect || selectEl === fullscreenSourceLangSelect) {
scheduleFullscreenTranslate(0, 'language');
}
});
});
if (currentLabelEl === fullscreenSourceLangCurrent) {
updateFullscreenSourceCurrentLabel();
} else if (currentLabelEl === fullscreenTargetLangCurrent) {
updateFullscreenTargetCurrentLabel();
} else if (currentLabelEl) {
currentLabelEl.textContent = getLanguageLabel(current);
}
}
if (fullscreenSourceCopy) fullscreenSourceCopy.addEventListener('click', () => {
const text = fullscreenSource.value || '';
if (!text) return;
navigator.clipboard.writeText(text);
const svg = fullscreenSourceCopy.querySelector('svg');
if (svg) {
svg.style.stroke = COPY_FEEDBACK_STROKE;
setTimeout(() => { svg.style.stroke = getIconDefaultStrokeColor(); }, 900);
}
});
if (fullscreenTargetCopy) fullscreenTargetCopy.addEventListener('click', () => {
const text = fullscreenTarget.value || '';
if (!text) return;
navigator.clipboard.writeText(text);
const svg = fullscreenTargetCopy.querySelector('svg');
if (svg) {
svg.style.stroke = COPY_FEEDBACK_STROKE;
setTimeout(() => { svg.style.stroke = getIconDefaultStrokeColor(); }, 900);
}
});
if (fullscreenSourceSpeak) fullscreenSourceSpeak.addEventListener('click', () => {
const text = fullscreenSource.value.trim();
if (!text) return;
if (speechPlaying && currentSpeakerId === 'fs-source') {
stopSpeaking();
return;
}
const selectedSrc = fullscreenSourceLangSelect ? fullscreenSourceLangSelect.value : 'auto';
const langForSpeech = resolveSourceSpeechLanguage(selectedSrc);
speak(text, langForSpeech, 'fs-source');
});
if (fullscreenTargetSpeak) fullscreenTargetSpeak.addEventListener('click', () => {
const text = fullscreenTarget.value.trim();
if (!text) return;
if (speechPlaying && currentSpeakerId === 'fs-target') {
stopSpeaking();
return;
}
const selectedTarget = fullscreenTargetLangSelect ? fullscreenTargetLangSelect.value : (targetLangSelect ? targetLangSelect.value : defaultTargetLang);
const tgtLang = resolveTargetSpeechLanguage(selectedTarget, currentResolvedTargetLang);
speak(text, tgtLang || browserLang, 'fs-target');
});
if (fullscreenSourceLangSearch) fullscreenSourceLangSearch.addEventListener('input', () => {
renderLanguageGrid(fullscreenSourceLangGrid, fullscreenSourceLangSearch, fullscreenSourceLangSelect, fullscreenSourceLangCurrent, fullscreenSourceLangPanel);
});
if (fullscreenTargetLangSearch) fullscreenTargetLangSearch.addEventListener('input', () => {
renderLanguageGrid(fullscreenTargetLangGrid, fullscreenTargetLangSearch, fullscreenTargetLangSelect, fullscreenTargetLangCurrent, fullscreenTargetLangPanel);
});
if (fullscreenSource) fullscreenSource.addEventListener('input', () => scheduleFullscreenTranslate(250, 'translate'));
if (fullscreenSourceLangSelect) fullscreenSourceLangSelect.addEventListener('change', () => {
if (fullscreenSourceLangSelect.value !== 'auto') {
detectedSourceLang = fullscreenSourceLangSelect.value;
}
updateFullscreenSourceCurrentLabel();
scheduleFullscreenTranslate(0, 'language');
});
if (fullscreenTargetLangSelect) fullscreenTargetLangSelect.addEventListener('change', () => {
const validTarget = ensureFullscreenTargetLanguageValid(fullscreenTargetLangSelect.value);
currentResolvedTargetLang = resolveTargetLanguageValue(validTarget, currentResolvedTargetLang || defaultTargetLang);
updateFullscreenTargetCurrentLabel();
scheduleFullscreenTranslate(0, 'language');
});
function swapFullscreenContent() {
if (!fullscreenSource || !fullscreenTarget || !fullscreenSourceLangSelect || !fullscreenTargetLangSelect) return;
stopSpeaking();
const srcText = fullscreenSource.value;
fullscreenSource.value = fullscreenTarget.value;
fullscreenTarget.value = srcText;
const srcLang = fullscreenSourceLangSelect.value || 'auto';
const tgtLang = fullscreenTargetLangSelect.value || defaultTargetLang;
fullscreenSourceLangSelect.value = fullscreenSourceLangSelect.querySelector(`option[value="${tgtLang}"]`) ? tgtLang : 'auto';
let swappedTargetLang = srcLang;
if (swappedTargetLang === 'auto') {
swappedTargetLang = resolveTargetLanguageValue(
(detectedSourceLang && detectedSourceLang !== 'auto') ? detectedSourceLang : currentResolvedTargetLang,
defaultTargetLang
);
}
const validTarget = ensureFullscreenTargetLanguageValid(swappedTargetLang);
currentResolvedTargetLang = resolveTargetLanguageValue(validTarget, defaultTargetLang);
if (fullscreenSourceLangSelect.value !== 'auto') {
detectedSourceLang = fullscreenSourceLangSelect.value;
}
updateFullscreenSourceCurrentLabel();
updateFullscreenTargetCurrentLabel();
scheduleFullscreenTranslate(0, 'language');
}
if (fullscreenSwap) {
fullscreenSwap.addEventListener('click', () => {
swapFullscreenContent();
fullscreenSwapRotation += 360;
fullscreenSwap.style.transform = `rotate(${fullscreenSwapRotation}deg)`;
});
}
function togglePanel(panelEl, otherPanel) {
if (!panelEl) return;
const isOpen = panelEl.style.display === 'block';
hideLanguagePanels();
panelEl.style.display = isOpen ? 'none' : 'block';
}
if (fullscreenSourceLangTrigger) fullscreenSourceLangTrigger.addEventListener('click', (e) => {
e.stopPropagation();
togglePanel(fullscreenSourceLangPanel, fullscreenTargetLangPanel);
renderLanguageGrid(fullscreenSourceLangGrid, fullscreenSourceLangSearch, fullscreenSourceLangSelect, fullscreenSourceLangCurrent, fullscreenSourceLangPanel);
});
if (fullscreenTargetLangTrigger) fullscreenTargetLangTrigger.addEventListener('click', (e) => {
e.stopPropagation();
togglePanel(fullscreenTargetLangPanel, fullscreenSourceLangPanel);
renderLanguageGrid(fullscreenTargetLangGrid, fullscreenTargetLangSearch, fullscreenTargetLangSelect, fullscreenTargetLangCurrent, fullscreenTargetLangPanel);
});
document.addEventListener('mousedown', (e) => {
if (selectionBubble && !selectionBubble.contains(e.target)) {
hideBubbleCloseMenu();
}
if (fullscreenSourceLangPanel && !fullscreenSourceLangPanel.contains(e.target) && fullscreenSourceLangTrigger && !fullscreenSourceLangTrigger.contains(e.target)) {
fullscreenSourceLangPanel.style.display = 'none';
}
if (fullscreenTargetLangPanel && !fullscreenTargetLangPanel.contains(e.target) && fullscreenTargetLangTrigger && !fullscreenTargetLangTrigger.contains(e.target)) {
fullscreenTargetLangPanel.style.display = 'none';
}
if (panelThemePanel && !panelThemePanel.contains(e.target) && panelThemeTrigger && !panelThemeTrigger.contains(e.target)) {
panelThemePanel.style.display = 'none';
}
inlineLanguagePanels.forEach(({ panel, selectEl }) => {
if (!panel.contains(e.target) && !selectEl.contains(e.target)) {
panel.style.display = 'none';
}
});
});
function hideInlinePanels(except) {
inlineLanguagePanels.forEach(p => {
if (p.panel === except) return;
p.panel.style.display = 'none';
});
}
function positionInlinePanel(panel, selectEl) {
if (!panel || panel.style.display !== 'block' || !selectEl) return;
const rect = selectEl.getBoundingClientRect();
const scrollX = window.scrollX || document.documentElement.scrollLeft || 0;
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
const panelWidth = panel.offsetWidth || 280;
const left = Math.min(rect.left + scrollX, scrollX + window.innerWidth - panelWidth - 10);
const top = rect.bottom + scrollY + 4;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
}
function updateInlinePanelsPosition() {
inlineLanguagePanels.forEach(({ panel, selectEl }) => {
positionInlinePanel(panel, selectEl);
});
}
function buildInlinePanel(selectEl, placeholder = langNames.navigator) {
const panel = document.createElement('div');
panel.style.cssText = `
display:none;
position: absolute;
width: 280px;
max-height: 260px;
background: rgba(30,30,47,0.98);
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 10px 24px rgba(0,0,0,0.35);
border-radius: 10px;
padding: 8px;
z-index: 2147483646;
`;
panel.innerHTML = `
<input class="inlineLangSearch" placeholder="${placeholder}" style="width:100%; max-width:100%; box-sizing:border-box; padding:8px 10px; border-radius:8px; border:1px solid rgba(255,255,255,0.14); background: rgba(255,255,255,0.08); color:#fff; font-size:13px; outline:none;" />
<div class="inlineLangGrid" style="display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; max-height:190px; overflow-y:auto; padding-top:8px;"></div>
`;
panel.classList.add('utst-inline-lang-panel');
const searchEl = panel.querySelector('.inlineLangSearch');
applyLanguagePanelContainerTheme(panel, searchEl);
panel.classList.add("utst-scroll");
document.body.appendChild(panel);
inlineLanguagePanels.push({ panel, selectEl });
return panel;
}
function attachInlineLanguagePanel(selectEl) {
if (!selectEl) return;
const panel = buildInlinePanel(selectEl);
const searchEl = panel.querySelector('.inlineLangSearch');
const gridEl = panel.querySelector('.inlineLangGrid');
function render() {
const opts = Array.from(selectEl.options)
.filter(o => !o.disabled)
.map(o => ({ value: o.value, label: o.textContent || o.value }));
renderLanguageGrid(gridEl, searchEl, selectEl, null, panel, opts);
gridEl.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const code = btn.getAttribute('data-code');
selectEl.value = code;
selectEl.dispatchEvent(new Event('change', { bubbles: true }));
hideInlinePanels();
});
});
}
if (searchEl) searchEl.addEventListener('input', render);
const openInlinePanel = (e) => {
e.preventDefault();
e.stopPropagation();
const isOpen = panel.style.display === 'block';
hideInlinePanels(panel);
if (isOpen) {
panel.style.display = 'none';
return;
}
render();
panel.style.display = 'block';
positionInlinePanel(panel, selectEl);
};
selectEl.addEventListener('pointerdown', openInlinePanel, { capture: true });
selectEl.addEventListener('mousedown', openInlinePanel);
selectEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
openInlinePanel(e);
}
});
}
attachInlineLanguagePanel(sourceLangSelect);
attachInlineLanguagePanel(targetLangSelect);
attachInlineLanguagePanel(defaultTranslateLangSelect);
attachInlineLanguagePanel(toolLanguageSelect);
refreshLanguagePanelTheme();
if (fullscreenToggle) fullscreenToggle.addEventListener('click', openFullscreenOverlay);
if (fullscreenClose) fullscreenClose.addEventListener('click', closeFullscreenOverlay);
const closeButton = translationBox.querySelector('#closeButton');
closeButton.addEventListener('click', () => {
panelTranslateRequestId++;
setPanelLoading(false);
translationBox.style.display = 'none';
translationBox.style.opacity = '0';
translationBox.style.transform = 'translateY(10px)';
sourceLangSelect.value = 'auto';
detectedSourceLang = 'auto';
stopSpeaking();
const translatorPanel = document.getElementById('translatorPanel');
const settingsPanel = document.getElementById('settingsPanel');
if (translatorPanel) translatorPanel.style.display = 'block';
if (settingsPanel) settingsPanel.style.display = 'none';
if (settingsHeader) settingsHeader.style.display = 'none';
translationBox.classList.remove('utst-settings-open');
scheduleSelectionBubbleUpdate(0);
});
settingsButton.addEventListener('click', () => {
const translatorPanel = document.getElementById('translatorPanel');
const settingsPanel = document.getElementById('settingsPanel');
if (translatorPanel) translatorPanel.style.display = 'none';
if (settingsPanel) settingsPanel.style.display = 'block';
if (settingsHeaderTitle) settingsHeaderTitle.textContent = settingsTitle;
if (settingsHeader) settingsHeader.style.display = 'flex';
translationBox.classList.remove('utst-settings-open');
});
backButton.addEventListener('click', () => {
const translatorPanel = document.getElementById('translatorPanel');
const settingsPanel = document.getElementById('settingsPanel');
if (translatorPanel) translatorPanel.style.display = 'block';
if (settingsPanel) settingsPanel.style.display = 'none';
if (settingsHeader) settingsHeader.style.display = 'none';
translationBox.classList.remove('utst-settings-open');
});
document.addEventListener('mousedown', (e) => {
const clickInInlinePanel = inlineLanguagePanels.some(({ panel, selectEl }) =>
panel.contains(e.target) || selectEl.contains(e.target)
);
const clickInFullscreenLangPanel =
(fullscreenSourceLangPanel && fullscreenSourceLangPanel.contains(e.target)) ||
(fullscreenTargetLangPanel && fullscreenTargetLangPanel.contains(e.target)) ||
(fullscreenSourceLangTrigger && fullscreenSourceLangTrigger.contains(e.target)) ||
(fullscreenTargetLangTrigger && fullscreenTargetLangTrigger.contains(e.target));
const clickInFullscreenOverlay = fullscreenOverlay && fullscreenOverlay.contains(e.target);
const clickInSelectionBubble = selectionBubble && selectionBubble.contains(e.target);
if (clickInInlinePanel || clickInFullscreenLangPanel || clickInFullscreenOverlay || clickInSelectionBubble) return;
if (!translationBox.contains(e.target)) {
panelTranslateRequestId++;
setPanelLoading(false);
translationBox.style.display = 'none';
translationBox.style.opacity = '0';
translationBox.style.transform = 'translateY(10px)';
sourceLangSelect.value = 'auto';
detectedSourceLang = 'auto';
if (settingsHeader) settingsHeader.style.display = 'none';
translationBox.classList.remove('utst-settings-open');
stopSpeaking();
scheduleSelectionBubbleUpdate(0);
}
});
function adjustBoxPosition() {
const rect = translationBox.getBoundingClientRect();
if (rect.right > window.innerWidth) {
translationBox.style.left = `${window.innerWidth - rect.width - 10}px`;
}
if (rect.bottom > window.innerHeight) {
translationBox.style.top = `${window.innerHeight - rect.height - 10}px`;
}
}
translationBox.addEventListener('transitionend', adjustBoxPosition);
document.addEventListener('selectionchange', () => {
if (isSelectingPointer) {
hideSelectionBubble();
return;
}
scheduleSelectionBubbleUpdate();
});
document.addEventListener('mouseup', () => {
isSelectingPointer = false;
scheduleSelectionBubbleUpdate();
}, true);
document.addEventListener('keyup', () => {
scheduleSelectionBubbleUpdate();
});
window.addEventListener('scroll', () => {
updateInlinePanelsPosition();
scheduleSelectionBubbleUpdate(0);
}, true);
window.addEventListener('resize', () => {
updateInlinePanelsPosition();
hideSelectionBubble();
scheduleSelectionBubbleUpdate(40);
});
}
if (document.body) {
bootstrap();
} else {
window.addEventListener('DOMContentLoaded', bootstrap);
}
})();