// ==UserScript== // @name Quick Translate Input // @namespace https://github.com/StringManolo/userscripts // @version 1.0 // @description Traduce al idioma que elijas lo que escribas en un input flotante. Se abre con doble clic (PC) o dos toques consecutivos con 2 dedos (móvil). Persistencia del idioma, copia y pronunciación. // @author StringManolo // @license GPL-3.0-or-later // @homepageURL https://github.com/StringManolo/userscripts // @supportURL https://github.com/StringManolo/userscripts/issues // @match *://*/* // @noframes // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect translate.googleapis.com // @connect wiktionary.org // @connect *.wiktionary.org // @connect upload.wikimedia.org // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ------------------------------------------------------------------ // CONFIGURACIÓN // ------------------------------------------------------------------ const STORAGE_KEY = 'qti_target_lang'; const DEFAULT_LANG = 'es'; const DOUBLE_TAP_DELAY = 500; // ms entre toques para considerar doble toque // Detección de dispositivo táctil (más fiable) const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); console.log(`[QTI] Dispositivo táctil: ${isTouchDevice}`); // ------------------------------------------------------------------ // ESTADO // ------------------------------------------------------------------ let currentTargetLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG; let lastOriginalText = ''; let lastTranslatedText = ''; let lastDetectedLang = 'en'; let audioCache = new Map(); let floatInputVisible = false; let lastClickTime = 0; let toastVisible = false; // Variables para detección de doble toque con 2 dedos let twoFingerTouchCount = 0; // número de toques completos con 2 dedos let twoFingerTimer = null; // timer para reiniciar el contador let isTouching = false; // si actualmente hay un toque con 2 dedos en curso let touchStartTime = 0; // para depuración // ------------------------------------------------------------------ // ESTILOS (igual que antes) // ------------------------------------------------------------------ GM_addStyle(` /* TOAST */ #qti-toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(16px); max-width: 88vw; background: #1e1e1e; color: #fff; padding: 14px 20px; border-radius: 16px; font-size: 16px; line-height: 1.5; box-shadow: 0 8px 30px rgba(0,0,0,0.5); z-index: 2147483647; opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; font-family: -apple-system, Roboto, Arial, sans-serif; cursor: pointer; display: flex; align-items: center; gap: 12px; border: 1px solid #333; } #qti-toast.qti-visible { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; } #qti-toast .qti-text { flex: 1; } #qti-toast .qti-text b { font-weight: 600; display: block; } #qti-toast .qti-hint { font-size: 11px; opacity: .65; margin-top: 2px; } #qti-toast .qti-copy-btn { background: transparent; border: none; color: #fff; border-radius: 8px; padding: 4px 8px; font-size: 18px; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; justify-content: center; line-height: 1; flex-shrink: 0; opacity: 0.7; } #qti-toast .qti-copy-btn:hover { background: rgba(255,255,255,0.1); opacity: 1; } #qti-toast .qti-copy-btn:active { transform: scale(0.95); } #qti-toast.qti-loading .qti-hint { opacity: .9; } /* INPUT FLOTANTE */ #qti-float-container { position: fixed; background: #1e1e1e; border: 1px solid #444; border-radius: 16px; padding: 10px 14px; box-shadow: 0 8px 30px rgba(0,0,0,0.6); z-index: 2147483647; display: none; align-items: center; gap: 10px; font-family: -apple-system, Roboto, Arial, sans-serif; backdrop-filter: blur(6px); background: rgba(30,30,30,0.95); max-width: 90vw; } #qti-float-container.qti-visible { display: flex; } /* Posición en escritorio (abajo a la derecha) */ #qti-float-container { right: 20px; bottom: 30px; } /* Posición en móvil (arriba con margen) */ @media (pointer: coarse) { #qti-float-container { right: 10px; top: 20px; bottom: auto; left: 10px; width: auto; } } #qti-float-input { background: transparent; border: none; color: #fff; font-size: 16px; padding: 6px 8px; outline: none; min-width: 140px; flex: 1; font-family: inherit; } #qti-float-input::placeholder { color: #888; } #qti-lang-selector { background: #333; border: none; color: #fff; border-radius: 8px; padding: 4px 8px; font-size: 13px; cursor: pointer; outline: none; font-family: inherit; flex-shrink: 0; } #qti-lang-selector option { background: #1e1e1e; color: #fff; } #qti-float-close { background: transparent; border: none; color: #888; font-size: 18px; cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.2s; flex-shrink: 0; } #qti-float-close:hover { color: #fff; } /* Indicador de carga en el toast */ .qti-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: qti-spin 0.8s linear infinite; margin-right: 8px; } @keyframes qti-spin { to { transform: rotate(360deg); } } `); // ------------------------------------------------------------------ // UI: TOAST // ------------------------------------------------------------------ const toast = document.createElement('div'); toast.id = 'qti-toast'; document.documentElement.appendChild(toast); function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function renderToast(main, hint, { loading = false, copyText = null } = {}) { const copyBtn = copyText !== null ? `` : ''; toast.innerHTML = `
${loading ? '' : ''} ${escapeHtml(main)} ${hint}
${copyBtn} `; toast.classList.toggle('qti-loading', loading); toast.classList.add('qti-visible'); toastVisible = true; const copyAction = document.getElementById('qti-copy-action'); if (copyAction) { copyAction.addEventListener('click', (e) => { e.stopPropagation(); if (copyText !== null) { navigator.clipboard.writeText(copyText).then(() => { copyAction.textContent = '✅'; setTimeout(() => { copyAction.textContent = '📋'; }, 2000); }).catch(() => { const textArea = document.createElement('textarea'); textArea.value = copyText; document.body.appendChild(textArea); textArea.select(); document.execCommand('copy'); document.body.removeChild(textArea); copyAction.textContent = '✅'; setTimeout(() => { copyAction.textContent = '📋'; }, 2000); }); } }); } } function hideToast() { toast.classList.remove('qti-visible'); toastVisible = false; } // Cerrar toast al hacer clic/touch fuera, excluyendo input y toast document.addEventListener('click', (e) => { if (toastVisible && !toast.contains(e.target) && !container.contains(e.target)) { hideToast(); } }, true); document.addEventListener('touchstart', (e) => { if (toastVisible && !toast.contains(e.target) && !container.contains(e.target)) { hideToast(); } }, { passive: true }); // ------------------------------------------------------------------ // UI: INPUT FLOTANTE // ------------------------------------------------------------------ const container = document.createElement('div'); container.id = 'qti-float-container'; container.innerHTML = ` `; document.documentElement.appendChild(container); const floatInput = document.getElementById('qti-float-input'); const langSelector = document.getElementById('qti-lang-selector'); const closeBtn = document.getElementById('qti-float-close'); // Poblar selector de idiomas const LANG_OPTIONS = [ { code: 'es', label: '🇪🇸 Español' }, { code: 'en', label: '🇬🇧 Inglés' }, { code: 'fr', label: '🇫🇷 Francés' }, { code: 'de', label: '🇩🇪 Alemán' }, { code: 'ru', label: '🇷🇺 Ruso' }, { code: 'pt', label: '🇵🇹 Portugués' }, { code: 'it', label: '🇮🇹 Italiano' }, { code: 'ja', label: '🇯🇵 Japonés' }, { code: 'zh', label: '🇨🇳 Chino' }, { code: 'ar', label: '🇸🇦 Árabe' }, ]; LANG_OPTIONS.forEach(opt => { const option = document.createElement('option'); option.value = opt.code; option.textContent = opt.label; langSelector.appendChild(option); }); langSelector.value = currentTargetLang; function showFloatInput() { container.classList.add('qti-visible'); floatInputVisible = true; floatInput.value = ''; if (isTouchDevice) { setTimeout(() => floatInput.focus(), 100); } else { floatInput.focus(); } } function hideFloatInput() { container.classList.remove('qti-visible'); floatInputVisible = false; } // Cerrar al hacer clic/touch fuera del input document.addEventListener('click', (e) => { if (floatInputVisible && !container.contains(e.target)) { hideFloatInput(); } }, true); document.addEventListener('touchstart', (e) => { if (floatInputVisible && !container.contains(e.target)) { hideFloatInput(); } }, { passive: true }); closeBtn.addEventListener('click', (e) => { e.stopPropagation(); hideFloatInput(); }); floatInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideFloatInput(); }); // ------------------------------------------------------------------ // GESTOS: DOBLE CLIC (PC) y DOBLE TOQUE CON 2 DEDOS (móvil) // ------------------------------------------------------------------ // --- Doble clic (solo si NO es táctil) --- if (!isTouchDevice) { document.addEventListener('click', (e) => { const target = e.target; if (target.closest('a') || target.closest('button') || target.closest('input') || target.closest('textarea')) return; if (container.contains(target)) return; if (toast.contains(target)) return; const now = Date.now(); if (now - lastClickTime < DOUBLE_TAP_DELAY) { e.preventDefault(); if (floatInputVisible) hideFloatInput(); else showFloatInput(); lastClickTime = 0; } else { lastClickTime = now; } }, true); } // --- Doble toque con 2 dedos (solo si es táctil) --- if (isTouchDevice) { // Contamos los toques completos con 2 dedos document.addEventListener('touchstart', (e) => { const target = e.target; if (target.closest('a') || target.closest('button') || target.closest('input') || target.closest('textarea')) return; if (container.contains(target)) return; if (toast.contains(target)) return; const touches = e.touches; // Si no son 2 dedos, reiniciamos el estado if (touches.length !== 2) { if (isTouching) { // Si estaba en un toque con 2 dedos y ahora no, lo consideramos un toque "cancelado" isTouching = false; } return; } // Si ya estamos en un toque con 2 dedos, ignoramos (evita múltiples eventos) if (isTouching) { return; } // Primer toque con 2 dedos: marcamos que estamos tocando isTouching = true; touchStartTime = Date.now(); console.log('[QTI] Inicio toque con 2 dedos'); }, { passive: true }); // Cuando se levantan los dedos, contamos el toque document.addEventListener('touchend', (e) => { // Solo procesamos si estábamos en un toque con 2 dedos if (!isTouching) return; // Verificar si los dedos que se levantan son 2 (puede haber más de uno) // Pero como solo nos interesan los toques con 2 dedos, usamos un contador simple isTouching = false; const elapsed = Date.now() - touchStartTime; // Incrementamos el contador de toques completos con 2 dedos twoFingerTouchCount++; console.log(`[QTI] Toque con 2 dedos completado (#${twoFingerTouchCount}) en ${elapsed}ms`); // Reiniciamos el timer para que el contador vuelva a 0 si pasa el tiempo clearTimeout(twoFingerTimer); twoFingerTimer = setTimeout(() => { console.log('[QTI] Reiniciado contador por timeout (más de 500ms sin nuevo toque)'); twoFingerTouchCount = 0; }, DOUBLE_TAP_DELAY); // Si llegamos a 2 toques consecutivos, activamos el input if (twoFingerTouchCount >= 2) { console.log('[QTI] ✅ Doble toque con 2 dedos detectado (2 toques consecutivos)'); // Evitamos múltiples activaciones twoFingerTouchCount = 0; clearTimeout(twoFingerTimer); twoFingerTimer = null; // Ejecutar en el siguiente ciclo para evitar conflictos con el evento actual setTimeout(() => { if (floatInputVisible) hideFloatInput(); else showFloatInput(); }, 50); } }, { passive: true }); // Si el toque se cancela (por ejemplo, se mueven los dedos), reiniciamos document.addEventListener('touchcancel', (e) => { if (isTouching) { console.log('[QTI] Toque cancelado'); isTouching = false; twoFingerTouchCount = 0; clearTimeout(twoFingerTimer); twoFingerTimer = null; } }, { passive: true }); } // ------------------------------------------------------------------ // COMPORTAMIENTO DEL INPUT // ------------------------------------------------------------------ floatInput.addEventListener('keydown', async (e) => { if (e.key === 'Enter') { const text = floatInput.value.trim(); if (!text) return; const targetLang = langSelector.value; localStorage.setItem(STORAGE_KEY, targetLang); currentTargetLang = targetLang; try { const { translated, detectedLang } = await translateText(text, targetLang); lastOriginalText = text; lastTranslatedText = translated; lastDetectedLang = normalizeLang(detectedLang); renderToast( translated, `${detectedLang} → ${targetLang} · 🔊 toca para escuchar`, { copyText: translated } ); } catch (err) { renderToast('Error al traducir', 'Revisa conexión.', { copyText: null }); } } }); langSelector.addEventListener('change', () => { const newLang = langSelector.value; localStorage.setItem(STORAGE_KEY, newLang); currentTargetLang = newLang; }); // ------------------------------------------------------------------ // TRADUCCIÓN // ------------------------------------------------------------------ function translateText(text, targetLang = currentTargetLang) { const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' + targetLang + '&dt=t&q=' + encodeURIComponent(text); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { try { const data = JSON.parse(res.responseText); const translated = data[0].map(seg => seg[0]).join(''); const detectedLang = data[2] || 'en'; resolve({ translated, detectedLang }); } catch (e) { reject(e); } }, onerror: reject, ontimeout: reject, timeout: 8000, }); }); } function normalizeLang(code) { return (code || 'en').split('-')[0].toLowerCase(); } // ------------------------------------------------------------------ // PRONUNCIACIÓN (del texto traducido en el idioma destino) // ------------------------------------------------------------------ const LANG_SUBDOMAIN = { en: 'en', es: 'es', fr: 'fr', de: 'de', it: 'it', pt: 'pt', ru: 'ru', zh: 'zh', ja: 'ja', ar: 'ar', nl: 'nl', pl: 'pl', sv: 'sv', ko: 'ko', tr: 'tr', vi: 'vi', id: 'id', ms: 'ms', }; function fetchWiktionaryParse(word, lang) { const sub = LANG_SUBDOMAIN[lang] || lang; const url = `https://${sub}.wiktionary.org/w/api.php?action=parse&page=${encodeURIComponent(word)}&prop=text&format=json&origin=*`; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { try { const data = JSON.parse(res.responseText); const html = data.parse?.text?.['*'] || ''; const audioRegex = /]+src="([^"]+\.(?:ogg|mp3|wav))"/i; const match = html.match(audioRegex); if (match) { let audioUrl = match[1]; if (audioUrl.startsWith('//')) audioUrl = 'https:' + audioUrl; else if (audioUrl.startsWith('/')) audioUrl = 'https://' + sub + '.wiktionary.org' + audioUrl; resolve(audioUrl); } else { const hrefRegex = /href="([^"]+\.(?:ogg|mp3|wav))"/i; const hrefMatch = html.match(hrefRegex); if (hrefMatch) { let audioUrl = hrefMatch[1]; if (audioUrl.startsWith('//')) audioUrl = 'https:' + audioUrl; else if (audioUrl.startsWith('/')) audioUrl = 'https://' + sub + '.wiktionary.org' + audioUrl; resolve(audioUrl); } else { resolve(null); } } } catch (e) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), timeout: 5000, }); }); } function fetchWiktionaryRaw(word, lang) { const sub = LANG_SUBDOMAIN[lang] || lang; const url = `https://${sub}.wiktionary.org/w/index.php?title=${encodeURIComponent(word)}&action=raw`; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { try { const wikitext = res.responseText; const audioTemplate = /{{(?:audio|pronunciaci[óo]n|pronunciation)\s*\|([^}|]+)(?:\|[^}]*)?}}/i; const match = wikitext.match(audioTemplate); if (match) { let fileName = match[1].trim(); resolve(fetchCommonsFileUrl(fileName)); return; } const mediaLink = /\[\[Media:([^\]]+)\]\]/i; const mediaMatch = wikitext.match(mediaLink); if (mediaMatch) { let fileName = mediaMatch[1].trim(); resolve(fetchCommonsFileUrl(fileName)); return; } resolve(null); } catch (e) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), timeout: 5000, }); }); } function fetchCommonsFileUrl(fileName) { const url = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent(fileName)}&prop=imageinfo&iiprop=url&format=json&origin=*`; return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url, onload: (res) => { try { const data = JSON.parse(res.responseText); const pages = data.query?.pages; for (const pageId in pages) { const info = pages[pageId]?.imageinfo?.[0]; if (info?.url) { resolve(info.url); return; } } resolve(null); } catch (e) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), timeout: 5000, }); }); } async function getWiktionaryAudio(word, lang) { if (!LANG_SUBDOMAIN[lang]) return null; const cacheKey = `${lang}:${word}`; if (audioCache.has(cacheKey)) return audioCache.get(cacheKey); let url = await fetchWiktionaryParse(word, lang); if (!url) url = await fetchWiktionaryRaw(word, lang); audioCache.set(cacheKey, url); return url ? { url, source: 'Wiktionary' } : null; } function playGoogleTTS(word, lang) { const url = 'https://translate.google.com/translate_tts?ie=UTF-8&q=' + encodeURIComponent(word) + '&tl=' + lang + '&client=tw-ob'; const audio = new Audio(url); audio.play().catch(() => {}); } async function pronounce(text, lang) { renderToast(lastTranslatedText, 'Buscando pronunciación…', { loading: true, copyText: null }); let result = null; try { result = await getWiktionaryAudio(text, lang); } catch (e) {} if (result) { try { const audio = new Audio(result.url); await audio.play(); renderToast( lastTranslatedText, `${lang} (${result.source}) · toca para repetir`, { copyText: lastTranslatedText } ); return; } catch (e) {} } playGoogleTTS(text, lang); renderToast( lastTranslatedText, `${lang} (Google TTS) · toca para repetir`, { copyText: lastTranslatedText } ); } // ------------------------------------------------------------------ // PRONUNCIACIÓN AL TOCAR EL TOAST // ------------------------------------------------------------------ toast.addEventListener('click', (e) => { if (e.target.closest('.qti-copy-btn')) return; if (!lastTranslatedText || !currentTargetLang) return; pronounce(lastTranslatedText, currentTargetLang); }); // ------------------------------------------------------------------ // INICIALIZACIÓN // ------------------------------------------------------------------ langSelector.value = currentTargetLang; console.log('✅ Quick Translate Input cargado. Doble clic (PC) o DOS toques consecutivos con 2 dedos (móvil) para abrir el input.'); })();