248 lines
5.7 KiB
JavaScript
248 lines
5.7 KiB
JavaScript
import {
|
|
DEFAULT_INPUT_RULE,
|
|
DEFAULT_INPUT_SHORTCUT,
|
|
OPT_LANGS_LIST,
|
|
DEFAULT_API_SETTING,
|
|
} from "../config";
|
|
import { genEventName, removeEndchar, matchInputStr } from "./utils";
|
|
import { stepShortcutRegister } from "./shortcut";
|
|
import { apiTranslate } from "../apis";
|
|
import { loadingSvg } from "./svg";
|
|
import { kissLog } from "./log";
|
|
|
|
function isInputNode(node) {
|
|
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
|
|
}
|
|
|
|
function isEditAbleNode(node) {
|
|
return node.hasAttribute("contenteditable");
|
|
}
|
|
|
|
function replaceContentEditableText(node, newText) {
|
|
node.focus();
|
|
const selection = window.getSelection();
|
|
if (!selection) return;
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(node);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
range.deleteContents();
|
|
const textNode = document.createTextNode(newText);
|
|
range.insertNode(textNode);
|
|
|
|
selection.collapseToEnd();
|
|
}
|
|
|
|
function getNodeText(node) {
|
|
if (isInputNode(node)) {
|
|
return node.value;
|
|
}
|
|
return node.innerText || node.textContent || "";
|
|
}
|
|
|
|
function addLoading(node, loadingId) {
|
|
const rect = node.getBoundingClientRect();
|
|
const div = document.createElement("div");
|
|
div.id = loadingId;
|
|
div.innerHTML = loadingSvg;
|
|
div.style.cssText = `
|
|
position: fixed;
|
|
left: ${rect.left}px;
|
|
top: ${rect.top}px;
|
|
width: ${rect.width}px;
|
|
height: ${rect.height}px;
|
|
line-height: ${rect.height}px;
|
|
text-align: center;
|
|
z-index: 2147483647;
|
|
pointer-events: none; /* 允许点击穿透 */
|
|
`;
|
|
document.body.appendChild(div);
|
|
}
|
|
|
|
function removeLoading(loadingId) {
|
|
const div = document.getElementById(loadingId);
|
|
if (div) div.remove();
|
|
}
|
|
|
|
/**
|
|
* 输入框翻译
|
|
*/
|
|
export class InputTranslator {
|
|
#config;
|
|
#unregisterShortcut = null;
|
|
#isEnabled = false;
|
|
#triggerShortcut; // 用于缓存快捷键
|
|
|
|
constructor({ inputRule = DEFAULT_INPUT_RULE, transApis = [] } = {}) {
|
|
this.#config = { inputRule, transApis };
|
|
|
|
const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;
|
|
if (initialTriggerShortcut && initialTriggerShortcut.length > 0) {
|
|
this.#triggerShortcut = initialTriggerShortcut;
|
|
} else {
|
|
this.#triggerShortcut = DEFAULT_INPUT_SHORTCUT;
|
|
}
|
|
|
|
if (this.#config.inputRule.transOpen) {
|
|
this.enable();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 启用输入翻译功能
|
|
*/
|
|
enable() {
|
|
if (this.#isEnabled || !this.#config.inputRule.transOpen) {
|
|
return;
|
|
}
|
|
|
|
const { triggerCount, triggerTime } = this.#config.inputRule;
|
|
this.#unregisterShortcut = stepShortcutRegister(
|
|
this.#triggerShortcut,
|
|
this.#handleTranslate.bind(this),
|
|
triggerCount,
|
|
triggerTime
|
|
);
|
|
|
|
this.#isEnabled = true;
|
|
kissLog("Input Translator enabled.");
|
|
}
|
|
|
|
/**
|
|
* 禁用输入翻译功能
|
|
*/
|
|
disable() {
|
|
if (!this.#isEnabled) {
|
|
return;
|
|
}
|
|
if (this.#unregisterShortcut) {
|
|
this.#unregisterShortcut();
|
|
this.#unregisterShortcut = null;
|
|
}
|
|
this.#isEnabled = false;
|
|
kissLog("Input Translator disabled.");
|
|
}
|
|
|
|
/**
|
|
* 切换启用/禁用状态
|
|
*/
|
|
toggle() {
|
|
if (this.#isEnabled) {
|
|
this.disable();
|
|
} else {
|
|
this.enable();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 翻译核心逻辑
|
|
* @private
|
|
*/
|
|
async #handleTranslate() {
|
|
let node = document.activeElement;
|
|
if (!node) return;
|
|
|
|
while (node.shadowRoot && node.shadowRoot.activeElement) {
|
|
node = node.shadowRoot.activeElement;
|
|
}
|
|
|
|
if (!isInputNode(node) && !isEditAbleNode(node)) {
|
|
return;
|
|
}
|
|
|
|
const { apiSlug, transSign, triggerCount } = this.#config.inputRule;
|
|
let { fromLang, toLang } = this.#config.inputRule;
|
|
|
|
let initText = getNodeText(node);
|
|
|
|
if (
|
|
this.#triggerShortcut.length === 1 &&
|
|
this.#triggerShortcut[0].length === 1
|
|
) {
|
|
initText = removeEndchar(
|
|
initText,
|
|
this.#triggerShortcut[0],
|
|
triggerCount
|
|
);
|
|
}
|
|
|
|
if (!initText.trim()) return;
|
|
|
|
let text = initText;
|
|
if (transSign) {
|
|
const res = matchInputStr(text, transSign);
|
|
if (res) {
|
|
let lang = res[1];
|
|
if (lang === "zh" || lang === "cn") lang = "zh-CN";
|
|
else if (lang === "tw" || lang === "hk") lang = "zh-TW";
|
|
|
|
if (lang && OPT_LANGS_LIST.includes(lang)) {
|
|
toLang = lang;
|
|
}
|
|
text = res[2];
|
|
}
|
|
}
|
|
|
|
const apiSetting =
|
|
this.#config.transApis.find((api) => api.apiSlug === apiSlug) ||
|
|
DEFAULT_API_SETTING;
|
|
const loadingId = "kiss-loading-" + genEventName();
|
|
|
|
try {
|
|
addLoading(node, loadingId);
|
|
|
|
const [trText, isSame] = await apiTranslate({
|
|
text,
|
|
fromLang,
|
|
toLang,
|
|
apiSlug,
|
|
apiSetting,
|
|
});
|
|
|
|
if (!trText || isSame) return;
|
|
|
|
if (isInputNode(node)) {
|
|
node.value = trText;
|
|
node.dispatchEvent(
|
|
new Event("input", { bubbles: true, cancelable: true })
|
|
);
|
|
} else {
|
|
replaceContentEditableText(node, trText);
|
|
}
|
|
} catch (err) {
|
|
kissLog("Translate input error:", err);
|
|
} finally {
|
|
removeLoading(loadingId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 更新配置
|
|
*/
|
|
updateConfig({ inputRule, transApis }) {
|
|
const wasEnabled = this.#isEnabled;
|
|
if (wasEnabled) {
|
|
this.disable();
|
|
}
|
|
|
|
if (inputRule) {
|
|
this.#config.inputRule = inputRule;
|
|
}
|
|
if (transApis) {
|
|
this.#config.transApis = transApis;
|
|
}
|
|
|
|
const { triggerShortcut: initialTriggerShortcut } = this.#config.inputRule;
|
|
this.#triggerShortcut =
|
|
initialTriggerShortcut && initialTriggerShortcut.length > 0
|
|
? initialTriggerShortcut
|
|
: DEFAULT_INPUT_SHORTCUT;
|
|
|
|
if (wasEnabled) {
|
|
this.enable();
|
|
}
|
|
}
|
|
}
|