// ==UserScript==
// @name Tencent Translator Enhancer
// @name:ja Tencent Translator Enhancer
// @name:zh-CN Tencent Translator Enhancer
// @namespace knoa.jp
// @description It brings back-and-forth translation to Tencent Translator (腾讯翻译君).
// @description:ja 騰訊翻訳君(腾讯翻译君)に往復翻訳などの機能を追加します。
// @description:zh-CN 在腾讯翻译君中添加往返翻译等功能。
// @include https://fanyi.qq.com/
// @version 1
// @grant none
// ==/UserScript==
(function(){
const SCRIPTID = 'TencentTranslatorEnhancer';
const SCRIPTNAME = 'Tencent Translator Enhancer';
const DEBUG = false;/*
[update]
[bug]
たまに失敗するね observe検知のタイミング?
[todo]
[possible]
[memo]
*/
if(window === top && console.time) console.time(SCRIPTID);
const CORRECTIONS = [
(s) => s.replace(/htt?p(s?)[::]\/\/([^\s。]+)([。. ]*)/ig, 'http$1://$2'),
(s) => s.replace(/([0-9]+):([0-9]+)/g, '$1:$2'),
];
const SEPARATORS = ['\n:\n', '\n:\n', ':'];/*翻訳元, 翻訳先, 翻訳先span.textContent */
const RETRY = 10;
let site = {
targets: {
textpanelSource: () => $('.textpanel-source'),
sourceTextarea: () => $('[node-type="source-textarea"]'),
textpanelTargetTextblock: () => $('[node-type="textpanel-target-textblock"]'),
exchangeLanguageButton: () => $('[node-type="exchange_language_button"]'),
},
get: {
textSrcs: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-src'),
textDsts: (textpanelTargetTextblock) => textpanelTargetTextblock.querySelectorAll('.text-dst'),
textMatrix: (textpanelTargetTextblock) => {
return {
srcs: Array.from(site.get.textSrcs(textpanelTargetTextblock)).map(e => e.textContent),
dsts: Array.from(site.get.textDsts(textpanelTargetTextblock)).map(e => e.textContent),
};
},
},
};
let html, elements = {}, timers = {}, sizes = {};
let core = {
initialize: function(){
html = document.documentElement;
html.classList.add(SCRIPTID);
core.ready();
core.addStyle();
},
ready: function(){
core.getTargets(site.targets, RETRY).then(() => {
log("I'm ready.");
core.listenUserActions();
core.expandClickableArea();
});
},
listenUserActions: function(){
window.addEventListener('keypress', function(e){
switch(true){
case(e.key === 'Enter' && e.shiftKey === true):
core.translateSwitch();
return e.preventDefault();
case(e.key === 'Enter' && e.ctrlKey === true):
core.translateBackSwitch();
return e.preventDefault();
}
});
},
translateSwitch: function(){
/* 翻訳言語の向きを入れ替える */
let exchangeLanguageButton = elements.exchangeLanguageButton, sourceTextarea = elements.sourceTextarea;
exchangeLanguageButton.click();
sourceTextarea.focus();
},
translateBackSwitch: function(){
/* 往復翻訳の有効無効を切り替える */
let exchangeLanguageButton = elements.exchangeLanguageButton;
if(exchangeLanguageButton.dataset.translateBack === 'true'){
exchangeLanguageButton.dataset.translateBack = 'false';
}else{
exchangeLanguageButton.dataset.translateBack = 'true';
core.translateBack();
}
},
translateBack: function(){
/* 往復翻訳する */
let exchangeLanguageButton = elements.exchangeLanguageButton;
let sourceTextarea = elements.sourceTextarea, textpanelTargetTextblock = elements.textpanelTargetTextblock;
let source = sourceTextarea.value, target = textpanelTargetTextblock.innerText, result = '';
/* まだ往復翻訳してなければ */
let selectionStart = sourceTextarea.selectionStart, selectionEnd = sourceTextarea.selectionEnd;/*カーソル位置を記憶*/
if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
result = source + SEPARATORS[0] + target;
/* すでに往復翻訳済みなら */
}else{
source = source.slice(0, source.indexOf(SEPARATORS[0]));
target = target.slice(0, target.indexOf(SEPARATORS[1]));
result = source + SEPARATORS[0] + target;
}
/* 左辺の表示を完成させる */
CORRECTIONS.forEach(c => result = c(result));
sourceTextarea.value = result;
sourceTextarea.dispatchEvent(new Event('input'));
sourceTextarea.setSelectionRange(selectionStart, selectionEnd);
/* 右辺の表示を追従させる */
core.translateSwitch();
if(textpanelTargetTextblock.dataset.status !== undefined) return;
let compositing = false, innerText = textpanelTargetTextblock.innerText;
let observer = observe(textpanelTargetTextblock, function(records){
if(sourceTextarea.value.includes(SEPARATORS[0]) === false){
exchangeLanguageButton.dataset.translateBack = 'false';
delete(textpanelTargetTextblock.dataset.status);
observer.disconnect();
return;
}
if(textpanelTargetTextblock.innerText === innerText) return;
innerText = textpanelTargetTextblock.innerText;
switch(textpanelTargetTextblock.dataset.status){
/* 往復を終えた最終翻訳が取得できたタイミング */
case(undefined):
case('back'):
textpanelTargetTextblock.textMatrix = site.get.textMatrix(textpanelTargetTextblock);
core.translateSwitch();
textpanelTargetTextblock.dataset.status = 'go';
break;
/* 往路スタンバイに戻ったタイミング */
case('go'):
setTimeout(function(){
let textDsts = site.get.textDsts(textpanelTargetTextblock);
for(let i = Array.from(textDsts).findIndex(t => t.textContent === SEPARATORS[2]) + 1; textDsts[i]; i++){
textDsts[i].textContent = textpanelTargetTextblock.textMatrix.dsts[i];
let once = observe(textDsts[i], function(r){
log(r);
});
}
textpanelTargetTextblock.dataset.status = 'done';
}, 1000);/*再度更新される場合があるので*/
break;
/* テキスト変更を検知して自動翻訳されたタイミング */
case('done'):
if(compositing === true) return;
core.translateBack();
textpanelTargetTextblock.dataset.status = 'back';
break;
}
});
sourceTextarea.addEventListener('compositionstart', function(e){
compositing = true;
});
sourceTextarea.addEventListener('compositionend', function(e){
compositing = false;
});
},
expandClickableArea: function(){
let textpanelSource = elements.textpanelSource, sourceTextarea = elements.sourceTextarea;
textpanelSource.addEventListener('click', function(e){
sourceTextarea.focus();
}, true);
},
getTargets: function(targets, retry = 0){
const get = function(resolve, reject, retry){
for(let i = 0, keys = Object.keys(targets), key; key = keys[i]; i++){
let selected = targets[key]();
if(selected){
if(selected.length) selected.forEach((s) => s.dataset.selector = key);
else selected.dataset.selector = key;
elements[key] = selected;
}else{
if(--retry < 0) return reject(log(`Not found: ${key}, I give up.`));
log(`Not found: ${key}, retrying... (left ${retry})`);
return setTimeout(get, 1000, resolve, reject, retry);
}
}
resolve();
};
return new Promise(function(resolve, reject){
get(resolve, reject, retry);
});
},
addStyle: function(name = 'style'){
if(core.html[name] === undefined) return;
let style = createElement(core.html[name]());
document.head.appendChild(style);
if(elements[name] && elements[name].isConnected) document.head.removeChild(elements[name]);
elements[name] = style;
},
html: {
style: () => `
<style type="text/css">
/* 翻訳方向スイッチボタン */
[data-selector="exchangeLanguageButton"]{
border: 1px solid transparent;
border-radius: 100%;
width: 36px;
height: 36px;
}
[data-selector="exchangeLanguageButton"][data-translate-back="true"]{
border: 1px solid rgb(160, 76, 247);
}
/* クリッカブル領域を広げる */
[data-selector="textpanelSource"]{
cursor: text;
}
dummy [data-selector="sourceTextarea"]{
height: 100% !important;
}
/* 往復翻訳処理中 */
[data-selector="textpanelTargetTextblock"]{
transition: opacity 125ms;
}
[data-selector="textpanelTargetTextblock"][data-status="back"],
[data-selector="textpanelTargetTextblock"][data-status="go"]{
animation: ${SCRIPTID}-blink 500ms ease infinite;
}
@keyframes ${SCRIPTID}-blink{
0%{opacity: .250}
100%{opacity: .125}
}
</style>
`,
},
};
const setTimeout = window.setTimeout, clearTimeout = window.clearTimeout, setInterval = window.setInterval, clearInterval = window.clearInterval, requestAnimationFrame = window.requestAnimationFrame;
const alert = window.alert, confirm = window.confirm, getComputedStyle = window.getComputedStyle, fetch = window.fetch;
if(!('isConnected' in Node.prototype)) Object.defineProperty(Node.prototype, 'isConnected', {get: function(){return document.contains(this)}});
class Storage{
static key(key){
return (SCRIPTID) ? (SCRIPTID + '-' + key) : key;
}
static save(key, value, expire = null){
key = Storage.key(key);
localStorage[key] = JSON.stringify({
value: value,
saved: Date.now(),
expire: expire,
});
}
static read(key){
key = Storage.key(key);
if(localStorage[key] === undefined) return undefined;
let data = JSON.parse(localStorage[key]);
if(data.value === undefined) return data;
if(data.expire === undefined) return data;
if(data.expire === null) return data.value;
if(data.expire < Date.now()) return localStorage.removeItem(key);
return data.value;
}
static delete(key){
key = Storage.key(key);
delete localStorage.removeItem(key);
}
static saved(key){
key = Storage.key(key);
if(localStorage[key] === undefined) return undefined;
let data = JSON.parse(localStorage[key]);
if(data.saved) return data.saved;
else return undefined;
}
}
const $ = function(s, f){
let target = document.querySelector(s);
if(target === null) return null;
return f ? f(target) : target;
};
const $$ = function(s){return document.querySelectorAll(s)};
const animate = function(callback, ...params){requestAnimationFrame(() => requestAnimationFrame(() => callback(...params)))};
const wait = function(ms){return new Promise((resolve) => setTimeout(resolve, ms))};
const createElement = function(html = '<span></span>'){
let outer = document.createElement('div');
outer.innerHTML = html;
return outer.firstElementChild;
};
const observe = function(element, callback, options = {childList: true, attributes: false, characterData: false, subtree: false}){
let observer = new MutationObserver(callback.bind(element));
observer.observe(element, options);
return observer;
};
const normalize = function(string){
return string.replace(/[!-~]/g, function(s){
return String.fromCharCode(s.charCodeAt(0) - 0xFEE0);
}).replace(normalize.RE, function(s){
return normalize.KANA[s];
}).replace(/ /g, ' ').replace(/~/g, '〜');
};
normalize.KANA = {
ガ:'ガ', ギ:'ギ', グ:'グ', ゲ:'ゲ', ゴ: 'ゴ',
ザ:'ザ', ジ:'ジ', ズ:'ズ', ゼ:'ゼ', ゾ: 'ゾ',
ダ:'ダ', ヂ:'ヂ', ヅ:'ヅ', デ:'デ', ド: 'ド',
バ:'バ', ビ:'ビ', ブ:'ブ', ベ:'ベ', ボ: 'ボ',
パ:'パ', ピ:'ピ', プ:'プ', ペ:'ペ', ポ: 'ポ',
ヷ:'ヷ', ヺ:'ヺ', ヴ:'ヴ',
ア:'ア', イ:'イ', ウ:'ウ', エ:'エ', オ:'オ',
カ:'カ', キ:'キ', ク:'ク', ケ:'ケ', コ:'コ',
サ:'サ', シ:'シ', ス:'ス', セ:'セ', ソ:'ソ',
タ:'タ', チ:'チ', ツ:'ツ', テ:'テ', ト:'ト',
ナ:'ナ', ニ:'ニ', ヌ:'ヌ', ネ:'ネ', ノ:'ノ',
ハ:'ハ', ヒ:'ヒ', フ:'フ', ヘ:'ヘ', ホ:'ホ',
マ:'マ', ミ:'ミ', ム:'ム', メ:'メ', モ:'モ',
ヤ:'ヤ', ユ:'ユ', ヨ:'ヨ',
ラ:'ラ', リ:'リ', ル:'ル', レ:'レ', ロ:'ロ',
ワ:'ワ', ヲ:'ヲ', ン:'ン',
ァ:'ァ', ィ:'ィ', ゥ:'ゥ', ェ:'ェ', ォ:'ォ',
ッ:'ッ', ャ:'ャ', ュ:'ュ', ョ:'ョ',
"。":'。', "、":'、', "ー":'ー', "「":'「', "」":'」', "・":'・',
};
normalize.RE = new RegExp('(' + Object.keys(normalize.KANA).join('|') + ')', 'g');
const log = function(){
if(!DEBUG) return;
let l = log.last = log.now || new Date(), n = log.now = new Date();
let error = new Error(), line = log.format.getLine(error), callers = log.format.getCallers(error);
//console.log(error.stack);
console.log(
(SCRIPTID || '') + ':',
/* 00:00:00.000 */ n.toLocaleTimeString() + '.' + n.getTime().toString().slice(-3),
/* +0.000s */ '+' + ((n-l)/1000).toFixed(3) + 's',
/* :00 */ ':' + line,
/* caller.caller */ (callers[2] ? callers[2] + '() => ' : '') +
/* caller */ (callers[1] || '') + '()',
...arguments
);
};
log.formats = [{
name: 'Firefox Scratchpad',
detector: /MARKER@Scratchpad/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Console',
detector: /MARKER@debugger/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Greasemonkey 3',
detector: /\/gm_scripts\//,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1],
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Greasemonkey 4+',
detector: /MARKER@user-script:/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 500,
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Firefox Tampermonkey',
detector: /MARKER@moz-extension:/,
getLine: (e) => e.stack.split('\n')[1].match(/([0-9]+):[0-9]+$/)[1] - 6,
getCallers: (e) => e.stack.match(/^[^@]*(?=@)/gm),
}, {
name: 'Chrome Console',
detector: /at MARKER \(<anonymous>/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(<anonymous>)/gm),
}, {
name: 'Chrome Tampermonkey',
detector: /at MARKER \(chrome-extension:.*?\/userscript.html\?id=/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1] - 6,
getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
}, {
name: 'Chrome Extension',
detector: /at MARKER \(chrome-extension:/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)?$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(chrome-extension:)/gm),
}, {
name: 'Edge Console',
detector: /at MARKER \(eval/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1],
getCallers: (e) => e.stack.match(/[^ ]+(?= \(eval)/gm),
}, {
name: 'Edge Tampermonkey',
detector: /at MARKER \(Function/,
getLine: (e) => e.stack.split('\n')[2].match(/([0-9]+):[0-9]+\)$/)[1] - 4,
getCallers: (e) => e.stack.match(/[^ ]+(?= \(Function)/gm),
}, {
name: 'Safari',
detector: /^MARKER$/m,
getLine: (e) => 0,/*e.lineが用意されているが最終呼び出し位置のみ*/
getCallers: (e) => e.stack.split('\n'),
}, {
name: 'Default',
detector: /./,
getLine: (e) => 0,
getCallers: (e) => [],
}];
log.format = log.formats.find(function MARKER(f){
if(!f.detector.test(new Error().stack)) return false;
//console.log('////', f.name, 'wants', 0/*line*/, '\n' + new Error().stack);
return true;
});
const time = function(label){
if(!DEBUG) return;
const BAR = '|', TOTAL = 100;
switch(true){
case(label === undefined):/* time() to output total */
let total = 0;
Object.keys(time.records).forEach((label) => total += time.records[label].total);
Object.keys(time.records).forEach((label) => {
console.log(
BAR.repeat((time.records[label].total / total) * TOTAL),
label + ':',
(time.records[label].total).toFixed(3) + 'ms',
'(' + time.records[label].count + ')',
);
});
time.records = {};
break;
case(!time.records[label]):/* time('label') to create and start the record */
time.records[label] = {count: 0, from: performance.now(), total: 0};
break;
case(time.records[label].from === null):/* time('label') to re-start the lap */
time.records[label].from = performance.now();
break;
case(0 < time.records[label].from):/* time('label') to add lap time to the record */
time.records[label].total += performance.now() - time.records[label].from;
time.records[label].from = null;
time.records[label].count += 1;
break;
}
};
time.records = {};
core.initialize();
if(window === top && console.timeEnd) console.timeEnd(SCRIPTID);
})();