diff --git a/src/apis/trans.js b/src/apis/trans.js index bc342d9..1f30d62 100644 --- a/src/apis/trans.js +++ b/src/apis/trans.js @@ -919,7 +919,7 @@ export const handleTranslate = async ( httpTimeout, }); if (!response) { - throw new Error("tranlate got empty response"); + throw new Error("translate got empty response"); } const result = await parseTransRes(response, { @@ -934,7 +934,7 @@ export const handleTranslate = async ( ...apiSetting, }); if (!result?.length) { - throw new Error("tranlate got an unexpected result"); + throw new Error("translate got an unexpected result"); } return result; diff --git a/src/common.js b/src/common.js index 2945ec8..9dbb711 100644 --- a/src/common.js +++ b/src/common.js @@ -8,8 +8,13 @@ import { MSG_TRANS_TOGGLE_STYLE, MSG_TRANS_PUTRULE, APP_CONSTS, + OPT_HIGHLIGHT_WORDS_DISABLE, } from "./config"; -import { getFabWithDefault, getSettingWithDefault } from "./libs/storage"; +import { + getFabWithDefault, + getSettingWithDefault, + getWordsWithDefault, +} from "./libs/storage"; import { Translator } from "./libs/translator"; import { isIframe, sendIframeMsg } from "./libs/iframe"; import { touchTapListener } from "./libs/touch"; @@ -209,7 +214,19 @@ export async function run(isUserscript = false) { // 翻译网页 const rule = await matchRule(href, setting); - const translator = new Translator(rule, setting, isUserscript); + let favWords = []; + if ( + rule.highlightWords && + rule.highlightWords !== OPT_HIGHLIGHT_WORDS_DISABLE + ) { + favWords = Object.keys(await getWordsWithDefault()); + } + const translator = new Translator({ + rule, + setting, + favWords, + isUserscript, + }); // 适配iframe if (isIframe) { diff --git a/src/config/i18n.js b/src/config/i18n.js index dcb549b..f866f64 100644 --- a/src/config/i18n.js +++ b/src/config/i18n.js @@ -719,6 +719,11 @@ export const I18N = { en: `Terms Style`, zh_TW: `專業術語樣式`, }, + highlight_style: { + zh: `词汇高亮样式`, + en: `Fav Words highlight style`, + zh_TW: `詞彙高亮樣式`, + }, selector_style_helper: { zh: `开启翻译时注入。`, en: `It is injected when translation is turned on.`, @@ -1669,6 +1674,52 @@ export const I18N = { en: `Click to view [Custom Interface Example]`, zh_TW: `點選查看【自訂介面範例】`, }, + split_paragraph: { + zh: `切分长段落`, + en: `Split long paragraph`, + zh_TW: `切分長段落`, + }, + split_length: { + zh: `切分长度 (0-10000)`, + en: `Segmentation length(0-10000)`, + zh_TW: `切分長度(0-10000)`, + }, + highlight_words: { + zh: `高亮收藏词汇`, + en: `Highlight favorite words`, + zh_TW: `高亮收藏詞彙`, + }, + + split_disable: { + zh: `禁用`, + en: `Disable`, + zh_TW: `停用`, + }, + split_textlength: { + zh: `按照长度切分`, + en: `Split by length`, + zh_TW: `依長度切分`, + }, + split_punctuation: { + zh: `按照句子切分`, + en: `Split by sentence`, + zh_TW: `按照句子切分`, + }, + highlight_disable: { + zh: `禁用`, + en: `Disable`, + zh_TW: `停用`, + }, + highlight_beforetrans: { + zh: `翻译前高亮`, + en: `Highlight before translation`, + zh_TW: `翻譯前高亮`, + }, + highlight_aftertrans: { + zh: `翻译后高亮`, + en: `Highlight after translation`, + zh_TW: `翻譯後高亮`, + }, }; export const i18n = (lang) => (key) => I18N[key]?.[lang] || ""; diff --git a/src/config/rules.js b/src/config/rules.js index 44fa2ac..cb3bab2 100644 --- a/src/config/rules.js +++ b/src/config/rules.js @@ -63,6 +63,24 @@ export const OPT_TIMING_ALL = [ OPT_TIMING_ALT, ]; +export const OPT_SPLIT_PARAGRAPH_DISABLE = "split_disable"; +export const OPT_SPLIT_PARAGRAPH_TEXTLENGTH = "split_textlength"; +export const OPT_SPLIT_PARAGRAPH_PUNCTUATION = "split_punctuation"; +export const OPT_SPLIT_PARAGRAPH_ALL = [ + OPT_SPLIT_PARAGRAPH_DISABLE, + OPT_SPLIT_PARAGRAPH_PUNCTUATION, + OPT_SPLIT_PARAGRAPH_TEXTLENGTH, +]; + +export const OPT_HIGHLIGHT_WORDS_DISABLE = "highlight_disable"; +export const OPT_HIGHLIGHT_WORDS_BEFORETRANS = "highlight_beforetrans"; +export const OPT_HIGHLIGHT_WORDS_AFTERTRANS = "highlight_aftertrans"; +export const OPT_HIGHLIGHT_WORDS_ALL = [ + OPT_HIGHLIGHT_WORDS_DISABLE, + OPT_HIGHLIGHT_WORDS_BEFORETRANS, + OPT_HIGHLIGHT_WORDS_AFTERTRANS, +]; + export const DEFAULT_DIY_STYLE = `color: #333; background: linear-gradient( 45deg, @@ -94,6 +112,7 @@ export const DEFAULT_RULE = { bgColor: "", // 译文颜色 textDiyStyle: "", // 自定义译文样式 termsStyle: "", // 专业术语样式 + highlightStyle: "", // 高亮词汇样式 selectStyle: "", // 选择器节点样式 parentStyle: "", // 选择器父节点样式 grandStyle: "", // 选择器父节点样式 @@ -116,6 +135,9 @@ export const DEFAULT_RULE = { hasShadowroot: GLOBAL_KEY, // 是否包含shadowroot rootsSelector: "", // 翻译范围选择器 ignoreSelector: "", // 不翻译的选择器 + splitParagraph: GLOBAL_KEY, // 切分段落 + splitLength: 0, // 切分段落长度 + highlightWords: GLOBAL_KEY, // 高亮词汇 }; // 全局规则 @@ -133,6 +155,7 @@ export const GLOBLA_RULE = { bgColor: "", // 译文颜色 textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式 termsStyle: "font-weight: bold;", // 专业术语样式 + highlightStyle: "color: red;", // 高亮词汇样式 selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式 parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式 grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式 @@ -155,6 +178,9 @@ export const GLOBLA_RULE = { hasShadowroot: "false", // 是否包含shadowroot rootsSelector: "body", // 翻译范围选择器 ignoreSelector: DEFAULT_IGNORE_SELECTOR, // 不翻译的选择器 + splitParagraph: OPT_SPLIT_PARAGRAPH_DISABLE, // 切分段落 + splitLength: 100, // 切分段落长度 + highlightWords: OPT_HIGHLIGHT_WORDS_DISABLE, // 高亮词汇 }; export const DEFAULT_RULES = [GLOBLA_RULE]; diff --git a/src/libs/rules.js b/src/libs/rules.js index c085d90..9ccce74 100644 --- a/src/libs/rules.js +++ b/src/libs/rules.js @@ -7,6 +7,8 @@ import { // OPT_TIMING_ALL, DEFAULT_RULE, GLOBLA_RULE, + OPT_SPLIT_PARAGRAPH_ALL, + OPT_HIGHLIGHT_WORDS_ALL, } from "../config"; import { loadOrFetchSubRules } from "./subRules"; import { getRulesWithDefault, setRules } from "./storage"; @@ -53,6 +55,7 @@ export const matchRule = async (href, { injectRules, subrulesList }) => { "terms", "aiTerms", "termsStyle", + "highlightStyle", "selectStyle", "parentStyle", "grandStyle", @@ -82,12 +85,20 @@ export const matchRule = async (href, { injectRules, subrulesList }) => { "transTitle", // "detectRemote", // "fixerFunc", + "splitParagraph", + "highlightWords", ].forEach((key) => { if (!rule[key] || rule[key] === GLOBAL_KEY) { rule[key] = globalRule[key]; } }); + ["splitLength"].forEach((key) => { + if (!rule[key]) { + rule[key] = globalRule[key]; + } + }); + // if (!rule.skipLangs || rule.skipLangs.length === 0) { // rule.skipLangs = globalRule.skipLangs; // } @@ -138,6 +149,7 @@ export const checkRules = (rules) => { terms, aiTerms, termsStyle, + highlightStyle, selectStyle, parentStyle, grandStyle, @@ -164,6 +176,9 @@ export const checkRules = (rules) => { transStartHook, transEndHook, // transRemoveHook, + splitParagraph, + splitLength, + highlightWords, }) => ({ pattern: pattern.trim(), selector: type(selector) === "string" ? selector : "", @@ -173,6 +188,7 @@ export const checkRules = (rules) => { terms: type(terms) === "string" ? terms : "", aiTerms: type(aiTerms) === "string" ? aiTerms : "", termsStyle: type(termsStyle) === "string" ? termsStyle : "", + highlightStyle: type(highlightStyle) === "string" ? highlightStyle : "", selectStyle: type(selectStyle) === "string" ? selectStyle : "", parentStyle: type(parentStyle) === "string" ? parentStyle : "", grandStyle: type(grandStyle) === "string" ? grandStyle : "", @@ -203,6 +219,15 @@ export const checkRules = (rules) => { // transRemoveHook: // type(transRemoveHook) === "string" ? transRemoveHook : "", // fixerFunc: matchValue([GLOBAL_KEY, ...FIXER_ALL], fixerFunc), + splitParagraph: matchValue( + [GLOBAL_KEY, ...OPT_SPLIT_PARAGRAPH_ALL], + splitParagraph + ), + splitLength: Number.isInteger(splitLength) ? splitLength : 0, + highlightWords: matchValue( + [GLOBAL_KEY, ...OPT_HIGHLIGHT_WORDS_ALL], + highlightWords + ), }) ); diff --git a/src/libs/translator.js b/src/libs/translator.js index d09727e..ab89681 100644 --- a/src/libs/translator.js +++ b/src/libs/translator.js @@ -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 = /(?:[。!??!]+|(? { + 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); + } } // 恢复原文 diff --git a/src/views/Options/Rules.js b/src/views/Options/Rules.js index b9c605a..87af4f5 100644 --- a/src/views/Options/Rules.js +++ b/src/views/Options/Rules.js @@ -16,6 +16,10 @@ import { URL_KISS_RULES_NEW_ISSUE, OPT_SYNCTYPE_WORKER, DEFAULT_TRANS_TAG, + OPT_SPLIT_PARAGRAPH_DISABLE, + OPT_HIGHLIGHT_WORDS_DISABLE, + OPT_SPLIT_PARAGRAPH_ALL, + OPT_HIGHLIGHT_WORDS_ALL, } from "../../config"; import { useState, useEffect, useMemo } from "react"; import { useI18n } from "../../hooks/I18n"; @@ -59,6 +63,7 @@ import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import CancelIcon from "@mui/icons-material/Cancel"; import SaveIcon from "@mui/icons-material/Save"; +import ValidationInput from "../../hooks/ValidationInput"; import { kissLog } from "../../libs/log"; import { useApiList } from "../../hooks/Api"; import ShowMoreButton from "./ShowMoreButton"; @@ -98,6 +103,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) { terms = "", aiTerms = "", termsStyle = "", + highlightStyle = "color: red;", selectStyle = "", parentStyle = "", grandStyle = "", @@ -124,6 +130,9 @@ function RuleFields({ rule, rules, setShow, setKeyword }) { transStartHook = "", transEndHook = "", // transRemoveHook = "", + splitParagraph = OPT_SPLIT_PARAGRAPH_DISABLE, + splitLength = 0, + highlightWords = OPT_HIGHLIGHT_WORDS_DISABLE, } = formValues; const isModified = useMemo(() => { @@ -423,6 +432,58 @@ function RuleFields({ rule, rules, setShow, setKeyword }) { + + + {GlobalItem} + {OPT_SPLIT_PARAGRAPH_ALL.map((item) => ( + + {i18n(item)} + + ))} + + + + + + + + {GlobalItem} + {OPT_HIGHLIGHT_WORDS_ALL.map((item) => ( + + {i18n(item)} + + ))} + + + +