// ==UserScript==
// @name Translate It to Me!
// @namespace -
// @version 1.0.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'
}, 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
}
}
class Select extends UIComponent {
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._onChange = () => {}
this.element.addEventListener('change', ev => this._onChange(ev))
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
}
onChange(fn) {
this._onChange = fn
return this
}
}
class Checkbox extends UIComponent {
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._onChange = () => {}
this.element.addEventListener('click', () => {
this.checked ? this.uncheck() : this.check()
this._onChange(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
}
onChange(fn) {
this._onChange = fn
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 UIComponent {
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._onClick = () => {}
this.element.addEventListener('click', ev => this._onClick(ev))
this.element.textContent = text
}
onClick(fn) {
this._onClick = fn
return this
}
}
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.inner = 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.setParent(document.body)
}
async setupSettings() {
new Headline('Settings').setParent(this.inner)
const grid = new Grid(2).setParent(this.inner)
const translationGroup = new Group().setParent(grid)
new Title('Translation').setParent(translationGroup)
new Paragraph('Target language').setParent(translationGroup)
const language = await UserData.getItem('language')
const languageSelect = new Select(Translate.languageCodes).onChange(ev => {
const option = ev.target.options[ev.target.selectedIndex]
UserData.setItem('language', option.value)
})
.select(language)
.setParent(translationGroup)
new Paragraph('Ignore this page').setParent(translationGroup)
const ignore = await UserData.getItem('ignore')
const ignorePageCheckbox = new Checkbox(ignore.page[location.host + location.pathname] === 1).onChange(state => {
if (state) {
ignore.page[location.host + location.pathname] = 1
} else {
delete ignore.page[location.host + location.pathname]
}
UserData.setItem('ignore', ignore)
}).setParent(translationGroup)
new Paragraph('Ignore this site').setParent(translationGroup)
const ignoreSiteCheckbox = new Checkbox(ignore.site[location.host] === 1).onChange(state => {
if (state) {
ignore.site[location.host] = 1
} else {
delete ignore.site[location.host]
}
UserData.setItem('ignore', ignore)
}).setParent(translationGroup)
const clickConfigGroup = new Group().setParent(grid)
new Title('Click Config').setParent(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').setParent(clickConfigGroup)
const shiftCheckbox = new Checkbox(data.shiftKey).onChange(state => {
UserData.setItem('shiftKey', state)
resetIfNoneChecked()
}).setParent(clickConfigGroup)
new Paragraph('Ctrl must be pressed').setParent(clickConfigGroup)
const ctrlCheckbox = new Checkbox(data.ctrlKey).onChange(state => {
UserData.setItem('ctrlKey', state)
resetIfNoneChecked()
}).setParent(clickConfigGroup)
new Paragraph('Alt must be pressed').setParent(clickConfigGroup)
const altCheckbox = new Checkbox(data.altKey).onChange(state => {
UserData.setItem('altKey', state)
resetIfNoneChecked()
}).setParent(clickConfigGroup)
new Button('Reset', true).onClick(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']()
}).setParent(this.inner)
new Button('OK').onClick(() => {
this.close()
}).setParent(this.inner)
}
close() {
this.element.style.display = 'none'
this.element.removeChild(this.inner.element)
this.inner.element.innerHTML = ''
}
open() {
this.element.style.display = 'block'
this.setupSettings()
this.inner.setParent(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',
'sq': 'Albanian',
'ar': 'Arabic',
'az': 'Azerbaijani',
'eu': 'Basque',
'bn': 'Bengali',
'bg': 'Bulgarian',
'ca': 'Catalan',
'cs': 'Czech',
'da': 'Danish',
'nl': 'Dutch',
'eo': 'Esperanto',
'et': 'Estonian',
'fi': 'Finnish',
'fr': 'French',
'gl': 'Galician',
'de': 'German',
'el': 'Greek',
'hi': 'Hindi',
'hu': 'Hungarian',
'id': 'Indonesian',
'ga': 'Irish',
'it': 'Italian',
'ja': 'Japanese',
'ko': 'Korean',
'ky': 'Kyrgyz',
'lv': 'Latvian',
'lt': 'Lithuanian',
'ms': 'Malay',
'fa': 'Persian',
'pl': 'Polish',
'pt-BR': 'Portuguese (Brazil)',
'ro': 'Romanian',
'ru': 'Russian',
'sr': 'Serbian',
'sk': 'Slovak',
'sl': 'Slovenian',
'es': 'Spanish',
'sv': 'Swedish',
'tl': 'Filipino',
'th': 'Thai',
'tr': 'Turkish',
'uk': 'Ukrainian',
'ur': 'Urdu',
'vi': 'Vietnamese',
'zh-CN': 'Chinese (Simplified)',
'zh-TW': 'Chinese (Traditional)',
}
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 = []
const handleOnlyTextNodes = elem => {
for (const childNode of elem.childNodes) {
if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent.trim() !== '') {
toTranslate.push([Translate.translate(childNode.textContent, language), 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) {
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.map(item => item[0])).then(translations => {
for (let i = 0; i < translations.length; i++) {
const translation = translations[i]
const childNode = toTranslate[i][1]
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()
}();