feat: highlight fav words && split long paragraph

This commit is contained in:
Gabe
2025-10-19 00:19:47 +08:00
parent b6ff4aae6a
commit 2325155b1e
7 changed files with 402 additions and 7 deletions

View File

@@ -18,6 +18,11 @@ import {
MSG_TRANSBOX_TOGGLE,
MSG_MOUSEHOVER_TOGGLE,
MSG_TRANSINPUT_TOGGLE,
OPT_HIGHLIGHT_WORDS_BEFORETRANS,
OPT_HIGHLIGHT_WORDS_AFTERTRANS,
OPT_SPLIT_PARAGRAPH_PUNCTUATION,
OPT_SPLIT_PARAGRAPH_DISABLE,
OPT_SPLIT_PARAGRAPH_TEXTLENGTH,
} from "../config";
import interpreter from "./interpreter";
import { ShadowRootMonitor } from "./shadowroot";
@@ -164,6 +169,8 @@ export class Translator {
warpper: `${APP_LCNAME}-wrapper notranslate`,
inner: `${APP_LCNAME}-inner`,
term: `${APP_LCNAME}-term`,
br: `${APP_LCNAME}-br`,
highlight: `${APP_LCNAME}-highlight`,
};
// 内置跳过翻译文本
@@ -279,6 +286,7 @@ export class Translator {
#textClass = {}; // 译文样式class
#textSheet = ""; // 译文样式字典
#apisMap = new Map(); // 用于接口快速查找
#favWords = []; // 收藏词汇
#isUserscript = false;
#transboxManager = null; // 划词翻译
@@ -289,6 +297,7 @@ export class Translator {
#viewNodes = new Set(); // 当前在可视范围内的单元
#processedNodes = new WeakMap(); // 已处理已执行翻译DOM操作的单元
#rootNodes = new Set(); // 已监控的根节点
#skipMoNodes = new WeakSet(); // 忽略变化的节点
#removeKeydownHandler; // 快捷键清理函数
#hoveredNode = null; // 存储当前悬停的可翻译节点
@@ -330,9 +339,15 @@ export class Translator {
};
}
constructor(rule = {}, setting = {}, isUserscript = false) {
constructor({
rule = {},
setting = {},
favWords = [],
isUserscript = false,
}) {
this.#setting = { ...Translator.DEFAULT_OPTIONS, ...setting };
this.#rule = { ...Translator.DEFAULT_RULE, ...rule };
this.#favWords = favWords;
this.#apisMap = new Map(
this.#setting.transApis.map((api) => [api.apiSlug, api])
);
@@ -585,6 +600,8 @@ export class Translator {
#createMutationObserver() {
return new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (this.#skipMoNodes.has(mutation.target)) return;
if (
mutation.type === "characterData" &&
mutation.oldValue !== mutation.target.nodeValue
@@ -599,6 +616,8 @@ export class Translator {
let nodes = new Set();
let hasText = false;
mutation.addedNodes.forEach((node) => {
if (this.#skipMoNodes.has(node)) return;
if (/\S/.test(node.nodeValue)) {
if (node.nodeType === Node.TEXT_NODE) {
hasText = true;
@@ -780,6 +799,10 @@ export class Translator {
// 开始/重新监控节点
#startObserveNode(node) {
if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_BEFORETRANS) {
this.#highlightWordsDeeply(node);
}
if (
!this.#observedNodes.has(node) &&
this.#enabled &&
@@ -861,7 +884,12 @@ export class Translator {
// 提前进行语言检测
let deLang = "";
const { fromLang = "auto", toLang } = this.#rule;
const {
fromLang = "auto",
toLang,
splitParagraph = OPT_SPLIT_PARAGRAPH_DISABLE,
splitLength = 100,
} = this.#rule;
const { langDetector, skipLangs = [] } = this.#setting;
if (fromLang === "auto") {
deLang = await tryDetectLang(node.textContent, langDetector);
@@ -876,6 +904,11 @@ export class Translator {
}
}
// 切分长段落
if (splitParagraph !== OPT_SPLIT_PARAGRAPH_DISABLE) {
this.#splitTextNodesBySentence(node, splitParagraph, splitLength);
}
let nodeGroup = [];
[...node.childNodes].forEach((child) => {
const shouldBreak = this.#shouldBreak(child);
@@ -895,6 +928,162 @@ export class Translator {
}
}
// 高亮词汇
#highlightTextNode(textNode, wordRegex) {
if (textNode.parentNode?.nodeName.toLowerCase() === "b") {
return;
}
if (!wordRegex.test(textNode.textContent)) {
return;
}
wordRegex.lastIndex = 0;
const fragments = textNode.textContent.split(wordRegex);
const newNodes = [];
fragments.forEach((fragment, i) => {
if (!fragment) return;
if (i % 2 === 1) {
// 奇数索引是匹配到的关键词
const bTag = document.createElement("b");
bTag.className = Translator.KISS_CLASS.highlight;
bTag.style.cssText = this.#rule.highlightStyle || "";
bTag.textContent = fragment;
this.#skipMoNodes.add(bTag);
newNodes.push(bTag);
} else {
// 偶数索引是普通文本
const newTextNode = document.createTextNode(fragment);
this.#skipMoNodes.add(newTextNode);
newNodes.push(newTextNode);
}
});
if (newNodes.length > 0) {
textNode.replaceWith(...newNodes);
}
}
// 高亮词汇
#highlightWordsDeeply(parentNode) {
if (!parentNode || this.#favWords.length === 0) {
return;
}
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapedWords = this.#favWords.map(escapeRegex);
const wordRegex = new RegExp(`\\b(${escapedWords.join("|")})\\b`, "gi");
if (parentNode.nodeType === Node.ELEMENT_NODE) {
const walker = document.createTreeWalker(
parentNode,
NodeFilter.SHOW_TEXT,
null,
false
);
const nodesToProcess = [];
let node;
while ((node = walker.nextNode())) {
nodesToProcess.push(node);
}
nodesToProcess.forEach((textNode) => {
this.#highlightTextNode(textNode, wordRegex);
});
} else if (parentNode.nodeType === Node.TEXT_NODE) {
this.#highlightTextNode(parentNode, wordRegex);
}
}
// 切分文本段落
#splitTextNodesBySentence(parentNode, splitParagraph, splitLength) {
const sentenceEndRegexForSplit = /[。!?]+|[.?!]+(?=\s+|$)/g;
[...parentNode.childNodes].forEach((node) => {
if (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === "") {
return;
}
const text = node.textContent;
const parts = [];
let lastIndex = 0;
let match;
while ((match = sentenceEndRegexForSplit.exec(text)) !== null) {
let realEndIndex = match.index + match[0].length;
while (realEndIndex < text.length && /\s/.test(text[realEndIndex])) {
realEndIndex++;
}
parts.push(text.substring(lastIndex, realEndIndex));
lastIndex = realEndIndex;
sentenceEndRegexForSplit.lastIndex = realEndIndex;
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
const validParts = parts.filter((part) => part.trim().length > 0);
if (validParts.length <= 1) {
return;
}
const newNodes = validParts.map((part) => {
const newNode = document.createTextNode(part);
this.#skipMoNodes.add(newNode);
return newNode;
});
node.replaceWith(...newNodes);
});
const sentenceEndRegexForTest = /(?:[。!??!]+|(?<!\d)\.)\s*$/;
let textLength = 0;
[...parentNode.childNodes].forEach((node) => {
textLength += node.textContent.length;
const isSentenceEnd = sentenceEndRegexForTest.test(node.textContent);
if (!isSentenceEnd || node.nextSibling?.nodeName === "BR") {
return;
}
if (
splitParagraph === OPT_SPLIT_PARAGRAPH_PUNCTUATION ||
(splitParagraph === OPT_SPLIT_PARAGRAPH_TEXTLENGTH &&
textLength >= splitLength)
) {
textLength = 0;
const br = document.createElement("br");
br.className = Translator.KISS_CLASS.br;
this.#skipMoNodes.add(br);
node.after(br);
}
});
}
// 清除高亮
#removeHighlights(parentNode) {
if (!parentNode) {
return;
}
const highlightedElements = parentNode.querySelectorAll(
`.${Translator.KISS_CLASS.highlight}`
);
highlightedElements.forEach((element) => {
const textNode = document.createTextNode(element.textContent);
element.replaceWith(textNode);
});
parentNode.normalize();
}
// 判断是否需要换行
#shouldBreak(node) {
if (!Translator.isElementOrFragment(node)) return false;
@@ -971,6 +1160,7 @@ export class Translator {
// detectRemote,
// toLang,
// skipLangs = [],
highlightWords,
} = this.#rule;
const {
newlineLength,
@@ -1062,6 +1252,11 @@ export class Translator {
parentNode.parentElement.style.cssText += grandStyle;
}
// 高亮词汇
if (highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) {
nodes.forEach((node) => this.#highlightWordsDeeply(node));
}
// 翻译完成钩子函数
if (transEndHook?.trim()) {
try {
@@ -1226,6 +1421,10 @@ export class Translator {
root
.querySelectorAll(APP_LCNAME)
.forEach((el) => this.#removeTranslationElement(el));
root
.querySelectorAll(Translator.KISS_CLASS.br)
.forEach((br) => br.remove());
}
// 清理子节点译文dom
@@ -1237,7 +1436,8 @@ export class Translator {
// 清理译文
#removeTranslationElement(el) {
this.#processedNodes.delete(el.parentElement);
const parentElement = el.parentElement;
this.#processedNodes.delete(parentElement);
// 如果是仅显示译文模式,先恢复原文
const { nodes, isHide } = this.#translationNodes.get(el) || {};
@@ -1247,6 +1447,11 @@ export class Translator {
this.#translationNodes.delete(el);
el.remove();
// 清除高亮
if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) {
this.#removeHighlights(parentElement);
}
}
// 恢复原文