Greasy Fork is available in English.
Hover element + modifier key to toggle translation. Select text + modifier key for tooltip translation.
// ==UserScript==
// @name Shift Translator Hover Toggle + Selection Tooltip (Chrome Translator API)
// @namespace https://example.com/
// @version 1.2.0
// @description Hover element + modifier key to toggle translation. Select text + modifier key for tooltip translation.
// @author Link Chen
// @license MIT
// @match *://*/*
// @run-at document-idle
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(() => {
'use strict';
/*
* =========================================================
* Debug
* =========================================================
*/
const DEBUG = false;
/*
* =========================================================
* Config
* =========================================================
*/
const SOURCE_LANGUAGE = 'en';
const TARGET_LANGUAGE_CANDIDATES = ['zh-CN', 'zh', 'zh-Hans'];
const PARAGRAPH_SELECTOR = `
article p,
article li,
article blockquote,
article div,
article span,
main p,
main li,
main blockquote,
main div,
main span,
.markdown-body p,
.markdown-body li,
.markdown-body blockquote,
.markdown-body div,
.markdown-body span,
p,
blockquote,
div,
span
`;
const EXCLUDED_SELECTOR = [
'script',
'style',
'noscript',
'textarea',
'code',
'pre',
'kbd',
'samp',
'svg',
'canvas',
'nav',
'header',
'footer',
'button',
'input',
'select',
'option',
'img',
'[role="navigation"]',
'[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 MODIFIER_STORAGE_KEY = 'tm_modifier_keys';
const DEFAULT_MODIFIER_KEYS = ['shift'];
const DEBUG_OUTLINE_CLASS = 'tm-debug-outline';
/*
* =========================================================
* State
* =========================================================
*/
const translatorCache = new Map();
let modifierKeys = loadModifierKeys();
let activeToast = null;
let toastTimer = null;
let hoveredParagraph = null;
let tooltipState = null;
let modifierModalOverlay = null;
/*
* =========================================================
* Style
* =========================================================
*/
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,.16);
border-top-color: rgba(0,0,0,.72);
animation: tm-spin .8s linear infinite;
flex: 0 0 auto;
}
.tm-loading-row {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 0;
color: rgba(0,0,0,.62);
font: 13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
.${DEBUG_OUTLINE_CLASS} {
outline: 2px solid rgba(0,128,255,.65) !important;
outline-offset: 2px !important;
background: rgba(0,128,255,.04) !important;
}
`;
document.documentElement.appendChild(style);
/*
* =========================================================
* Utils
* =========================================================
*/
function uid() {
return `tm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function isElement(value) {
return value && value.nodeType === Node.ELEMENT_NODE;
}
function isEditableTarget(target) {
if (!target) return false;
const tag = target.tagName;
if (
tag === 'INPUT' ||
tag === 'TEXTAREA'
) {
return true;
}
if (target.isContentEditable) {
return true;
}
if (
target.closest?.(
'[contenteditable="true"]'
)
) {
return true;
}
return false;
}
function isParagraphLike(el) {
if (
!isElement(el) ||
!el.matches(PARAGRAPH_SELECTOR)
) {
return false;
}
if (el.closest(EXCLUDED_SELECTOR)) {
return false;
}
const text =
el.innerText?.trim() || '';
// ignore tiny texts
if (text.length < 12) {
return false;
}
// ignore huge container blocks
const childCount =
el.children?.length || 0;
if (childCount > 8) {
return false;
}
// ignore giant layout containers
const rect =
el.getBoundingClientRect();
if (
rect.width > window.innerWidth * 0.9 &&
rect.height > 300
) {
return false;
}
// ignore deep wrappers
const hasNestedParagraph =
el.querySelector?.(
'p, article, main, section'
);
if (
hasNestedParagraph &&
childCount > 2
) {
return false;
}
return true;
}
function normalizeModifierKeys(input) {
if (!input) {
return [...DEFAULT_MODIFIER_KEYS];
}
const allowed = ['shift', 'control', 'command'];
const parts = input
.toLowerCase()
.split('+')
.map(v => v.trim())
.filter(Boolean);
const unique = [...new Set(parts)];
const valid = unique.filter(v => allowed.includes(v));
return valid.length
? valid
: [...DEFAULT_MODIFIER_KEYS];
}
function loadModifierKeys() {
try {
const raw = GM_getValue(
MODIFIER_STORAGE_KEY,
''
);
if (!raw) {
return [...DEFAULT_MODIFIER_KEYS];
}
return normalizeModifierKeys(raw);
} catch {
return [...DEFAULT_MODIFIER_KEYS];
}
}
function saveModifierKeys(keys) {
modifierKeys = normalizeModifierKeys(
keys.join('+')
);
GM_setValue(
MODIFIER_STORAGE_KEY,
modifierKeys.join('+')
);
}
function isModifierMatch(event) {
const pressed = [];
if (event.shiftKey) pressed.push('shift');
if (event.ctrlKey) pressed.push('control');
if (event.metaKey) pressed.push('command');
if (pressed.length !== modifierKeys.length) {
return false;
}
return modifierKeys.every(k => pressed.includes(k));
}
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;
color:#fff;
font:13px/1.4 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
box-shadow:0 8px 30px rgba(0,0,0,.28);
white-space:pre-wrap;
pointer-events:none;
`;
document.documentElement.appendChild(
activeToast
);
}
activeToast.textContent = message;
activeToast.style.background = isError
? 'rgba(176,0,32,.94)'
: 'rgba(20,20,20,.92)';
activeToast.style.display = 'block';
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
if (activeToast) {
activeToast.style.display = 'none';
}
}, isError ? 5000 : ms);
}
function showErrorToast(err) {
const message =
err?.message ||
String(err) ||
'Translation failed.';
console.error('[TM Translator]', err);
showToast(message, 5000, true);
}
function createLoadingRow(text = 'Translating...') {
const row = document.createElement('div');
row.className = 'tm-loading-row';
const spinner = document.createElement('span');
spinner.className = 'tm-spinner';
const label = document.createElement('span');
label.textContent = text;
row.appendChild(spinner);
row.appendChild(label);
return row;
}
/*
* =========================================================
* Translator
* =========================================================
*/
async function getTranslator() {
if (!('Translator' in self)) {
throw new Error(
'Translator API is not available.'
);
}
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 = Translator.create({
sourceLanguage: SOURCE_LANGUAGE,
targetLanguage,
});
translatorCache.set(cacheKey, promise);
try {
return await promise;
} catch {
translatorCache.delete(cacheKey);
}
}
throw new Error(
'No supported translator available.'
);
}
async function translatePlainText(text) {
const translator = await getTranslator();
return translator.translate(text);
}
/*
* =========================================================
* Hover Translate
* =========================================================
*/
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 &&
isParagraphLike(paragraph)
) {
return paragraph;
}
}
}
return null;
}
function updateDebugOutline(nextParagraph) {
if (!DEBUG) return;
if (
hoveredParagraph &&
hoveredParagraph !== nextParagraph
) {
hoveredParagraph.classList.remove(
DEBUG_OUTLINE_CLASS
);
}
if (nextParagraph) {
nextParagraph.classList.add(
DEBUG_OUTLINE_CLASS
);
}
}
function setHoveredParagraph(nextParagraph) {
updateDebugOutline(nextParagraph);
hoveredParagraph =
nextParagraph || null;
}
function stripDuplicateIds(root) {
if (!root) 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 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) return null;
const sibling =
original.nextElementSibling;
if (
sibling &&
sibling.getAttribute(
TRANSLATED_COPY_ATTR
) === '1' &&
sibling.getAttribute(
TRANSLATED_FROM_ATTR
) === sourceId
) {
return sibling;
}
return null;
}
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 loading =
createLoadingRow(
'Translating...'
);
original.insertAdjacentElement(
'afterend',
loading
);
const clone =
original.cloneNode(true);
stripDuplicateIds(clone);
clone.style.opacity = '0.5';
clone.style.marginTop = '4px';
clone.style.marginBottom = '0';
clone.setAttribute(
TRANSLATED_COPY_ATTR,
'1'
);
clone.setAttribute(
TRANSLATED_FROM_ATTR,
sourceId
);
try {
await translateCloneTree(clone);
if (loading.isConnected) {
loading.replaceWith(clone);
}
showToast(
'Translation shown.'
);
} catch (err) {
loading.remove();
showErrorToast(err);
throw err;
}
}
/*
* =========================================================
* Tooltip Translate
* =========================================================
*/
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
) {
try {
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;
max-height:calc(100vh - 24px);
overflow:auto;
background:#fff;
color:#111;
border-radius:14px;
box-shadow:0 16px 48px rgba(0,0,0,.22);
border:1px solid rgba(0,0,0,.08);
padding:14px;
font:14px/1.55 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
word-break:break-word;
box-sizing:border-box;
visibility:hidden;
`;
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,.48);
`;
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 margin = 12;
const viewportW =
window.visualViewport?.width ||
window.innerWidth;
const viewportH =
window.visualViewport?.height ||
window.innerHeight;
const viewportLeft =
window.visualViewport?.offsetLeft || 0;
const viewportTop =
window.visualViewport?.offsetTop || 0;
function positionTooltip() {
const rect =
selectionData.rect;
const tipRect =
tooltip.getBoundingClientRect();
const tipW = Math.min(
tipRect.width,
viewportW - margin * 2
);
const tipH = Math.min(
tipRect.height,
viewportH - margin * 2
);
const spaceBelow =
viewportH -
(rect.bottom - viewportTop) -
margin;
const spaceAbove =
(rect.top - viewportTop) -
margin;
let top;
if (
spaceBelow >= tipH ||
spaceBelow >= spaceAbove
) {
top = Math.min(
rect.bottom + 12,
viewportTop +
viewportH -
tipH -
margin
);
} else {
top = Math.max(
viewportTop + margin,
rect.top - tipH - 12
);
}
let left =
rect.left - viewportLeft;
left = Math.min(
left,
viewportW -
tipW -
margin
);
left = Math.max(
margin,
left
);
tooltip.style.left =
`${left + viewportLeft}px`;
tooltip.style.top =
`${top}px`;
tooltip.style.visibility =
'visible';
}
requestAnimationFrame(
positionTooltip
);
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,
};
const translated =
await translatePlainText(
selectionData.text
);
if (!tooltipState) return;
content.textContent =
translated || '';
requestAnimationFrame(() => {
if (!tooltipState) return;
positionTooltip();
});
} catch (err) {
showErrorToast(err);
}
}
/*
* =========================================================
* Settings Modal
* =========================================================
*/
function openModifierSettingsModal() {
try {
if (
modifierModalOverlay?.isConnected
) {
return;
}
const overlay =
document.createElement('div');
modifierModalOverlay =
overlay;
overlay.style.cssText = `
position:fixed;
inset:0;
z-index:2147483647;
background:rgba(0,0,0,.18);
display:flex;
align-items:center;
justify-content:center;
`;
const modal =
document.createElement('div');
modal.style.cssText = `
width:420px;
background:#fff;
border-radius:16px;
padding:20px;
box-shadow:0 20px 60px rgba(0,0,0,.25);
font:14px/1.5 system-ui;
box-sizing:border-box;
`;
const title =
document.createElement('div');
title.style.cssText = `
font-size:18px;
font-weight:700;
margin-bottom:12px;
`;
title.textContent =
'Modifier Key Settings';
const desc =
document.createElement('div');
desc.style.cssText = `
margin-bottom:12px;
color:#666;
`;
desc.textContent =
'Allowed: shift / control / command';
const input =
document.createElement('input');
input.id =
'tm-modifier-input';
input.value =
modifierKeys.join('+');
input.style.cssText = `
width:100%;
padding:10px 12px;
border-radius:10px;
border:1px solid rgba(0,0,0,.12);
box-sizing:border-box;
font-size:14px;
`;
const actions =
document.createElement('div');
actions.style.cssText = `
display:flex;
justify-content:flex-end;
margin-top:16px;
gap:8px;
`;
const cancelBtn =
document.createElement('button');
cancelBtn.textContent =
'Cancel';
const saveBtn =
document.createElement('button');
saveBtn.textContent =
'Save';
actions.appendChild(cancelBtn);
actions.appendChild(saveBtn);
modal.appendChild(title);
modal.appendChild(desc);
modal.appendChild(input);
modal.appendChild(actions);
overlay.appendChild(modal);
document.documentElement.appendChild(
overlay
);
const close = () => {
modifierModalOverlay =
null;
overlay.remove();
};
overlay.addEventListener(
'click',
e => {
if (e.target === overlay) {
close();
}
}
);
cancelBtn.addEventListener(
'click',
close
);
saveBtn.addEventListener(
'click',
() => {
try {
const normalized =
normalizeModifierKeys(
input.value
);
saveModifierKeys(
normalized
);
showToast(
`Modifier updated: ${normalized.join('+')}`
);
close();
} catch (err) {
showErrorToast(err);
}
}
);
} catch (err) {
showErrorToast(err);
}
}
/*
* =========================================================
* Events
* =========================================================
*/
function handlePointerMove(event) {
try {
const paragraph =
getParagraphFromPoint(event);
setHoveredParagraph(
paragraph &&
isParagraphLike(paragraph)
? paragraph
: null
);
} catch (err) {
showErrorToast(err);
}
}
async function handleKeyDown(event) {
try {
if (
isEditableTarget(
event.target
)
) {
return;
}
const isSettingsShortcut =
event.code === 'Comma' &&
(
(
event.ctrlKey &&
event.shiftKey
) ||
(
event.metaKey &&
event.shiftKey
)
);
if (isSettingsShortcut) {
event.preventDefault();
event.stopPropagation();
// toggle modal
if (
modifierModalOverlay?.isConnected
) {
modifierModalOverlay.remove();
modifierModalOverlay = null;
} else {
openModifierSettingsModal();
}
return;
}
if (!isModifierMatch(event)) {
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();
await toggleTranslation(
hoveredParagraph
);
} catch (err) {
showErrorToast(err);
}
}
document.addEventListener(
'pointermove',
handlePointerMove,
true
);
document.addEventListener(
'keydown',
handleKeyDown,
true
);
console.log(
`[TM Translator] Loaded. DEBUG=${DEBUG}`
);
})();