Hover a paragraph, then press Shift to toggle translation. Press Shift with selected text to show translation tooltip.
// ==UserScript==
// @name Shift Translator Hover Toggle + Selection Tooltip (Chrome Translator API)
// @namespace https://example.com/
// @version 0.8.0
// @description Hover a paragraph, then press Shift to toggle translation. Press Shift with selected text to show translation tooltip.
// @match *://*/*
// @run-at document-idle
// @grant none
// @license MIT
// ==/UserScript==
(() => {
'use strict';
const SOURCE_LANGUAGE = 'en';
const TARGET_LANGUAGE_CANDIDATES = ['zh-CN', 'zh', 'zh-Hans'];
const PARAGRAPH_SELECTOR = 'div,p, li, blockquote';
const EXCLUDED_SELECTOR = [
'script',
'style',
'noscript',
'textarea',
'code',
'pre',
'kbd',
'samp',
'svg',
'canvas',
'[translate="no"]',
'[data-tm-no-translate="1"]',
].join(',');
const TRANSLATED_COPY_ATTR = 'data-tm-translated-copy';
const TRANSLATED_FROM_ATTR = 'data-tm-translated-from';
const SOURCE_ID_ATTR = 'data-tm-source-id';
const LOADING_ATTR = 'data-tm-loading-placeholder';
const translatorCache = new Map();
let activeToast = null;
let toastTimer = null;
let tooltipState = null;
let hoveredParagraph = null;
const style = document.createElement('style');
style.textContent = `
@keyframes tm-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.tm-spinner {
width: 16px;
height: 16px;
border-radius: 999px;
border: 2px solid rgba(0,0,0,0.16);
border-top-color: rgba(0,0,0,0.72);
animation: tm-spin 0.8s linear infinite;
flex: 0 0 auto;
}
.tm-loading-row {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 0;
color: rgba(0,0,0,0.62);
font: 13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
.tm-loading-bubble {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(0,0,0,0.04);
color: rgba(0,0,0,0.68);
font: 13px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
`;
document.documentElement.appendChild(style);
function uid() {
return `tm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function showToast(message, ms = 1600, isError = false) {
if (!activeToast) {
activeToast = document.createElement('div');
activeToast.style.cssText = [
'position:fixed',
'left:16px',
'bottom:16px',
'z-index:2147483647',
'max-width:min(520px,calc(100vw - 32px))',
'padding:10px 12px',
'border-radius:10px',
'background:rgba(20,20,20,0.92)',
'color:#fff',
'font:13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'box-shadow:0 8px 30px rgba(0,0,0,0.28)',
'white-space:pre-wrap',
'pointer-events:none',
].join(';');
document.documentElement.appendChild(activeToast);
}
activeToast.textContent = message;
activeToast.style.background = isError ? 'rgba(176,0,32,0.94)' : 'rgba(20,20,20,0.92)';
activeToast.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (activeToast) {
activeToast.style.display = 'none';
}
}, isError ? 5000 : ms);
}
function isElement(value) {
return value && value.nodeType === Node.ELEMENT_NODE;
}
function isParagraphLike(el) {
return isElement(el) && el.matches(PARAGRAPH_SELECTOR);
}
function getParagraphFromPoint(event) {
if (typeof document.elementsFromPoint === 'function') {
const stack = document.elementsFromPoint(event.clientX, event.clientY);
for (const el of stack) {
if (!isElement(el)) continue;
if (el.closest(`[${TRANSLATED_COPY_ATTR}="1"]`)) continue;
const paragraph = el.closest(PARAGRAPH_SELECTOR);
if (paragraph) return paragraph;
}
}
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
if (!target) return null;
const paragraph = target.closest(PARAGRAPH_SELECTOR);
if (!paragraph) return null;
if (paragraph.closest(`[${TRANSLATED_COPY_ATTR}="1"]`)) return null;
return paragraph;
}
function stripDuplicateIds(root) {
if (!root || root.nodeType !== Node.ELEMENT_NODE) return;
if (root.hasAttribute('id')) {
root.removeAttribute('id');
}
root.querySelectorAll('[id]').forEach((el) => {
el.removeAttribute('id');
});
}
function collectTranslatableTextNodes(root) {
const nodes = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node?.nodeValue?.trim()) {
return NodeFilter.FILTER_REJECT;
}
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (parent.closest(EXCLUDED_SELECTOR)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
},
});
let current;
while ((current = walker.nextNode())) {
nodes.push(current);
}
return nodes;
}
async function getTranslator() {
if (!('Translator' in self)) {
throw new Error('Translator API is not available in this browser.');
}
for (const targetLanguage of TARGET_LANGUAGE_CANDIDATES) {
const cacheKey = `${SOURCE_LANGUAGE}->${targetLanguage}`;
if (translatorCache.has(cacheKey)) {
return translatorCache.get(cacheKey);
}
let availability;
try {
availability = await Translator.availability({
sourceLanguage: SOURCE_LANGUAGE,
targetLanguage,
});
} catch {
continue;
}
if (availability !== 'available' && availability !== 'downloadable') {
continue;
}
const promise = (async () => {
const options = {
sourceLanguage: SOURCE_LANGUAGE,
targetLanguage,
};
return Translator.create(options);
})();
translatorCache.set(cacheKey, promise);
try {
return await promise;
} catch {
translatorCache.delete(cacheKey);
}
}
throw new Error('No supported English → Chinese translator pair available.');
}
async function translatePlainText(text) {
const translator = await getTranslator();
return translator.translate(text);
}
async function translateCloneTree(clone) {
const translator = await getTranslator();
const textNodes = collectTranslatableTextNodes(clone);
for (const node of textNodes) {
const text = node.nodeValue;
if (!text?.trim()) continue;
try {
const translated = await translator.translate(text);
if (translated) {
node.nodeValue = translated;
}
} catch (err) {
console.warn('[TM Translator]', err);
}
}
}
function findExistingTranslation(original) {
const sourceId = original.getAttribute(SOURCE_ID_ATTR);
if (!sourceId || !original.parentElement) {
return null;
}
const sibling = original.nextElementSibling;
if (
sibling &&
sibling.getAttribute(TRANSLATED_COPY_ATTR) === '1' &&
sibling.getAttribute(TRANSLATED_FROM_ATTR) === sourceId
) {
return sibling;
}
return null;
}
function createLoadingRow(text = 'Translating...') {
const row = document.createElement('div');
row.className = 'tm-loading-row';
row.innerHTML = `
<span class="tm-spinner" aria-hidden="true"></span>
<span>${text}</span>
`;
return row;
}
async function toggleTranslation(original) {
let sourceId = original.getAttribute(SOURCE_ID_ATTR);
if (!sourceId) {
sourceId = uid();
original.setAttribute(SOURCE_ID_ATTR, sourceId);
}
const existing = findExistingTranslation(original);
if (existing) {
existing.remove();
showToast('Translation hidden.');
return;
}
const loadingRow = createLoadingRow('Translating...');
loadingRow.setAttribute(LOADING_ATTR, '1');
original.insertAdjacentElement('afterend', loadingRow);
const clone = original.cloneNode(true);
stripDuplicateIds(clone);
clone.style.opacity = '0.3';
clone.setAttribute(TRANSLATED_COPY_ATTR, '1');
clone.setAttribute(TRANSLATED_FROM_ATTR, sourceId);
try {
await translateCloneTree(clone);
if (loadingRow.isConnected) {
loadingRow.replaceWith(clone);
}
showToast('Translation shown.');
} catch (err) {
if (loadingRow.isConnected) {
loadingRow.remove();
}
console.error('[TM Translator]', err);
showToast(err?.message || 'Translation failed.', 5000, true);
throw err;
}
}
function getSelectedText() {
const selection = window.getSelection?.();
if (selection && !selection.isCollapsed) {
const text = selection.toString();
if (text?.trim()) {
const range = selection.getRangeAt(0);
return {
text,
rect: range.getBoundingClientRect(),
};
}
}
return null;
}
function closeTooltip() {
if (!tooltipState) return;
const { tooltip, onPointerDown, onBlur, onVisibilityChange } = tooltipState;
document.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('blur', onBlur, true);
document.removeEventListener('visibilitychange', onVisibilityChange, true);
tooltip.remove();
tooltipState = null;
}
async function openSelectionTooltip(selectionData) {
if (!selectionData?.text?.trim()) {
return;
}
closeTooltip();
const tooltip = document.createElement('div');
tooltip.style.cssText = [
'position:fixed',
'z-index:2147483647',
'max-width:min(520px,calc(100vw - 24px))',
'min-width:280px',
'background:#fff',
'color:#111',
'border-radius:14px',
'box-shadow:0 16px 48px rgba(0,0,0,0.22)',
'border:1px solid rgba(0,0,0,0.08)',
'padding:14px',
'font:14px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
'overflow:hidden',
'word-break:break-word',
'box-sizing:border-box',
].join(';');
const title = document.createElement('div');
title.style.cssText = [
'font-size:12px',
'font-weight:700',
'text-transform:uppercase',
'letter-spacing:.04em',
'margin-bottom:10px',
'color:rgba(0,0,0,0.48)',
].join(';');
title.textContent = 'Translation';
const content = document.createElement('div');
content.style.whiteSpace = 'pre-wrap';
content.appendChild(createLoadingRow('Translating...'));
tooltip.appendChild(title);
tooltip.appendChild(content);
document.documentElement.appendChild(tooltip);
const rect = selectionData.rect;
const tooltipWidth = Math.min(520, window.innerWidth - 24);
const left = Math.min(window.innerWidth - tooltipWidth - 12, Math.max(12, rect.left));
const top = Math.min(window.innerHeight - 24, rect.bottom + 12);
tooltip.style.left = `${left}px`;
tooltip.style.top = `${top}px`;
tooltip.style.width = `${tooltipWidth}px`;
const onPointerDown = (event) => {
if (!tooltip.contains(event.target)) {
closeTooltip();
}
};
const onBlur = () => {
closeTooltip();
};
const onVisibilityChange = () => {
if (document.hidden) {
closeTooltip();
}
};
document.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('blur', onBlur, true);
document.addEventListener('visibilitychange', onVisibilityChange, true);
tooltipState = {
tooltip,
onPointerDown,
onBlur,
onVisibilityChange,
};
try {
const translated = await translatePlainText(selectionData.text);
if (!tooltipState) return;
content.textContent = translated || '';
} catch (err) {
console.error('[TM Translator]', err);
content.textContent = 'Translation failed.';
showToast(err?.message || 'Translation failed.', 5000, true);
}
}
function handlePointerMove(event) {
const paragraph = getParagraphFromPoint(event);
if (paragraph && isParagraphLike(paragraph)) {
hoveredParagraph = paragraph;
} else {
hoveredParagraph = null;
}
}
async function handleShiftKeyDown(event) {
if (event.key !== 'Shift') return;
if (event.repeat) return;
const selectionData = getSelectedText();
if (selectionData) {
event.preventDefault();
event.stopImmediatePropagation();
await openSelectionTooltip(selectionData);
return;
}
if (!hoveredParagraph || !isParagraphLike(hoveredParagraph)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
try {
await toggleTranslation(hoveredParagraph);
} catch {
// error toast already handled
}
}
document.addEventListener('pointermove', handlePointerMove, true);
document.addEventListener('keydown', handleShiftKeyDown, true);
console.log('[TM Translator] Loaded.');
})();