Files
kiss-translator/src/libs/inputTranslate.js
2025-10-01 01:47:15 +08:00

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();
}
}
}