[CTRL] + [Left Mouse Button] to translate the element you clicked on (configurable)
// ==UserScript==
// @name Translate It to Me!
// @namespace -
// @version 1.1.0
// @description [CTRL] + [Left Mouse Button] to translate the element you clicked on (configurable)
// @author NotYou
// @match *://*/*
// @grant GM.xmlHttpRequest
// @grant GM.registerMenuCommand
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.addStyle
// @run-at document-end
// @license MIT
// @icon https://www.svgrepo.com/download/470003/translate.svg
// @connect ftapi.pythonanywhere.com
// @connect abhi-api.vercel.app
// ==/UserScript==
!function() {
'use strict';
class UserData {
static key = 'user_data'
static async getData() {
return await GM.getValue(this.key, {
language: 'en',
shiftKey: false,
ctrlKey: true,
altKey: false,
ignore: {
site: {},
page: {}
}
})
}
static async getItem(key) {
const data = await this.getData()
return data[key]
}
static async setItem(key, value) {
const data = await this.getData()
data[key] = value
await GM.setValue(this.key, data)
}
static async resetData() {
await GM.deleteValue(this.key)
}
}
class UIComponent {
constructor(tagName, styles) {
this.element = document.createElement(tagName)
Object.assign(this.element.style, {
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
margin: '0',
padding: '0',
width: 'initial',
height: 'initial',
backgroundColor: 'initial',
boxShadow: 'initial',
opacity: 'initial'
}, styles)
}
setParent(parent) {
if (parent instanceof UIComponent) {
parent.element.appendChild(this.element)
} else if (parent instanceof HTMLElement) {
parent.appendChild(this.element)
} else {
throw new Error('"parent" is not a UIComponent, nor a HTMLElement')
}
return this
}
get parent () { return this.setParent } // alias
}
class ObservableUIComponent extends UIComponent {
constructor(tagName, styles) {
super(tagName, styles)
this._ghostEventTarget = new EventTarget()
}
addEventListener(...args) {
this._ghostEventTarget.addEventListener(...args)
return this
}
get on() { return this.addEventListener } // alias
removeEventListener(...args) {
this._ghostEventTarget.removeEventListener(...args)
return this
}
dispatchEvent(...args) {
this._ghostEventTarget.dispatchEvent(...args)
return this
}
}
class Select extends ObservableUIComponent {
constructor(options) {
super('select', {
padding: '8px',
borderRadius: '12px',
backgroundColor: 'rgb(240, 240, 240)',
color: 'rgb(10, 10, 10)',
width: '150px',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap',
})
this.element.addEventListener('change', ev => this.dispatchEvent(new CustomEvent('select')))
for (const value in options) {
const text = options[value]
const option = new Option(text, value)
this.element.appendChild(option)
}
}
select(value) {
for (const option of this.element.options) {
if (option.value === value) {
this.element.selectedIndex = option.index
return this
}
}
return this
}
}
class Checkbox extends ObservableUIComponent {
constructor(checked = false) {
super('div', {
width: 'var(--width)',
borderRadius: '100px',
margin: '4px 0',
padding: '4px',
transition: '0.3s background-color',
cursor: 'pointer',
boxSizing: 'content-box'
})
this.element.style.setProperty('--width', '60px')
this.circle = document.createElement('div')
this.circle.style.setProperty('--size', '25px')
this.circle.style.width = 'var(--size)'
this.circle.style.height = 'var(--size)'
this.circle.style.borderRadius = '50%'
this.circle.style.backgroundColor = 'rgb(240, 240, 240)'
this.circle.style.transition = '0.3s transform'
this.circle.style.margin = '0'
this.circle.style.padding = '0'
this.element.appendChild(this.circle)
this.checked = checked
if (this.checked) {
this.check()
} else {
this.uncheck()
}
this.element.addEventListener('click', ev => {
this.checked ? this.uncheck() : this.check()
this.dispatchEvent(new CustomEvent('toggle', {
detail: {
checked: this.checked
}
}))
})
}
check() {
this.element.style.backgroundColor = 'rgb(50, 200, 255)'
this.circle.style.transform = 'translate(calc(var(--width) - var(--size)))'
this.checked = true
return this
}
uncheck() {
this.element.style.backgroundColor = 'rgb(50, 50, 50)'
this.circle.style.transform = 'translate(0px)'
this.checked = false
return this
}
}
class Headline extends UIComponent {
constructor(text) {
super('h1', {
fontSize: '32px',
fontWeight: '800',
marginBottom: '8px'
})
this.element.textContent = text
}
}
class Title extends UIComponent {
constructor(text) {
super('h2', {
fontSize: '24px',
fontWeight: '600',
marginBottom: '4px'
})
this.element.textContent = text
}
}
class Paragraph extends UIComponent {
constructor(text) {
super('p', {
fontSize: '16px',
})
this.element.textContent = text
}
}
class Button extends ObservableUIComponent {
constructor(text, isOutlined = false) {
super('button', Object.assign(isOutlined ? {
color: 'rgb(50, 200, 255)',
border: '1px solid currentColor',
backgroundColor: 'transparent'
} : {
backgroundColor: 'rgb(50, 200, 255)',
border: '0'
}, {
fontSize: '16px',
margin: '8px 0',
padding: '8px',
borderRadius: '12px',
display: 'block',
width: '100%',
cursor: 'pointer',
fontWeight: 'bold'
}))
this.element.addEventListener('click', ev => this.dispatchEvent(new CustomEvent('click')))
this.element.textContent = text
}
}
class Grid extends UIComponent {
constructor(columns) {
super('div', {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, auto)`,
gap: '8px'
})
}
}
class Group extends UIComponent {
constructor(columns) {
super('div', {})
}
}
class Menu extends UIComponent {
constructor() {
super('div', {
display: 'none',
position: 'fixed',
left: '0',
top: '0',
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 0.333)',
zIndex: '2147483646',
fontFamily: '"DM Sans", Arial',
boxSizing: 'border-box'
})
this.element.addEventListener('click', ev => {
if (ev.target === ev.currentTarget) {
this.close()
}
})
this.menu = new class extends UIComponent {
constructor() {
super('div', {
padding: '32px',
backgroundColor: 'rgb(5, 23, 30)',
color: 'rgb(240, 240, 240)',
borderRadius: '16px',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
})
}
}
this.parent(document.body)
}
async setupSettings() {
new Headline('Settings').parent(this.menu)
const grid = new Grid(2).parent(this.menu)
const translationGroup = new Group().parent(grid)
new Title('Translation').parent(translationGroup)
new Paragraph('Target language').parent(translationGroup)
const language = await UserData.getItem('language')
const languageSelect = new Select(Translate.languageCodes).on('select', ev => {
const option = ev.target.options[ev.target.selectedIndex]
UserData.setItem('language', option.value)
})
.select(language)
.parent(translationGroup)
new Paragraph('Ignore this page').parent(translationGroup)
const ignore = await UserData.getItem('ignore')
const ignorePageCheckbox = new Checkbox(ignore.page[location.host + location.pathname] === 1).on('select', ev => {
if (ev.detail.checked) {
ignore.page[location.host + location.pathname] = 1
} else {
delete ignore.page[location.host + location.pathname]
}
UserData.setItem('ignore', ignore)
}).parent(translationGroup)
new Paragraph('Ignore this site').parent(translationGroup)
const ignoreSiteCheckbox = new Checkbox(ignore.site[location.host] === 1).on('select', ev => {
if (ev.detail.checked) {
ignore.site[location.host] = 1
} else {
delete ignore.site[location.host]
}
UserData.setItem('ignore', ignore)
}).parent(translationGroup)
const clickConfigGroup = new Group().parent(grid)
new Title('Click Config').parent(clickConfigGroup)
const data = await UserData.getData()
const resetIfNoneChecked = async () => {
if (!shiftCheckbox.checked && !ctrlCheckbox.checked && !altCheckbox.checked) {
await UserData.setItem('ctrlKey', true)
ctrlCheckbox.check()
}
}
new Paragraph('Shift must be pressed').parent(clickConfigGroup)
const shiftCheckbox = new Checkbox(data.shiftKey).on('toggle', ev => {
UserData.setItem('shiftKey', ev.detail.checked)
resetIfNoneChecked()
}).parent(clickConfigGroup)
new Paragraph('Ctrl must be pressed').parent(clickConfigGroup)
const ctrlCheckbox = new Checkbox(data.ctrlKey).on('toggle', ev => {
UserData.setItem('ctrlKey', ev.detail.checked)
resetIfNoneChecked()
}).parent(clickConfigGroup)
new Paragraph('Alt must be pressed').parent(clickConfigGroup)
const altCheckbox = new Checkbox(data.altKey).on('toggle', ev => {
UserData.setItem('altKey', ev.detail.checked)
resetIfNoneChecked()
}).parent(clickConfigGroup)
new Button('Reset', true).on('click', async () => {
await UserData.resetData()
const { language, shiftKey, ctrlKey, altKey } = await UserData.getData()
languageSelect.select(language)
ignorePageCheckbox.uncheck()
ignoreSiteCheckbox.uncheck()
shiftCheckbox[shiftKey ? 'check' : 'uncheck']()
ctrlCheckbox[ctrlKey ? 'check' : 'uncheck']()
altCheckbox[altKey ? 'check' : 'uncheck']()
}).parent(this.menu)
new Button('OK').on('click', () => {
this.close()
}).parent(this.menu)
}
close() {
if (this.element.style.display !== 'none') {
this.element.style.display = 'none'
this.element.removeChild(this.menu.element)
this.menu.element.innerHTML = ''
}
}
open() {
if (this.element.style.display !== 'block') {
this.element.style.display = 'block'
this.setupSettings()
this.menu.parent(this)
}
}
}
class API {
static baseUrl = 'https://example.com'
static stringifySearchParams(params) {
return [...params.entries()]
.filter(item => item[0] && item[1])
.map(([key, value]) => `${key}=${value}`)
.join('&')
}
static getUrl(path, searchParams = '') {
if (searchParams) {
return this.baseUrl + path + '?' + searchParams
}
return this.baseUrl + path
}
static fetch(params) {
return new Promise((resolve, reject) => {
return GM.xmlHttpRequest({
...params,
onload: data => resolve(data.response),
onerror: reject
})
})
}
}
class FreeTranslateAPI extends API {
static baseUrl = 'https://ftapi.pythonanywhere.com'
static translate(text, desinationLang) {
const url = this.getUrl(
'/translate',
this.stringifySearchParams(
new URLSearchParams({
dl: desinationLang,
text
})
)
)
return this.fetch({
url,
responseType: 'json'
})
}
}
class AbhiAPI extends API {
static baseUrl = 'https://abhi-api.vercel.app'
static translate(text, lang) {
const url = this.getUrl(
'/api/tool/translate',
this.stringifySearchParams(
new URLSearchParams({
text,
lang
})
)
)
return this.fetch({
url,
responseType: 'json'
})
}
}
class Translate {
static languageCodes = {
en: 'English',
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',
eo: 'Esperanto',
et: 'Estonian',
tl: 'Filipino',
fi: 'Finnish',
fr: 'French',
fy: 'Frisian',
gl: 'Galician',
ka: 'Georgian',
de: 'German',
el: 'Greek',
gu: 'Gujarati',
ht: 'Haitian\'creole',
ha: 'Hausa',
haw: 'Hawaiian',
iw: 'Hebrew',
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',
ko: 'Korean',
ku: 'Kurdish (kurmanji)',
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 (burmese)',
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',
te: 'Telugu',
th: 'Thai',
tr: 'Turkish',
uk: 'Ukrainian',
ur: 'Urdu',
ug: 'Uyghur',
uz: 'Uzbek',
vi: 'Vietnamese',
cy: 'Welsh',
xh: 'Xhosa',
yi: 'Yiddish',
yo: 'Yoruba',
zu: 'Zulu'
}
static async translate(text, targetLang) {
const failureResponse = {
sourceText: '',
targetText: '',
success: false
};
try {
const response = await FreeTranslateAPI.translate(text, targetLang)
if (response && response['destination-text']) {
return {
sourceText: response['source-text'],
targetText: response['destination-text'],
success: true
}
}
} catch (_) {}
try {
const response = await AbhiAPI.translate(text, targetLang)
if (response && response.result && response.result.translatedText) {
return {
sourceText: text,
targetText: response.result.translatedText,
success: true
}
}
} catch (_) {}
return failureResponse;
}
}
class Main {
static async init() {
const menu = new Menu()
GM.registerMenuCommand('🔧 Open Settings', () => {
menu.open()
})
GM.addStyle(`
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap');
.translate-flashing-element {
background-image: linear-gradient(to right, transparent 0%, rgba(50, 200, 255, 0.5) 25%, transparent 50%);
background-size: 200%;
background-position: 100%;
animation: 1.5s translate-flashing-anim 0.5s linear infinite;
}
.translate-error-element {
animation: 1.5s translate-error-anim ease-out;
}
.translate-success-element {
animation: 1.5s translate-success-anim ease-out;
}
@keyframes translate-flashing-anim {
0% {
background-position: 100%;
}
60%, 100% {
background-position: -100%;
}
}
@keyframes translate-error-anim {
0% {
background-color: rgb(255, 50, 50);
}
}
@keyframes translate-success-anim {
0% {
background-color: rgb(50, 200, 255);
}
}
`)
window.addEventListener('click', async ev => {
const userData = await UserData.getData()
const { shiftKey, ctrlKey, altKey, ignore } = userData
if (
ev.button === 0 &&
ev.shiftKey === shiftKey &&
ev.ctrlKey === ctrlKey &&
ev.altKey === altKey &&
ev.target !== document.body &&
ev.target !== document.documentElement &&
ignore.page[location.host + location.pathname] !== 1 &&
ignore.site[location.host] !== 1
) {
ev.preventDefault()
ev.stopImmediatePropagation()
ev.target.classList.add('translate-flashing-element')
const language = await UserData.getItem('language')
const toTranslate = {
translations: [],
nodes: []
}
const handleOnlyTextNodes = elem => {
for (const childNode of elem.childNodes) {
if (
childNode.nodeType === Node.TEXT_NODE &&
childNode.textContent.trim() !== ''
) {
toTranslate.translations.push(Translate.translate(childNode.textContent, language))
toTranslate.nodes.push(childNode)
}
}
}
// Handly text nodes from 1st and 2nd depth level of childNodes
handleOnlyTextNodes(ev.target)
for (const childNode of ev.target.childNodes) {
if (
childNode.nodeType === Node.ELEMENT_NODE &&
!childNode.classList.contains('notranslate') // skip elements with class "notranslate". Elements with that class are skipped by Google Translate
) {
handleOnlyTextNodes(childNode)
}
}
// Stop if no text nodes where found
if (toTranslate.length === 0) {
ev.target.classList.remove('translate-flashing-element')
return
}
// Wait for all translation promises, apply them to text and display success (fading away)
Promise.all(toTranslate.translations).then(translations => {
for (let i = 0; i < translations.length; i++) {
const translation = translations[i]
const childNode = toTranslate.nodes[i]
if (translation.success) {
childNode.textContent = translation.targetText
ev.target.classList.remove('translate-flashing-element')
ev.target.classList.add('translate-success-element')
ev.target.addEventListener('animationend', () => ev.target.classList.remove('translate-success-element'))
} else {
ev.target.classList.remove('translate-flashing-element')
ev.target.classList.add('translate-error-element')
ev.target.addEventListener('animationend', () => ev.target.classList.remove('translate-error-element'))
}
}
})
}
})
}
}
Main.init()
}();