Compare commits

..

95 Commits

Author SHA1 Message Date
Gabe
30c2cca2e1 Update version number: 2.0.5 2025-10-31 23:14:01 +08:00
Gabe
af3241b773 doc: readme 2025-10-31 23:12:27 +08:00
Gabe
eca0a63273 fix: subtitle 2025-10-31 20:23:58 +08:00
Gabe
f15cdb38d6 fix: put rule can write different pattern (#367) 2025-10-31 15:33:56 +08:00
Gabe
5b8577aaa7 doc: readme 2025-10-31 14:42:55 +08:00
Gabe
0a4a2b46c1 feat: support ai model with no batch 2025-10-31 14:36:33 +08:00
Gabe
53d441b3f5 doc: readme 2025-10-31 11:25:43 +08:00
Gabe
d4c346e40a fix: Increase the Max Tokens limit 2025-10-31 10:42:08 +08:00
Gabe
2dcacf71e4 fix: apitranslate bug 2025-10-31 10:25:53 +08:00
Gabe
9ded6446a7 fix: input translate 2025-10-31 10:05:01 +08:00
Gabe
0a6f4a9f02 fix: input translate (#342) 2025-10-31 01:39:27 +08:00
Gabe
635e588bcc fix: skip builtin ignore selector when autoscan disabled 2025-10-31 00:22:26 +08:00
Gabe
7343db78a5 fix: iframe bugs 2025-10-30 22:02:43 +08:00
Gabe
ccd457c992 fix: iframe bugs 2025-10-30 22:01:08 +08:00
Gabe
97676f114e fix: remove tink from ollama 2025-10-30 20:07:01 +08:00
Gabe
e83c1eb017 fix: throw error msg 2025-10-30 19:42:00 +08:00
Gabe
e417c0106a fix: change default fetchLimit 2025-10-30 19:10:07 +08:00
Gabe
3c09840d35 feat: can set rootMargin for IntersectionObserver. 2025-10-30 01:05:13 +08:00
Gabe
7361a94f8c feat: can set rootMargin for IntersectionObserver. 2025-10-30 01:03:46 +08:00
Gabe
a9bffe3913 feat: can set rootMargin for IntersectionObserver. 2025-10-30 00:55:17 +08:00
Gabe
5322555eba feat: can set whether skip ads. 2025-10-30 00:31:17 +08:00
Gabe
172dce2867 fix: hooks & injectjs 2025-10-30 00:19:13 +08:00
Gabe
5735fee36e fix: set main width 100% 2025-10-28 00:45:10 +08:00
Gabe
91642d8784 feat: support subtitle dragable on mobile 2025-10-28 00:36:38 +08:00
Gabe
9d8f3f4211 feat: add shadowroot injector 2025-10-28 00:07:44 +08:00
Gabe
66d39da80a fix: check io.observe must be element 2025-10-27 20:45:22 +08:00
Gabe
fbd4a31a9c fix: ignore script ellement 2025-10-27 20:00:05 +08:00
Gabe
3d7e03ddaf Update version number: 2.0.4 2025-10-26 20:26:52 +08:00
Gabe
1f213bf257 fix: styles 2025-10-26 20:10:13 +08:00
Gabe
b38f079611 fix: change showNotification duration 2025-10-26 19:59:51 +08:00
Gabe
21e639cacd fix: createMutationObserver 2025-10-26 18:42:59 +08:00
Gabe
bdaf665b7c feat: The translation box can be set to adaptive height 2025-10-26 16:18:56 +08:00
Gabe
61a515c1d2 feat: Support multi-touch selection 2025-10-26 00:06:52 +08:00
Gabe
1b646df908 feat: Remember the tranbox position and size 2025-10-25 23:18:39 +08:00
Gabe
5550f939b2 doc: readme 2025-10-25 18:41:20 +08:00
Gabe
b34fb5a600 doc: readme 2025-10-25 18:38:55 +08:00
Gabe
c0dce5c0b1 fix: Optimized text scanning logic 2025-10-25 17:46:29 +08:00
Gabe
d56bd2920f fix: isQualityPoor 2025-10-24 21:44:54 +08:00
Gabe
48ad100a64 fix: Optimized the scan node logic 2025-10-24 21:37:26 +08:00
Gabe
ef07a172a9 doc: custom api 2025-10-24 20:58:08 +08:00
Gabe
f492d47719 fix: disable field of rule 2025-10-24 20:57:10 +08:00
Gabe
ac8c07deb4 fix: keepselector for twitter 2025-10-24 01:46:36 +08:00
Gabe
ca48ab639e fix: remove stopPropagation for shortcut 2025-10-23 19:52:18 +08:00
Gabe
7c5232c1a1 fix: Make keepSelector effective even if richText is disabled 2025-10-23 19:32:59 +08:00
Gabe
4fac7fdfe1 fix: update custom api 2025-10-23 14:35:21 +08:00
Gabe
f7fc9560d5 fix: update custom api 2025-10-23 14:33:12 +08:00
Gabe
f7ba744e7f fix: ignore selector 2025-10-23 11:07:25 +08:00
Gabe
315164f142 fix: remove logger 2025-10-22 22:34:55 +08:00
Gabe
429cab5223 Update version number: 2.0.3 2025-10-22 22:27:17 +08:00
Gabe
deecbc874b fix: Adapt to the new UI of youtube 2025-10-22 21:39:17 +08:00
Gabe
504f4cafa0 fix: Adapt to the new UI of youtube 2025-10-22 21:14:39 +08:00
Gabe
6d327d17af fix: mousehover translate (close #331) 2025-10-22 21:01:23 +08:00
Gabe
74290eb52b fix: rules 2025-10-22 01:56:47 +08:00
Gabe
60b9653fd3 feat: more touch operations 2025-10-22 01:50:49 +08:00
Gabe
53e32d3031 refactor: add TranslatorManager 2025-10-21 02:07:33 +08:00
Gabe
ed279cf8a1 fix: highlight fav words 2025-10-19 01:28:29 +08:00
Gabe
296b898bda fix: disable preventDefault in shrotcut 2025-10-19 00:42:13 +08:00
Gabe
2325155b1e feat: highlight fav words && split long paragraph 2025-10-19 00:19:47 +08:00
Gabe
b6ff4aae6a docs: custom api 2025-10-18 11:34:16 +08:00
Gabe
f04002fdb8 fix: ycombinator.com rule 2025-10-18 00:37:55 +08:00
Gabe
b8cb254f56 perf: Optimized api find 2025-10-17 23:02:39 +08:00
Gabe
3983477904 fix: setting ui 2025-10-17 13:11:57 +08:00
Gabe
f011f5ae38 fix: api doc 2025-10-17 12:57:58 +08:00
Gabe
18ecab18df fix: api doc 2025-10-17 12:56:26 +08:00
Gabe
793c481221 fix: rules 2025-10-17 11:53:02 +08:00
Gabe
4fee3688ea fix: popup width change to 360 2025-10-17 10:53:13 +08:00
Gabe
b9693436bb Update version number: 2.0.2 2025-10-17 10:31:04 +08:00
Gabe
6b18d8f934 fix: rules 2025-10-17 10:20:42 +08:00
Gabe
65c325de9a fix: custom api example 2025-10-17 10:14:28 +08:00
Gabe
8da5aaf259 fix: add custom api example link 2025-10-17 10:01:02 +08:00
Gabe
00e8fdd3e6 fix: update custom api doc 2025-10-17 09:44:53 +08:00
Gabe
b2eea5d0d7 fix: custom api doc 2025-10-17 01:47:36 +08:00
Gabe
9a8e24f590 fix: sync rules, words 2025-10-17 01:19:24 +08:00
Gabe
32c6d45cb0 feat: add custom api examples 2025-10-16 23:51:49 +08:00
Gabe
74ce6f2f1f fix: youtube live caht rule 2025-10-16 21:29:58 +08:00
Gabe
573865cf10 fix: merge rules 2025-10-16 20:58:27 +08:00
Gabe
56d4733e2a feat: terms style 2025-10-16 20:16:03 +08:00
Gabe
a8965a01e3 fix: change some default setting 2025-10-16 19:35:28 +08:00
Gabe
beef51ef38 fix: default ignore selector 2025-10-16 01:25:52 +08:00
Gabe
b5b3ee8709 update version number: 2.0.1 2025-10-16 00:55:16 +08:00
Gabe
4f1e01dde0 feat: youtube ad skip 2025-10-15 23:58:57 +08:00
Gabe
d42ff51de5 feat: youtube ad skip 2025-10-15 23:44:45 +08:00
Gabe
c39861b7b7 fix: readme 2025-10-15 22:10:17 +08:00
Gabe
d39b9fd73e fix: api name in tranbox(Closes #322) 2025-10-15 22:04:03 +08:00
Gabe
ecab4ab634 feat: support subtitle translate for userscript 2025-10-15 21:41:09 +08:00
Gabe
5e67e15842 feat: clear caches in popup 2025-10-15 14:40:58 +08:00
Gabe
2af1a8b72c fix: remove retranslate subtitle code 2025-10-15 14:08:56 +08:00
Gabe
2510ed0ebb fix: shortcut(alt+tab) bug 2025-10-15 13:55:23 +08:00
XYenon
6827985289 feat: auto dark mode (#321) 2025-10-15 12:43:24 +08:00
Gabe
a095a2c01c fix: temperature limit float 2025-10-15 00:59:32 +08:00
Gabe
2033ff6777 fix: subtitle: retry translation failed 2025-10-14 23:45:30 +08:00
Gabe
0c22288833 Merge remote-tracking branch 'origin/dev' into dev 2025-10-14 23:37:30 +08:00
zxhzxhz
0576150067 Update BilingualSubtitleManager.js (#320)
fix translation style
2025-10-14 23:36:57 +08:00
Gabe
9cdcf616f7 fix: change innerHTML to trustedHTML 2025-10-14 23:36:28 +08:00
Gabe
2de10364f3 fix: try fix subtitle in userscript 2025-10-14 22:41:18 +08:00
75 changed files with 3197 additions and 1736 deletions

2
.env
View File

@@ -2,7 +2,7 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=2.0.0
REACT_APP_VERSION=2.0.5
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator

View File

@@ -1,40 +1,5 @@
# KISS Translator
**New Version Preview:**
After a period of intermittent development, the planned features for the new version are essentially complete. The main new features are as follows:
* **Core Translation Logic Refactoring:**
* Supports both automatic text detection and manual selection modes.
* The automatic text detection mode enables complete translation for the vast majority of websites without the need to write specific rules.
* The previous manual rule mode has been retained for meticulous optimization on specific websites.
* Supports rich text translation, preserving links and other text styles from the original content as much as possible.
* Optimize the display effect of showing only translated text (hiding original text).
* **API Refactoring:**
* Supports adding and deleting an arbitrary number of APIs.
* Supports aggregating text for sending, reducing the number of calls to the translation API and improving performance.
* Supports the built-in Chrome AI translation API, enabling AI-powered translation without an internet connection.
* Supports AI contextual conversation memory to enhance translation quality.
* All APIs support advanced features such as hooks and custom parameters.
* Added support for Azure AI translation interface.
* **Optimized YouTube Subtitle Support:**
* Supports translating video subtitles with any translation service and displaying them bilingually.
* Includes a built-in basic algorithm for subtitle merging and sentence splitting to improve translation results.
* Supports an AI-powered sentence splitting function to further enhance translation quality.
* **English Dictionary Redundancy:**
* Added Bing and Youdao dictionaries.
* Fixed the vocabulary collection feature.
* **User Experience Optimization:**
* The pop-up translation box for selected text now supports simultaneous translation by multiple services.
* The translation control panel has been updated with many new quick-toggle functions.
* Added a Playground page for convenient API debugging.
**Note:** Due to extensive refactoring, the configuration file for the new version is not backward compatible with the old version. Therefore, please back up your data manually before upgrading. Furthermore, **do not import old configuration files after upgrading to the new version.**
English | [简体中文](README.md)
A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator).
@@ -57,27 +22,35 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
- [x] Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
- [x] DeepL/DeepLX/NiuTrans
- [x] BuiltinAI/AzureAI/CloudflareAI
- [x] Custom translation interface
- [x] AzureAI / CloudflareAI
- [x] Chrome built-in AI translation (BuiltinAI)
- [x] Covers common translation scenarios
- [x] Web bilingual translation
- [x] Input box translation
- [x] Seletction translation
- [x] Open the translation box on any page
- [x] Favorite Words
- [x] Mouseover translation
- [x] Webpage bilingual translation
- [x] Input-box translation
- Instantly translate text in input fields into other languages via shortcut keys
- [x] Text selection translation
- [x] Open translation popup on any page, support multiple translation services for comparison
- [x] English dictionary lookup
- [x] Save vocabulary
- [x] Hover translation
- [x] YouTube subtitle translation
- [x] Support for various translation effects
- [x] Customizable text recognition and full-text translation
- [x] Customizable translation styles
- [x] Support for rich text translation and display
- [x] Support for displaying only the translated text (hiding the original text)
- [x] Advanced translation API features
- [x] Aggregate and send translated texts in batches
- [x] AI contextual conversation memory
- [x] Customizable AI terminology dictionary
- [x] AI-powered subtitle segmentation and translation
- [x] Customizable hooks and parameters
- Support translating video subtitles with any translation service and display bilingually
- Built-in basic subtitle merging and sentence-splitting algorithm to improve translation quality
- Supports AI-powered sentence segmentation for even better translation
- Custom subtitle style
- [x] Supports diverse translation modes
- [x] Supports both automatic text recognition and manual rule modes
- Automatic text recognition mode allows most sites to be translated fully without writing rules
- Manual rule mode enables extreme optimization for specific sites
- [x] Custom translation styling
- [x] Supports rich-text translation and rendering, preserving links and other text styles where possible
- [x] Option to show only translation (hide original text)
- [x] Advanced translation API features
- [x] With custom API support, theoretically works with any translation service
- [x] Batch aggregation of translation requests
- [x] Supports AI conversation context memory to improve translation quality
- [x] Custom AI terminology dictionary
- [x] All APIs support hooks and custom parameters for advanced usage
- [x] Cross-client data synchronization
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
@@ -96,7 +69,7 @@ A simple, open source [bilingual translation extension & Greasemonkey script](ht
> Note: For the following reasons, it is recommended to use browser extensions first
>
> - Browser extensions have more complete functions (subtitle translation, local language recognition, context menu, etc.)
> - Browser extensions have more complete functions (local language recognition, context menu, etc.)
> - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.)
- [x] Browser extension
@@ -139,14 +112,35 @@ Personal Rules > Subscription Rules > Global Rules
Among these, Global Rules have the lowest priority but are very important as they serve as the default rules.
### Local Ollama interface cannot be used
### API (Ollama, etc.) Test Failure
If encountering a 403 error, refer to: https://github.com/fishjar/kiss-translator/issues/174
Common reasons for API test failures include:
- Incorrect address:
- For example, `Ollama` has a native API address and an `Openai`-compatible address. This plugin currently supports the `Openai`-compatible address and does not support the `Ollama` native API address.
- Some AI models do not support batch translation:
- In this case, you can choose to disable batch translation or use a custom API.
- Alternatively, you can use a custom API. For details, please refer to: [Custom API Example Documentation](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
- Some AI models have inconsistent parameters:
- For example, the parameters of the `Gemini` native API are highly inconsistent. Some model versions do not support certain parameters, leading to errors.
- In this case, you can modify the request body using a `Hook`, or replace it with `Gemini2` (an OpenAI-compatible address).
- The server restricts cross-origin access, returning a 403 error:
- For example, `Ollama` requires adding the environment variable `OLLAMA_ORIGINS=*` when starting. See: https://github.com/fishjar/kiss-translator/issues/174
### Custom API doesn't work in Tampermonkey scripts
Tampermonkey scripts require adding domains to the whitelist; otherwise, requests cannot be sent.
### How to set up a hook function for a custom API
Custom APIs are very powerful and flexible, and can theoretically connect to any translation API.
Example reference: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
### How to directly access the Tampermonkey script settings page
Settings page address: https://fishjar.github.io/kiss-translator/options.html
## Future Plans
This is a side project with no strict timeline. Community contributions are welcome. The following are preliminary feature directions:

View File

@@ -1,36 +1,5 @@
# 简约翻译
> **新版预告**
>
> 经过一段时间断续开发,新版的预期功能已基本完成,主要引入的新特性如下:
>
> - 核心翻译逻辑重构:
> - 支持自动识别文本与手动选择两种模式。
> - 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整。
> - 保留之前的手动规则模式,可以针对特定网站极致优化。
> - 支持富文本翻译,能够尽量保留原文中的链接及其他文本样式。
> - 优化仅显示译文(隐藏原文)显示效果。
> - 接口重构:
> - 支持添加、删除任意数量的接口。
> - 支持聚合发送文本,减少翻译接口调用次数,提升性能。
> - 支持chrome内置AI翻译接口无需通过网络即可实现AI翻译。
> - 支持AI上下文会话记忆功能提升翻译效果。
> - 所有接口均支持Hook和自定义参数等高级功能。
> - 新增Azure AI翻译接口支持
> - 优化 YouTube 字幕支持:
> - 支持任意翻译服务对视频字幕进行翻译并双语显示。
> - 内置基础的字幕合并与断句算法,提升翻译效果。
> - 支持AI断句功能可进一步提升翻译质量。
> - 英文词典备灾:
> - 新增bing、有道词典。
> - 修复词汇收藏功能。
> - 用户操作优化:
> - 划词翻译框支持多种翻译服务同时翻译。
> - 翻译控制面板新增许多快捷切换功能。
> - 新增Playground页面方便调试接口。
>
> 注意:由于经过大量重构,使得新版配置文件很难与旧版兼容,因此在升级前请手动备份相关数据。并且,**升级新版后,勿再导入旧版配置**。
[English](README.en.md) | 简体中文
一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。
@@ -53,27 +22,35 @@
- [x] Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
- [x] DeepL/DeepLX/NiuTrans
- [x] BuiltinAI/AzureAI/CloudflareAI
- [x] 自定义翻译接口
- [x] AzureAI/CloudflareAI
- [x] Chrome浏览器内置AI翻译(BuiltinAI)
- [x] 覆盖常见翻译场景
- [x] 网页双语对照翻译
- [x] 输入框翻译
- 通过快捷键立即将输入框内文本翻译成其他语言
- [x] 划词翻译
- [x] 任意页面打开翻译框
- [x] 任意页面打开翻译框,可用多种翻译服务对比翻译
- [x] 英文词典翻译
- [x] 收藏词汇
- [x] 鼠标悬停翻译
- [x] YouTube 字幕翻译
- 支持任意翻译服务对视频字幕进行翻译并双语显示
- 内置基础的字幕合并与断句算法,提升翻译效果
- 支持AI断句功能可进一步提升翻译质量
- 自定义字幕样式
- [x] 支持多样翻译效果
- [x] 自定识别文本,全文翻译
- [x] 支持自动识别文本与手动规则两种模式
- 自动识别文本模式使得绝大部分网站无需编写规则也能翻译完整
- 手动规则模式,可以针对特定网站极致优化
- [x] 自定义译文样式
- [x] 支持富文本翻译及显示
- [x] 支持富文本翻译及显示,能够尽量保留原文中的链接及其他文本样式
- [x] 支持仅显示译文(隐藏原文)
- [x] 翻译接口高级功能
- [x] 通过自定义接口,理论上支持任何翻译接口
- [x] 聚合批量发送翻译文本
- [x] AI上下文会话记忆
- [x] 支持AI上下文会话记忆功能,提升翻译效果
- [x] 自定义AI术语词典
- [x] 字幕文本AI智能断句及翻译
- [x] 自定义Hook自定义参数
- [x] 所有接口均支持Hook和自定义参数等高级功能
- [x] 跨客户端数据同步
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
@@ -92,7 +69,7 @@
> 注:基于以下原因,建议优先使用浏览器扩展
>
> - 浏览器扩展的功能更完整(字幕翻译、本地语言识别、右键菜单等)
> - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
- [x] 浏览器扩展
@@ -135,14 +112,35 @@
其中全局规则优先级最低,但非常重要,相当于兜底规则。
### 本地的Ollama接口不能使用
### 接口(Ollama等)测试失败
如果出现403的情况参考https://github.com/fishjar/kiss-translator/issues/174
一般接口测试失败常见有以下几种原因:
- 地址填错了:
- 比如 `Ollama` 有原生接口地址和 `Openai` 兼容的地址,本插件目前统一支持 `Openai` 兼容的地址,不支持 `Ollama` 原生接口地址
- 某些AI模型不支持聚合翻译
- 此种情况可以选择禁用聚合翻译或通过自定义接口的方式来使用。
- 或通过自定义接口的方式来使用,详情参考: [自定义接口示例文档](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
- 某些AI模型的参数不一致
- 比如 `Gemini` 原生接口参数非常不一致,部分版本的模型不支持某些参数会导致返回错误。
- 此种情况可以通过 `Hook` 修改请求 `body` ,或者更换为 `Gemini2` (`Openai` 兼容的地址)
- 服务器跨域限制访问返回403错误
- 比如 `Ollama` 启动时须添加环境变量 `OLLAMA_ORIGINS=*`, 参考https://github.com/fishjar/kiss-translator/issues/174
### 填写的接口在油猴脚本不能使用
油猴脚本需要增加域名白名单,否则不能发出请求。
### 如何设置自定义接口的hook函数
自定义接口功能非常强大、灵活,理论可以接入任何翻译接口。
示例参考: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
### 如何直接进入油猴脚本设置页面
设置页面地址: https://fishjar.github.io/kiss-translator/options.html
## 未来规划
本项目为业余开发,无严格时间表,欢迎社区共建。以下为初步设想的功能方向:

View File

@@ -32,7 +32,8 @@ const extWebpack = (config, env) => {
options: paths.appSrc + "/options.js",
background: paths.appSrc + "/background.js",
content: paths.appSrc + "/content.js",
injector: paths.appSrc + "/injector.js",
"injector-subtitle": paths.appSrc + "/injector-subtitle.js",
"injector-shadowroot": paths.appSrc + "/injector-shadowroot.js",
};
config.output.filename = "[name].js";
@@ -106,6 +107,7 @@ const userscriptWebpack = (config, env) => {
// @connect openai.azure.com
// @connect workers.dev
// @connect github.io
// @connect github.com
// @connect githubusercontent.com
// @connect kiss-translator.rayjar.com
// @connect ghproxy.com
@@ -130,7 +132,6 @@ const userscriptWebpack = (config, env) => {
config.entry = {
main: paths.appIndexJs,
options: paths.appSrc + "/options.js",
injector: paths.appSrc + "/injector.js",
"kiss-translator.user": paths.appSrc + "/userscript.js",
};

View File

@@ -1,5 +1,7 @@
# 自定义接口示例(本文档已过期,新版不再适用)
V2版的示例请查看这里[custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
以下示例为网友提供,仅供学习参考。
## 本地运行 Seed-X-PPO-7B 量化模型

295
custom-api_v2.md Normal file
View File

@@ -0,0 +1,295 @@
# 自定义接口示例
## 默认接口规范
如果接口的请求数据和返回数据符合以下规范,
则无需填写 `Request Hook``Response Hook`
Request body
```json
{
"texts": ["hello"], // 需要翻译的文本列表
"from":"auto", // 原文语言
"to": "zh-CN" // 目标语言
}
```
Response
```json
[
{
"text": "你好", // 译文
"src": "en" // 原文语言
}
]
```
v2.0.4版后亦支持以下 Response 格式
```json
{
"translations": [ // 译文列表
{
"text": "你好", // 译文
"src": "en" // 原文语言
}
]
}
```
## 谷歌翻译接口
> 此接口不支持聚合
URL
```
https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN
```
Request Hook
```js
async (args) => {
const url = args.url.replace("{{text}}", args.texts[0]);
const method = "GET";
return { url, method };
};
```
Response Hook
```js
async ({ res }) => {
return { translations: [[res?.sentences?.[0]?.trans || "", res?.src]] };
};
```
## Ollama
> 此示例为支持聚合的模型类(要支持上下文,需进一步改动)
* 注意 ollama 启动参数需要添加环境变量 `OLLAMA_ORIGINS=*`
* 检查环境变量生效命令:`systemctl show ollama | grep OLLAMA_ORIGINS`
URL
```
http://localhost:11434/v1/chat/completions
```
Request Hook
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = { "Content-type": "application/json" };
const body = {
model: "gemma3",
messages: [
{
role: "system",
content:
'Act as a translation API. Output a single raw JSON object only. No extra text or fences.\n\nInput:\n{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}\n\nOutput:\n{"translations":[{"id":1,"text":"...","sourceLanguage":"<detected>"}]}\n\nRules:\n1. Use title/description for context only; do not output them.\n2. Keep id, order, and count of segments.\n3. Preserve whitespace, HTML entities, and all HTML-like tags (e.g., <i1>, <a1>). Translate inner text only.\n4. Highest priority: Follow \'glossary\'. Use value for translation; if value is "", keep the key.\n5. Do not translate: content in <code>, <pre>, text enclosed in backticks, or placeholders like {1}, {{1}}, [1], [[1]].\n6. Apply the specified tone to the translation.\n7. Detect sourceLanguage for each segment.\n8. Return empty or unchanged inputs as is.\n\nExample:\nInput: {"targetLanguage":"zh-CN","segments":[{"id":1,"text":"A <b>React</b> component."}],"glossary":{"component":"组件","React":""}}\nOutput: {"translations":[{"id":1,"text":"一个<b>React</b>组件","sourceLanguage":"en"}]}\n\nFail-safe: On any error, return {"translations":[]}.',
},
{
role: "user",
content: JSON.stringify({
targetLanguage: args.to,
segments: args.texts.map((text, id) => ({ id, text })),
glossary: {},
}),
},
],
temperature: 0,
max_tokens: 20480,
think: false,
stream: false,
};
return { url, body, headers, method };
};
```
v2.0.2 Request Hook 可以简化为:
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = { "Content-type": "application/json" };
const body = {
model: "gemma3", // v2.0.2 版后此处可填 args.model
messages: [
{
role: "system",
content: args.defaultSystemPrompt, // 或者 args.systemPrompt
},
{
role: "user",
content: JSON.stringify({
targetLanguage: args.to,
segments: args.texts.map((text, id) => ({ id, text })),
glossary: {},
}),
},
],
temperature: 0,
max_tokens: 20480,
think: false,
stream: false,
};
return { url, body, headers, method };
};
```
Response Hook
```js
async ({ res }) => {
const extractJson = (raw) => {
const jsonRegex = /({.*}|\[.*\])/s;
const match = raw.match(jsonRegex);
return match ? match[0] : null;
};
const parseAIRes = (raw) => {
if (!raw) return [];
try {
const jsonString = extractJson(raw);
if (!jsonString) return [];
const data = JSON.parse(jsonString);
if (Array.isArray(data.translations)) {
return data.translations.map((item) => [
item?.text ?? "",
item?.sourceLanguage ?? "",
]);
}
} catch (err) {
console.log("parseAIRes", err);
}
return [];
};
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
return { translations };
};
```
v2.0.2 版后内置`parseAIRes`函数Response Hook 可以简化为:
```js
async ({ res, parseAIRes }) => {
const translations = parseAIRes(res?.choices?.[0]?.message?.content);
return { translations };
};
```
## 硅基流动
> 此示例为不支持聚合模型类,支持聚合的模型类参考上面 Ollama 的写法
URL
```
https://api.siliconflow.cn/v1/chat/completions
```
Request Hook
```js
async (args) => {
const url = args.url;
const method = "POST";
const headers = {
"Content-type": "application/json",
Authorization: `Bearer ${args.key}`,
};
const body = {
model: "tencent/Hunyuan-MT-7B", // v2.0.2 版后此处可填 args.model
messages: [
{
role: "system",
content:
"You are a professional, authentic machine translation engine.",
},
{
role: "user",
content: `Translate the following source text to ${args.to}. Output translation directly without any additional text.\n\nSource Text: ${args.texts[0]}\n\nTranslated Text:`,
},
],
temperature: 0,
max_tokens: 20480,
};
return { url, body, headers, method };
};
```
Response Hook
```js
async ({ res }) => {
return { translations: [[res?.choices?.[0]?.message?.content || ""]] };
};
```
## 语言代码表及说明
Hook参数里面的语言含义说明
- `toLang`, `fromLang` 是本插件支持的标准语言代码
- `to`, `from` 是转换后的适用于特定接口的语言代码
如果你的自定义接口与下面的标准语言代码不匹配,需要自行映射转换。
```
["en", "English - English"],
["zh-CN", "Simplified Chinese - 简体中文"],
["zh-TW", "Traditional Chinese - 繁體中文"],
["ar", "Arabic - العربية"],
["bg", "Bulgarian - Български"],
["ca", "Catalan - Català"],
["hr", "Croatian - Hrvatski"],
["cs", "Czech - Čeština"],
["da", "Danish - Dansk"],
["nl", "Dutch - Nederlands"],
["fi", "Finnish - Suomi"],
["fr", "French - Français"],
["de", "German - Deutsch"],
["el", "Greek - Ελληνικά"],
["hi", "Hindi - हिन्दी"],
["hu", "Hungarian - Magyar"],
["id", "Indonesian - Indonesia"],
["it", "Italian - Italiano"],
["ja", "Japanese - 日本語"],
["ko", "Korean - 한국어"],
["ms", "Malay - Melayu"],
["mt", "Maltese - Malti"],
["nb", "Norwegian - Norsk Bokmål"],
["pl", "Polish - Polski"],
["pt", "Portuguese - Português"],
["ro", "Romanian - Română"],
["ru", "Russian - Русский"],
["sk", "Slovak - Slovenčina"],
["sl", "Slovenian - Slovenščina"],
["es", "Spanish - Español"],
["sv", "Swedish - Svenska"],
["ta", "Tamil - தமிழ்"],
["te", "Telugu - తెలుగు"],
["th", "Thai - ไทย"],
["tr", "Turkish - Türkçe"],
["uk", "Ukrainian - Українська"],
["vi", "Vietnamese - Tiếng Việt"],
```

View File

@@ -1,7 +1,7 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "2.0.0",
"version": "2.0.5",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.0",
"version": "2.0.5",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -17,7 +17,8 @@
}
],
"web_accessible_resources": [
"injector.js"
"injector-subtitle.js",
"injector-shadowroot.js"
],
"commands": {
"_execute_browser_action": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.0",
"version": "2.0.5",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -19,8 +19,12 @@
],
"web_accessible_resources": [
{
"resources": ["injector.js"],
"resources": ["injector-subtitle.js"],
"matches": ["https://www.youtube.com/*"]
},
{
"resources": ["injector-shadowroot.js"],
"matches": ["<all_urls>"]
}
],
"commands": {

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.0",
"version": "2.0.5",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -23,7 +23,8 @@
}
],
"web_accessible_resources": [
"injector.js"
"injector-subtitle.js",
"injector-shadowroot.js"
],
"commands": {
"_execute_browser_action": {

View File

@@ -14,9 +14,9 @@ import {
MSG_BUILTINAI_TRANSLATE,
OPT_TRANS_BUILTINAI,
URL_CACHE_SUBTITLE,
OPT_LANGS_TO_CODE,
} from "../config";
import { sha256, withTimeout } from "../libs/utils";
import { kissLog } from "../libs/log";
import {
handleTranslate,
handleSubtitle,
@@ -106,7 +106,7 @@ export const apiMicrosoftLangdetect = async (text) => {
const key = `${URL_CACHE_DELANG}_${OPT_TRANS_MICROSOFT}`;
const queue = getBatchQueue(key, handleMicrosoftLangdetect, {
batchInterval: 500,
batchInterval: 200,
batchSize: 20,
batchLength: 100000,
});
@@ -424,7 +424,7 @@ export const apiTranslate = async ({
usePool = true,
}) => {
if (!text) {
return ["", false];
throw new Error("The text cannot be empty.");
}
const { apiType, apiSlug, useBatchFetch } = apiSetting;
@@ -432,8 +432,7 @@ export const apiTranslate = async ({
const from = langMap.get(fromLang);
const to = langMap.get(toLang);
if (!to) {
kissLog(`target lang: ${toLang} not support`);
return ["", false];
throw new Error(`The target lang: ${toLang} not support`);
}
// todo: 优化缓存失效因素
@@ -451,7 +450,7 @@ export const apiTranslate = async ({
if (useCache) {
const cache = await getHttpCachePolyfill(cacheInput);
if (cache?.trText) {
return [cache.trText, cache.isSame];
return cache;
}
}
@@ -499,8 +498,12 @@ export const apiTranslate = async ({
let trText = "";
let srLang = "";
let srCode = "";
if (Array.isArray(tranlation)) {
[trText, srLang = ""] = tranlation;
if (srLang) {
srCode = OPT_LANGS_TO_CODE[apiType].get(srLang) || "";
}
} else if (typeof tranlation === "string") {
trText = tranlation;
}
@@ -513,10 +516,10 @@ export const apiTranslate = async ({
// 插入缓存
if (useCache) {
putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang });
putHttpCachePolyfill(cacheInput, null, { trText, isSame, srLang, srCode });
}
return [trText, isSame];
return { trText, srLang, srCode, isSame };
};
// 字幕处理/翻译

View File

@@ -22,15 +22,19 @@ import {
API_SPE_TYPES,
INPUT_PLACE_FROM,
INPUT_PLACE_TO,
// INPUT_PLACE_TEXT,
INPUT_PLACE_TEXT,
INPUT_PLACE_KEY,
INPUT_PLACE_MODEL,
DEFAULT_USER_AGENT,
defaultSystemPrompt,
defaultSubtitlePrompt,
defaultNobatchPrompt,
defaultNobatchUserPrompt,
} from "../config";
import { msAuth } from "../libs/auth";
import { genDeeplFree } from "./deepl";
import { genBaidu } from "./baidu";
import interpreter from "../libs/interpreter";
import { interpreter } from "../libs/interpreter";
import { parseJsonObj, extractJson } from "../libs/utils";
import { kissLog } from "../libs/log";
import { fetchData } from "../libs/fetch";
@@ -64,42 +68,46 @@ const genSystemPrompt = ({ systemPrompt, from, to }) =>
.replaceAll(INPUT_PLACE_TO, to);
const genUserPrompt = ({
// userPrompt,
nobatchUserPrompt,
useBatchFetch,
tone,
glossary = {},
// from,
from,
to,
texts,
docInfo,
}) => {
const prompt = JSON.stringify({
targetLanguage: to,
title: docInfo.title,
description: docInfo.description,
segments: texts.map((text, i) => ({ id: i, text })),
glossary,
tone,
});
if (useBatchFetch) {
return JSON.stringify({
targetLanguage: to,
title: docInfo.title,
description: docInfo.description,
segments: texts.map((text, i) => ({ id: i, text })),
glossary,
tone,
});
}
// if (userPrompt.includes(INPUT_PLACE_TEXT)) {
// return userPrompt
// .replaceAll(INPUT_PLACE_FROM, from)
// .replaceAll(INPUT_PLACE_TO, to)
// .replaceAll(INPUT_PLACE_TEXT, prompt);
// }
return prompt;
return nobatchUserPrompt
.replaceAll(INPUT_PLACE_FROM, from)
.replaceAll(INPUT_PLACE_TO, to)
.replaceAll(INPUT_PLACE_TEXT, texts[0]);
};
const parseAIRes = (raw) => {
const parseAIRes = (raw, useBatchFetch = true) => {
if (!raw) {
return [];
}
if (!useBatchFetch) {
return [[raw]];
}
try {
const jsonString = extractJson(raw);
const data = JSON.parse(jsonString);
if (!jsonString) return [];
const data = JSON.parse(jsonString);
if (Array.isArray(data.translations)) {
// todo: 考虑序号id可能会打乱
return data.translations.map((item) => [
@@ -494,7 +502,7 @@ const genOpenRouter = ({
};
const genOllama = ({
think,
// think,
url,
key,
systemPrompt,
@@ -520,7 +528,7 @@ const genOllama = ({
],
temperature,
max_tokens: maxTokens,
think,
// think,
stream: false,
};
@@ -624,7 +632,10 @@ export const genTransReq = async ({ reqHook, ...args }) => {
apiSlug,
key,
systemPrompt,
userPrompt,
// userPrompt,
nobatchPrompt = defaultNobatchPrompt,
nobatchUserPrompt = defaultNobatchUserPrompt,
useBatchFetch,
from,
to,
texts,
@@ -644,11 +655,16 @@ export const genTransReq = async ({ reqHook, ...args }) => {
}
if (API_SPE_TYPES.ai.has(apiType)) {
args.systemPrompt = genSystemPrompt({ systemPrompt, from, to });
args.systemPrompt = genSystemPrompt({
systemPrompt: useBatchFetch ? systemPrompt : nobatchPrompt,
from,
to,
});
args.userPrompt = !!events
? JSON.stringify(events)
: genUserPrompt({
userPrompt,
nobatchUserPrompt,
useBatchFetch,
from,
to,
texts,
@@ -677,13 +693,16 @@ export const genTransReq = async ({ reqHook, ...args }) => {
if (reqHook?.trim() && !events) {
try {
interpreter.run(`exports.reqHook = ${reqHook}`);
const hookResult = await interpreter.exports.reqHook(args, {
url,
body,
headers,
userMsg,
method,
});
const hookResult = await interpreter.exports.reqHook(
{ ...args, defaultSystemPrompt, defaultSubtitlePrompt },
{
url,
body,
headers,
userMsg,
method,
}
);
if (hookResult && hookResult.url) {
return genInit(hookResult);
}
@@ -711,10 +730,11 @@ export const parseTransRes = async (
toLang,
langMap,
resHook,
thinkIgnore,
// thinkIgnore,
history,
userMsg,
apiType,
useBatchFetch,
}
) => {
// 执行 response hook
@@ -731,6 +751,8 @@ export const parseTransRes = async (
fromLang,
toLang,
langMap,
extractJson,
parseAIRes,
});
if (hookResult && Array.isArray(hookResult.translations)) {
if (history && userMsg && hookResult.modelMsg) {
@@ -803,13 +825,13 @@ export const parseTransRes = async (
content: modelMsg.content,
});
}
return parseAIRes(res?.choices?.[0]?.message?.content ?? "");
return parseAIRes(modelMsg?.content, useBatchFetch);
case OPT_TRANS_GEMINI:
modelMsg = res?.candidates?.[0]?.content;
if (history && userMsg && modelMsg) {
history.add(userMsg, modelMsg);
}
return parseAIRes(res?.candidates?.[0]?.content?.parts?.[0]?.text ?? "");
return parseAIRes(modelMsg?.parts?.[0]?.text ?? "", useBatchFetch);
case OPT_TRANS_CLAUDE:
modelMsg = { role: res?.role, content: res?.content?.text };
if (history && userMsg && modelMsg) {
@@ -818,18 +840,18 @@ export const parseTransRes = async (
content: modelMsg.content,
});
}
return parseAIRes(res?.content?.[0]?.text ?? "");
return parseAIRes(res?.content?.[0]?.text ?? "", useBatchFetch);
case OPT_TRANS_CLOUDFLAREAI:
return [[res?.result?.translated_text]];
case OPT_TRANS_OLLAMA:
modelMsg = res?.choices?.[0]?.message;
const deepModels = thinkIgnore
.split(",")
.filter((model) => model?.trim());
if (deepModels.some((model) => res?.model?.startsWith(model))) {
modelMsg?.content.replace(/<think>[\s\S]*<\/think>/i, "");
}
// const deepModels = thinkIgnore
// .split(",")
// .filter((model) => model?.trim());
// if (deepModels.some((model) => res?.model?.startsWith(model))) {
// modelMsg?.content.replace(/<think>[\s\S]*<\/think>/i, "");
// }
if (history && userMsg && modelMsg) {
history.add(userMsg, {
@@ -837,9 +859,9 @@ export const parseTransRes = async (
content: modelMsg.content,
});
}
return parseAIRes(modelMsg?.content);
return parseAIRes(modelMsg?.content, useBatchFetch);
case OPT_TRANS_CUSTOMIZE:
return res?.map((item) => [item.text, item.src]);
return (res?.translations ?? res)?.map((item) => [item.text, item.src]);
default:
}
@@ -911,7 +933,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, {
@@ -925,8 +947,8 @@ export const handleTranslate = async (
userMsg,
...apiSetting,
});
if (!Array.isArray(result)) {
throw new Error("tranlate got an unexpected result");
if (!result?.length) {
throw new Error("translate got an unexpected result");
}
return result;

View File

@@ -23,6 +23,7 @@ import {
CMD_OPEN_TRANBOX,
CLIENT_THUNDERBIRD,
MSG_SET_LOGLEVEL,
MSG_CLEAR_CACHES,
} from "./config";
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
import { trySyncSettingAndRules } from "./libs/sync";
@@ -32,7 +33,7 @@ import { sendTabMsg } from "./libs/msg";
import { trySyncAllSubRules } from "./libs/subRules";
import { saveRule } from "./libs/rules";
import { getCurTabId } from "./libs/msg";
import { injectInlineJs, injectInternalCss } from "./libs/injector";
import { injectInlineJsBg, injectInternalCss } from "./libs/injector";
import { kissLog, logger } from "./libs/log";
import { chromeDetect, chromeTranslate } from "./libs/builtinAI";
@@ -267,7 +268,7 @@ const messageHandlers = {
[MSG_PUT_HTTPCACHE]: (args) => putHttpCache(args),
[MSG_OPEN_OPTIONS]: () => browser.runtime.openOptionsPage(),
[MSG_SAVE_RULE]: (args) => saveRule(args),
[MSG_INJECT_JS]: (args) => injectToCurrentTab(injectInlineJs, args),
[MSG_INJECT_JS]: (args) => injectToCurrentTab(injectInlineJsBg, args),
[MSG_INJECT_CSS]: (args) => injectToCurrentTab(injectInternalCss, args),
[MSG_UPDATE_CSP]: (args) => updateCspRules(args),
[MSG_CONTEXT_MENUS]: (args) => addContextMenus(args),
@@ -275,6 +276,7 @@ const messageHandlers = {
[MSG_BUILTINAI_DETECT]: (args) => chromeDetect(args),
[MSG_BUILTINAI_TRANSLATE]: (args) => chromeTranslate(args),
[MSG_SET_LOGLEVEL]: (args) => logger.setLevel(args),
[MSG_CLEAR_CACHES]: () => tryClearCaches(),
};
/**
@@ -283,20 +285,11 @@ const messageHandlers = {
*/
browser.runtime.onMessage.addListener(async ({ action, args }) => {
const handler = messageHandlers[action];
if (!handler) {
const errorMessage = `Message action is unavailable: ${action}`;
kissLog("runtime onMessage", action, new Error(errorMessage));
return null;
throw new Error(`Message action is unavailable: ${action}`);
}
try {
const result = await handler(args);
return result;
} catch (err) {
kissLog("runtime onMessage", action, err);
return null;
}
return handler(args);
});
/**

View File

@@ -1,25 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Action from "./views/Action";
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import { OPT_HIGHLIGHT_WORDS_DISABLE } from "./config";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_PUTRULE,
APP_CONSTS,
} from "./config";
import { getFabWithDefault, getSettingWithDefault } from "./libs/storage";
import { Translator } from "./libs/translator";
import { isIframe, sendIframeMsg } from "./libs/iframe";
import { touchTapListener } from "./libs/touch";
import { debounce, genEventName } from "./libs/utils";
getFabWithDefault,
getSettingWithDefault,
getWordsWithDefault,
} from "./libs/storage";
import { isIframe } from "./libs/iframe";
import { genEventName } from "./libs/utils";
import { handlePing, injectScript } from "./libs/gm";
import { matchRule } from "./libs/rules";
import { trySyncAllSubRules } from "./libs/subRules";
import { isInBlacklist } from "./libs/blacklist";
import { runSubtitle } from "./subtitle/subtitle";
import { logger } from "./libs/log";
import { injectInlineJs } from "./libs/injector";
import TranslatorManager from "./libs/translatorManager";
/**
* 油猴脚本设置页面
@@ -35,68 +29,13 @@ function runSettingPage() {
const ping = genEventName();
window.addEventListener(ping, handlePing);
// window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line
const script = document.createElement("script");
script.textContent = `(${injectScript})("${ping}")`;
document.head.append(script);
injectInlineJs(
`(${injectScript})("${ping}")`,
"kiss-translator-options-injector"
);
}
}
/**
* iframe 页面执行
* @param {*} translator
*/
function runIframe(translator) {
window.addEventListener("message", (e) => {
const { action, args } = e.data || {};
switch (action) {
case MSG_TRANS_TOGGLE:
translator?.toggle();
break;
case MSG_TRANS_TOGGLE_STYLE:
translator?.toggleStyle();
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args || {});
break;
default:
}
});
}
/**
* 悬浮按钮
* @param {*} translator
* @returns
*/
async function showFab(translator) {
const fab = await getFabWithDefault();
const $action = document.createElement("div");
$action.id = APP_CONSTS.fabID;
$action.className = "notranslate";
$action.style.fontSize = "0";
$action.style.width = "0";
$action.style.height = "0";
document.body.parentElement.appendChild($action);
const shadowContainer = $action.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowRootElement.className = `${APP_CONSTS.fabID}_warpper notranslate`;
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: APP_CONSTS.fabID,
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Action translator={translator} fab={fab} />
</CacheProvider>
</React.StrictMode>
);
}
/**
* 显示错误信息到页面顶部
* @param {*} message
@@ -127,7 +66,7 @@ function showErr(message) {
});
const closeButton = document.createElement("span");
closeButton.innerHTML = "&times;";
closeButton.textContent = "×";
Object.assign(closeButton.style, {
position: "absolute",
@@ -159,22 +98,19 @@ function showErr(message) {
setTimeout(removeBanner, 10000);
}
/**
* 监听触屏操作
* @param {*} translator
* @returns
*/
function touchOperation(translator) {
const { touchTranslate = 2 } = translator.setting;
if (touchTranslate === 0) {
return;
async function getFavWords(rule) {
if (
rule.highlightWords &&
rule.highlightWords !== OPT_HIGHLIGHT_WORDS_DISABLE
) {
try {
return Object.keys(await getWordsWithDefault());
} catch (err) {
logger.info("get fav words", err);
}
}
const handleTap = debounce(() => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
});
touchTapListener(handleTap, touchTranslate);
return [];
}
/**
@@ -207,34 +143,28 @@ export async function run(isUserscript = false) {
// 翻译网页
const rule = await matchRule(href, setting);
const translator = new Translator(rule, setting, isUserscript);
const favWords = await getFavWords(rule);
const fabConfig = await getFabWithDefault();
const translatorManager = new TranslatorManager({
setting,
rule,
fabConfig,
favWords,
isIframe,
isUserscript,
});
translatorManager.start();
// 适配iframe
if (isIframe) {
runIframe(translator);
return;
}
// 字幕翻译
runSubtitle({ href, setting, rule });
runSubtitle({ href, setting, rule, isUserscript });
// 监听消息
// !isUserscript && runtimeListener(translator);
// 输入框翻译
// inputTranslate(setting);
// 划词翻译
// showTransbox(setting, rule);
// 浮球按钮
await showFab(translator);
// 触屏操作
touchOperation(translator);
// 同步订阅规则
isUserscript && (await trySyncAllSubRules(setting));
if (isUserscript) {
trySyncAllSubRules(setting);
}
} catch (err) {
console.error("[KISS-Translator]", err);
showErr(err.message);

View File

@@ -1,7 +1,7 @@
export const DEFAULT_HTTP_TIMEOUT = 10000; // 调用超时时间
export const DEFAULT_FETCH_LIMIT = 10; // 默认最大任务数量
export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
export const DEFAULT_BATCH_INTERVAL = 1000; // 批处理请求间隔时间
export const DEFAULT_BATCH_INTERVAL = 400; // 批处理请求间隔时间
export const DEFAULT_BATCH_SIZE = 10; // 每次最多发送段落数量
export const DEFAULT_BATCH_LENGTH = 10000; // 每次发送最大文字数量
export const DEFAULT_CONTEXT_SIZE = 3; // 上下文会话数量
@@ -46,7 +46,7 @@ export const OPT_TRANS_OPENROUTER = "OpenRouter";
export const OPT_TRANS_CUSTOMIZE = "Custom";
// 内置支持的翻译引擎
export const OPT_ALL_TYPES = [
export const OPT_ALL_TRANS_TYPES = [
OPT_TRANS_BUILTINAI,
OPT_TRANS_GOOGLE,
OPT_TRANS_GOOGLE_2,
@@ -82,7 +82,7 @@ export const OPT_LANGDETECTOR_MAP = new Set(OPT_LANGDETECTOR_ALL);
// 翻译引擎特殊集合
export const API_SPE_TYPES = {
// 内置翻译
builtin: new Set(OPT_ALL_TYPES),
builtin: new Set(OPT_ALL_TRANS_TYPES),
// 机器翻译
machine: new Set([
OPT_TRANS_MICROSOFT,
@@ -340,7 +340,10 @@ Object.entries(OPT_LANGS_TO_SPEC).forEach(([t, m]) => {
OPT_LANGS_TO_CODE[t] = specToCode(m);
});
const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences.
export const defaultNobatchPrompt = `You are a professional, authentic machine translation engine.`;
export const defaultNobatchUserPrompt = `Translate the following source text from ${INPUT_PLACE_FROM} to ${INPUT_PLACE_TO}. Output translation directly without any additional text.\n\nSource Text: ${INPUT_PLACE_TEXT}\n\nTranslated Text:`;
export const defaultSystemPrompt = `Act as a translation API. Output a single raw JSON object only. No extra text or fences.
Input:
{"targetLanguage":"<lang>","title":"<context>","description":"<context>","segments":[{"id":1,"text":"..."}],"glossary":{"sourceTerm":"targetTerm"},"tone":"<formal|casual>"}
@@ -381,7 +384,7 @@ Fail-safe: On any error, return {"translations":[]}.`;
// 4. **Special Cases**: '[Music]' (and similar cues) are standalone entries. Translate appropriately (e.g., '[音乐]', '[Musique]').
// `;
const defaultSubtitlePrompt = `You are an expert AI for subtitle generation. Convert a JSON array of word-level timestamps into a bilingual VTT file.
export const defaultSubtitlePrompt = `You are an expert AI for subtitle generation. Convert a JSON array of word-level timestamps into a bilingual VTT file.
**Workflow:**
1. Merge \`text\` fields into complete sentences; ignore empty text.
@@ -409,16 +412,16 @@ Good morning.
\`\`\``;
const defaultRequestHook = `async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args);
console.log("request hook args:", { args, url, body, headers, userMsg, method });
// return { url, body, headers, userMsg, method };
}`;
};`;
const defaultResponseHook = `async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args);
console.log("reaponse hook args:", { res, args });
// const translations = [["你好", "zh"]];
// const modelMsg = "";
// return { translations, modelMsg };
}`;
};`;
// 翻译接口默认参数
const defaultApi = {
@@ -430,6 +433,8 @@ const defaultApi = {
model: "", // 模型名称
systemPrompt: defaultSystemPrompt,
subtitlePrompt: defaultSubtitlePrompt,
nobatchPrompt: defaultNobatchPrompt,
nobatchUserPrompt: defaultNobatchUserPrompt,
userPrompt: "",
tone: BUILTIN_STONES[0], // 翻译风格
placeholder: BUILTIN_PLACEHOLDERS[0], // 占位符
@@ -448,10 +453,10 @@ const defaultApi = {
useBatchFetch: false, // 是否启用聚合发送请求
useContext: false, // 是否启用智能上下文
contextSize: DEFAULT_CONTEXT_SIZE, // 智能上下文保留会话数
temperature: 0,
temperature: 0.0,
maxTokens: 20480,
think: false,
thinkIgnore: "qwen3,deepseek-r1",
// think: false, // (OpenAI 兼容接口未支持,暂时移除)
// thinkIgnore: "qwen3,deepseek-r1", // (OpenAI 兼容接口未支持,暂时移除)
isDisabled: false, // 是否不显示,
region: "", // Azure 专用
};
@@ -499,7 +504,6 @@ const defaultApiOpts = {
[OPT_TRANS_DEEPLX]: {
...defaultApi,
url: "http://localhost:1188/translate",
fetchLimit: 1,
},
[OPT_TRANS_NIUTRANS]: {
...defaultApi,
@@ -512,7 +516,6 @@ const defaultApiOpts = {
url: "https://api.openai.com/v1/chat/completions",
model: "gpt-4",
useBatchFetch: true,
fetchLimit: 1,
},
[OPT_TRANS_GEMINI]: {
...defaultApi,
@@ -557,7 +560,7 @@ const defaultApiOpts = {
};
// 内置翻译接口列表(带参数)
export const DEFAULT_API_LIST = OPT_ALL_TYPES.map((apiType) => ({
export const DEFAULT_API_LIST = OPT_ALL_TRANS_TYPES.map((apiType) => ({
...defaultApiOpts[apiType],
apiSlug: apiType,
apiName: apiType,
@@ -565,4 +568,6 @@ export const DEFAULT_API_LIST = OPT_ALL_TYPES.map((apiType) => ({
}));
export const DEFAULT_API_TYPE = OPT_TRANS_MICROSOFT;
export const DEFAULT_API_SETTING = DEFAULT_API_LIST[DEFAULT_API_TYPE];
export const DEFAULT_API_SETTING = DEFAULT_API_LIST.find(
(a) => a.apiType === DEFAULT_API_TYPE
);

View File

@@ -2,9 +2,11 @@ export const APP_NAME = process.env.REACT_APP_NAME.trim()
.split(/\s+/)
.join("-");
export const APP_LCNAME = APP_NAME.toLowerCase();
export const APP_UPNAME = APP_NAME.toUpperCase();
export const APP_CONSTS = {
fabID: `${APP_LCNAME}-fab`,
boxID: `${APP_LCNAME}-box`,
popupID: `${APP_LCNAME}-popup`,
};
export const APP_VERSION = process.env.REACT_APP_VERSION.split(".");

View File

@@ -137,48 +137,44 @@ ${customApiLangs}
`;
const requestHookHelperZH = `1、第一个参数包含如下字段'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...
2、返回值必须是包含以下字段的对象 'url', 'body', 'headers', 'userMsg', 'method'
2、返回值必须是包含以下字段的对象 'url', 'body', 'headers', 'method'
3、如返回空值则hook函数不会产生任何效果。
// 示例
async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args);
return { url, body, headers, userMsg, method };
}`;
const requestHookHelperEN = `1. The first parameter contains the following fields: 'texts', 'from', 'to', 'url', 'key', 'model', 'systemPrompt', ...
2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'userMsg', 'method'
2. The return value must be an object containing the following fields: 'url', 'body', 'headers', 'method'
3. If a null value is returned, the hook function will have no effect.
// Example
async (args, { url, body, headers, userMsg, method } = {}) => {
console.log("request hook args:", args);
return { url, body, headers, userMsg, method };
}`;
const responsetHookHelperZH = `1、第一个参数包含如下字段'res', ...
2、返回值必须是包含以下字段的对象 'translations', 'modelMsg'
'translations' 应为一个二维数组:[[译文, 语言]]
2、返回值必须是包含以下字段的对象 'translations'
'translations' 应为一个二维数组:[[译文, 原文语言]]
3、如返回空值则hook函数不会产生任何效果。
// 示例
async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args);
const translations = [["你好", "zh"]];
const modelMsg = "";
const translations = [["你好", "en"]];
const modelMsg = {}; // 用于AI上下文
return { translations, modelMsg };
}`;
const responsetHookHelperEN = `1. The first parameter contains the following fields: 'res', ...
2. The return value must be an object containing the following fields: 'translations', 'modelMsg'
2. The return value must be an object containing the following fields: 'translations'
('translations' should be a two-dimensional array: [[translation, source language]]).
3. If a null value is returned, the hook function will have no effect.
// Example
async ({ res, ...args }) => {
console.log("reaponse hook args:", res, args);
const translations = [["你好", "zh"]];
const modelMsg = "";
const translations = [["你好", "en"]];
const modelMsg = {}; // For AI context
return { translations, modelMsg };
}`;
@@ -535,9 +531,9 @@ export const I18N = {
zh_TW: `2.大部分AI介面都與OpenAI相容因此選擇新增OpenAI類型即可。`,
},
about_api_3: {
zh: `2、暂未列出的接口,理论上都可以通过自定义接口 (Custom) 的形式支持。`,
en: `2. Interfaces that have not yet been launched can theoretically be supported through custom interfaces.`,
zh_TW: `2、暫未列出的介面,理論上都可透過自訂介面 (Custom) 的形式支援。`,
zh: `3、暂未列出的接口,理论上都可以通过自定义接口 (Custom) 的形式支持。`,
en: `3. Interfaces that have not yet been launched can theoretically be supported through custom interfaces.`,
zh_TW: `3、暫未列出的介面,理論上都可透過自訂介面 (Custom) 的形式支援。`,
},
about_api_proxy: {
zh: `查看自建一个翻译接口代理`,
@@ -718,6 +714,16 @@ export const I18N = {
en: `Selector Style`,
zh_TW: `選擇器節點樣式`,
},
terms_style: {
zh: `专业术语样式`,
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.`,
@@ -739,9 +745,33 @@ export const I18N = {
zh_TW: `注入 JS`,
},
inject_js_helper: {
zh: `初始化时注入运行,一个页面仅运行一次。`,
en: `Injected and run at initialization, and only run once per page.`,
zh_TW: `初始化時注入運行,一個頁面僅運行一次。`,
zh: `预加载时注入,一个页面仅运行一次。内置全局对象 KT: {
apiTranslate,
apiDectect,
apiSetting,
apisMap,
toLang,
docInfo,
glossary,
}`,
en: `Injected during preload, runs only once per page. Built-in global object KT: {
apiTranslate,
apiDectect,
apiSetting,
apisMap,
toLang,
docInfo,
glossary,
}`,
zh_TW: `預先載入時注入,一個頁面僅運行一次。內建全域物件 KT: {
apiTranslate,
apiDectect,
apiSetting,
apisMap,
toLang,
docInfo,
glossary,
}`,
},
inject_css: {
zh: `注入CSS`,
@@ -1154,9 +1184,9 @@ export const I18N = {
zh_TW: `觸控設定`,
},
touch_translate_shortcut: {
zh: `触屏翻译快捷方式`,
en: `Touch Translate Shortcut`,
zh_TW: `觸控翻譯捷徑`,
zh: `触屏翻译快捷方式 (支持多选)`,
en: `Touch Translate Shortcut (multiple supported)`,
zh_TW: `觸控翻譯捷徑 (支援多選)`,
},
touch_tap_0: {
zh: `禁用`,
@@ -1178,6 +1208,21 @@ export const I18N = {
en: `Four finger tap`,
zh_TW: `四指輕觸`,
},
touch_tap_5: {
zh: `单指双击`,
en: `Double-click`,
zh_TW: `單指雙擊`,
},
touch_tap_6: {
zh: `单指三击`,
en: `Triple-click`,
zh_TW: `單指三擊`,
},
touch_tap_7: {
zh: `双指双击`,
en: `Two-finger double-click`,
zh_TW: `雙指雙擊`,
},
translate_blacklist: {
zh: `禁用翻译名单`,
en: `Translate Blacklist`,
@@ -1328,15 +1373,35 @@ export const I18N = {
en: `Transbox Follow Selection`,
zh_TW: `翻譯框跟隨選取文字`,
},
tranbox_auto_height: {
zh: `翻译框自适应高度`,
en: `Translation box adaptive height`,
zh_TW: `翻譯框自適應高度`,
},
translate_start_hook: {
zh: `翻译开始钩子函数`,
en: `Translate Start Hook`,
zh_TW: `翻譯開始 Hook`,
},
translate_start_hook_helper: {
zh: `翻译前时运行,入参为: ({hostNode, parentNode, nodes})`,
en: `Run before translation, input parameters are: ({hostNode, parentNode, nodes})`,
zh_TW: `翻譯前時運行,入參為: ({hostNode, parentNode, nodes})`,
zh: `翻译前时运行,入参为: {text,
fromLang,
toLang,
apiSetting,
docInfo,
glossary,}`,
en: `Run before translation, input parameters are: {text,
fromLang,
toLang,
apiSetting,
docInfo,
glossary,}`,
zh_TW: `翻譯前時運行,入參為: {text,
fromLang,
toLang,
apiSetting,
docInfo,
glossary,}`,
},
translate_end_hook: {
zh: `翻译完成钩子函数`,
@@ -1583,6 +1648,11 @@ export const I18N = {
en: `Enable bilingual display`,
zh_TW: `雙語顯示`,
},
is_skip_ad: {
zh: `是否快进广告`,
en: `Should I fast forward to the ad?`,
zh_TW: `是否快轉廣告`,
},
background_styles: {
zh: `背景样式`,
en: `DBackground Style`,
@@ -1609,8 +1679,8 @@ export const I18N = {
zh_TW: `AI处理切割长度(200-20000)`,
},
subtitle_helper_1: {
zh: `1、目前仅支持Youtube桌面网站,且仅支持浏览器扩展`,
en: `1. Currently only supports Youtube desktop website and browser extension.`,
zh: `1、目前仅支持Youtube桌面网站。`,
en: `1. Currently only supports Youtube desktop website.`,
zh_TW: `1.目前僅支援Youtube桌面網站且僅支援瀏覽器擴充功能。`,
},
subtitle_helper_2: {
@@ -1663,6 +1733,62 @@ export const I18N = {
en: `Log Level`,
zh_TW: `日誌等級`,
},
goto_custom_api_example: {
zh: `点击查看【自定义接口示例】`,
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: `翻譯後高亮`,
},
pagescroll_root_margin: {
zh: `滚动加载提前触发 (0-10000px)`,
en: `Early triggering of scroll loading (0-10000px)`,
zh_TW: `滾動載入提前觸發 (0-10000px)`,
},
};
export const i18n = (lang) => (key) => I18N[key]?.[lang] || "";
export const newI18n = (lang) => (key) => I18N[key]?.[lang] || "";

View File

@@ -3,7 +3,7 @@ export const CMD_TOGGLE_STYLE = "toggleStyle";
export const CMD_OPEN_OPTIONS = "openOptions";
export const CMD_OPEN_TRANBOX = "openTranbox";
export const MSG_FETCH = "fetch";
export const MSG_FETCH = "kiss_fetch";
export const MSG_GET_HTTPCACHE = "get_httpcache";
export const MSG_PUT_HTTPCACHE = "put_httpcache";
export const MSG_OPEN_OPTIONS = "open_options";
@@ -15,6 +15,7 @@ export const MSG_TRANS_GETRULE = "trans_getrule";
export const MSG_TRANS_PUTRULE = "trans_putrule";
export const MSG_TRANS_CURRULE = "trans_currule";
export const MSG_TRANSBOX_TOGGLE = "transbox_toggle";
export const MSG_POPUP_TOGGLE = "popup_toggle";
export const MSG_MOUSEHOVER_TOGGLE = "mousehover_toggle";
export const MSG_TRANSINPUT_TOGGLE = "transinput_toggle";
export const MSG_CONTEXT_MENUS = "context_menus";
@@ -25,6 +26,9 @@ export const MSG_UPDATE_CSP = "update_csp";
export const MSG_BUILTINAI_DETECT = "builtinai_detect";
export const MSG_BUILTINAI_TRANSLATE = "builtinai_translte";
export const MSG_SET_LOGLEVEL = "set_loglevel";
export const MSG_CLEAR_CACHES = "clear_caches";
export const EVENT_KISS = "event_kiss_translate";
export const MSG_XHR_DATA_YOUTUBE = "KISS_XHR_DATA_YOUTUBE";
// export const MSG_GLOBAL_VAR_FETCH = "KISS_GLOBAL_VAR_FETCH";

View File

@@ -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,
@@ -78,9 +96,8 @@ background: linear-gradient(
export const DEFAULT_SELECTOR =
"h1, h2, h3, h4, h5, h6, li, p, dd, blockquote, figcaption, label, legend";
export const DEFAULT_IGNORE_SELECTOR =
"aside, button, footer, form, pre, mark, nav";
export const DEFAULT_KEEP_SELECTOR = `a:has(code)`;
export const DEFAULT_IGNORE_SELECTOR = "button, footer, pre, mark, nav";
export const DEFAULT_KEEP_SELECTOR = `code, cite, math, .math, a:has(code)`;
export const DEFAULT_RULE = {
pattern: "", // 匹配网址
selector: "", // 选择器
@@ -94,11 +111,13 @@ export const DEFAULT_RULE = {
transOpen: GLOBAL_KEY, // 开启翻译
bgColor: "", // 译文颜色
textDiyStyle: "", // 自定义译文样式
termsStyle: "", // 专业术语样式
highlightStyle: "", // 高亮词汇样式
selectStyle: "", // 选择器节点样式
parentStyle: "", // 选择器父节点样式
grandStyle: "", // 选择器父节点样式
injectJs: "", // 注入JS
injectCss: "", // 注入CSS
// injectCss: "", // 注入CSS (作废)
transOnly: GLOBAL_KEY, // 是否仅显示译文
// transTiming: GLOBAL_KEY, // 翻译时机/鼠标悬停翻译 (暂时作废)
transTag: GLOBAL_KEY, // 译文元素标签
@@ -116,6 +135,9 @@ export const DEFAULT_RULE = {
hasShadowroot: GLOBAL_KEY, // 是否包含shadowroot
rootsSelector: "", // 翻译范围选择器
ignoreSelector: "", // 不翻译的选择器
splitParagraph: GLOBAL_KEY, // 切分段落
splitLength: 0, // 切分段落长度
highlightWords: GLOBAL_KEY, // 高亮词汇
};
// 全局规则
@@ -132,11 +154,13 @@ export const GLOBLA_RULE = {
transOpen: "false", // 开启翻译
bgColor: "", // 译文颜色
textDiyStyle: DEFAULT_DIY_STYLE, // 自定义译文样式
termsStyle: "font-weight: bold;", // 专业术语样式
highlightStyle: "color: red;", // 高亮词汇样式
selectStyle: DEFAULT_SELECT_STYLE, // 选择器节点样式
parentStyle: DEFAULT_SELECT_STYLE, // 选择器父节点样式
grandStyle: DEFAULT_SELECT_STYLE, // 选择器祖节点样式
injectJs: "", // 注入JS
injectCss: "", // 注入CSS
// injectCss: "", // 注入CSS(作废)
transOnly: "false", // 是否仅显示译文
// transTiming: OPT_TIMING_PAGESCROLL, // 翻译时机/鼠标悬停翻译 (暂时作废)
transTag: DEFAULT_TRANS_TAG, // 译文元素标签
@@ -154,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];
@@ -170,34 +197,44 @@ export const DEFAULT_OW_RULE = {
// todo: 校验几个内置规则
const RULES_MAP = {
"www.google.com/search": {
rootsSelector: `#rcnt`,
},
// "www.google.com/search": {
// rootsSelector: `#rcnt`,
// },
"en.wikipedia.org": {
ignoreSelector: `.button, code, footer, form, mark, pre, .mwe-math-element, .mw-editsection`,
},
"news.ycombinator.com": {
selector: `p, .titleline, .commtext`,
rootsSelector: `#bigbox`,
selector: `p, .titleline, .commtext, .hn-item-title, .hn-comment-text, .hn-story-title`,
keepSelector: `code, img, svg, pre, .sitebit`,
ignoreSelector: `button, code, footer, form, header, mark, nav, pre, .reply`,
autoScan: `false`,
},
"twitter.com, https://x.com": {
selector: `[data-testid='tweetText']`,
keepSelector: `img, svg, span:has(a), div:has(a)`,
keepSelector: `img, svg, a, span:has(a), div:has(a)`,
ignoreSelector: `button, [data-testid='videoPlayer'], [role='group']`,
autoScan: `false`,
},
"www.youtube.com/live_chat": {
rootsSelector: `div#items`,
selector: `span.yt-live-chat-text-message-renderer`,
autoScan: `false`,
},
"www.youtube.com": {
rootsSelector: `ytd-page-manager`,
ignoreSelector: `aside, button, footer, form, header, pre, mark, nav, #player, #container, .caption-window, .ytp-settings-menu`,
},
"web.telegram.org": {
autoScan: `false`,
selector: ".text-content, .embedded-text-wrapper",
rootsSelector: ".Transition",
},
};
export const BUILTIN_RULES = Object.entries(RULES_MAP)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([pattern, rule]) => ({
export const BUILTIN_RULES = Object.entries(RULES_MAP).map(
([pattern, rule]) => ({
// ...DEFAULT_RULE,
...rule,
pattern,
}));
})
);

View File

@@ -88,6 +88,7 @@ export const DEFAULT_TRANBOX_SETTING = {
hideClickAway: false, // 是否点击外部关闭弹窗
simpleStyle: false, // 是否简洁界面
followSelection: false, // 翻译框是否跟随选中文本
autoHeight: false, // 自适应高度
triggerMode: OPT_TRANBOX_TRIGGER_CLICK, // 触发翻译方式
// extStyles: "", // 附加样式
enDict: OPT_DICT_BING, // 英文词典
@@ -113,6 +114,7 @@ export const DEFAULT_SUBTITLE_SETTING = {
// fromLang: "en",
toLang: "zh-CN",
isBilingual: true, // 是否双语显示
skipAd: false, // 是否快进广告
windowStyle: SUBTITLE_WINDOW_STYLE, // 背景样式
originStyle: SUBTITLE_ORIGIN_STYLE, // 原文样式
translationStyle: SUBTITLE_TRANSLATION_STYLE, // 译文样式
@@ -134,14 +136,14 @@ export const DEFAULT_SUBRULES_LIST = [
},
];
export const DEFAULT_MOUSEHOVER_KEY = ["KeyQ"];
export const DEFAULT_MOUSEHOVER_KEY = ["ControlLeft"];
export const DEFAULT_MOUSE_HOVER_SETTING = {
useMouseHover: true, // 是否启用鼠标悬停翻译
useMouseHover: false, // 是否启用鼠标悬停翻译
mouseHoverKey: DEFAULT_MOUSEHOVER_KEY, // 鼠标悬停翻译组合键
};
export const DEFAULT_SETTING = {
darkMode: false, // 深色模式
darkMode: "auto", // 深色模式
uiLang: "en", // 界面语言
// fetchLimit: DEFAULT_FETCH_LIMIT, // 最大任务数量(移至rule作废)
// fetchInterval: DEFAULT_FETCH_INTERVAL, // 任务间隔时间(移至rule作废)
@@ -166,7 +168,8 @@ export const DEFAULT_SETTING = {
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
touchTranslate: 2, // 触屏翻译
// touchTranslate: 2, // 触屏翻译 {5:单指双击6:单指三击7:双指双击} (作废)
touchModes: [2], // 触屏翻译 {5:单指双击6:单指三击7:双指双击} (多选)
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
csplist: DEFAULT_CSPLIST.join(",\n"), // 禁用CSP名单
orilist: DEFAULT_ORILIST.join(",\n"), // 禁用CSP名单
@@ -179,4 +182,5 @@ export const DEFAULT_SETTING = {
transAllnow: false, // 是否立即全部翻译
subtitleSetting: DEFAULT_SUBTITLE_SETTING, // 字幕设置
logLevel: LogLevel.INFO.value, // 日志级别
rootMargin: 500, // 提前触发翻译
};

View File

@@ -16,6 +16,7 @@ export const STOKEY_RULES = `${APP_NAME}_rules_v${APP_VERSION[0]}`;
export const STOKEY_WORDS = `${APP_NAME}_words`;
export const STOKEY_SYNC = `${APP_NAME}_sync`;
export const STOKEY_FAB = `${APP_NAME}_fab`;
export const STOKEY_TRANBOX = `${APP_NAME}_tranbox`;
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
export const CACHE_NAME = `${APP_NAME}_cache`;

View File

@@ -59,7 +59,11 @@ export function AlertProvider({ children }) {
onClose={handleClose}
anchorOrigin={{ vertical, horizontal }}
>
<Alert onClose={handleClose} severity={severity} sx={{ width: "100%" }}>
<Alert
onClose={handleClose}
severity={severity}
sx={{ minWidth: "300px", maxWidth: "80%" }}
>
{message}
</Alert>
</Snackbar>

View File

@@ -12,7 +12,12 @@ export function useDarkMode() {
} = useSetting();
const toggleDarkMode = useCallback(() => {
updateSetting({ darkMode: !darkMode });
const nextMode = {
light: "dark",
dark: "auto",
auto: "light",
};
updateSetting({ darkMode: nextMode[darkMode] || "light" });
}, [darkMode, updateSetting]);
return { darkMode, toggleDarkMode };

View File

@@ -1,16 +1,25 @@
import { STOKEY_WORDS, KV_WORDS_KEY } from "../config";
import { useCallback, useMemo } from "react";
import { useStorage } from "./Storage";
import { debounceSyncMeta } from "../libs/storage";
const DEFAULT_FAVWORDS = {};
export function useFavWords() {
const { data: favWords, save } = useStorage(
const { data: favWords, save: saveWords } = useStorage(
STOKEY_WORDS,
DEFAULT_FAVWORDS,
KV_WORDS_KEY
);
const save = useCallback(
(objOrFn) => {
saveWords(objOrFn);
debounceSyncMeta(KV_WORDS_KEY);
},
[saveWords]
);
const toggleFav = useCallback(
(word) => {
save((prev) => {

View File

@@ -2,18 +2,27 @@ import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
import { useStorage } from "./Storage";
import { checkRules } from "../libs/rules";
import { useCallback } from "react";
import { debounceSyncMeta } from "../libs/storage";
/**
* 规则 hook
* @returns
*/
export function useRules() {
const { data: list = [], save } = useStorage(
const { data: list = [], save: saveRules } = useStorage(
STOKEY_RULES,
DEFAULT_RULES,
KV_RULES_KEY
);
const save = useCallback(
(objOrFn) => {
saveRules(objOrFn);
debounceSyncMeta(KV_RULES_KEY);
},
[saveRules]
);
const add = useCallback(
(rule) => {
save((prev) => {
@@ -48,13 +57,9 @@ export function useRules() {
const put = useCallback(
(pattern, obj) => {
save((prev) => {
if (
prev.some(
(item) => item.pattern === obj.pattern && item.pattern !== pattern
)
) {
return prev;
}
// if (pattern !== obj.pattern) {
// return prev;
// }
return prev.map((item) =>
item.pattern === pattern ? { ...item, ...obj } : item
);
@@ -71,15 +76,26 @@ export function useRules() {
return prev;
}
const map = new Map();
// 不进行深度合并
// [...prev, ...adds].forEach((item) => {
// const k = item.pattern;
// map.set(k, { ...(map.get(k) || {}), ...item });
// });
prev.forEach((item) => map.set(item.pattern, item));
adds.forEach((item) => map.set(item.pattern, item));
return [...map.values()];
// const map = new Map();
// // 不进行深度合并
// // [...prev, ...adds].forEach((item) => {
// // const k = item.pattern;
// // map.set(k, { ...(map.get(k) || {}), ...item });
// // });
// prev.forEach((item) => map.set(item.pattern, item));
// adds.forEach((item) => map.set(item.pattern, item));
// return [...map.values()];
const addsMap = new Map(adds.map((item) => [item.pattern, item]));
const prevPatterns = new Set(prev.map((item) => item.pattern));
const updatedPrev = prev.map(
(prevItem) => addsMap.get(prevItem.pattern) || prevItem
);
const newItems = adds.filter(
(addItem) => !prevPatterns.has(addItem.pattern)
);
return [...newItems, ...updatedPrev];
});
},
[save]

View File

@@ -17,6 +17,7 @@ import { debounceSyncMeta } from "../libs/storage";
import Loading from "./Loading";
import { logger } from "../libs/log";
import { sendBgMsg } from "../libs/msg";
import { isExt } from "../libs/client";
const SettingContext = createContext({
setting: DEFAULT_SETTING,
@@ -32,11 +33,22 @@ export function SettingProvider({ children }) {
reload,
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
useEffect(() => {
if (typeof setting?.darkMode === "boolean") {
update((currentSetting) => ({
...currentSetting,
darkMode: currentSetting.darkMode ? "dark" : "light",
}));
}
}, [setting?.darkMode, update]);
useEffect(() => {
(async () => {
try {
logger.setLevel(setting?.logLevel);
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
if (isExt) {
await sendBgMsg(MSG_SET_LOGLEVEL, setting?.logLevel);
}
} catch (error) {
logger.error("Failed to fetch log level, using default.", error);
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { CssBaseline, GlobalStyles } from "@mui/material";
import { useDarkMode } from "./ColorMode";
@@ -11,6 +11,21 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
*/
export default function Theme({ children, options, styles }) {
const { darkMode } = useDarkMode();
const [systemMode, setSystemMode] = useState(THEME_LIGHT);
useEffect(() => {
if (typeof window.matchMedia !== "function") {
return;
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setSystemMode(mediaQuery.matches ? THEME_DARK : THEME_LIGHT);
};
handleChange(); // Set initial value
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
const theme = useMemo(() => {
let htmlFontSize = 16;
try {
@@ -23,16 +38,19 @@ export default function Theme({ children, options, styles }) {
//
}
const isDarkMode =
darkMode === "dark" || (darkMode === "auto" && systemMode === THEME_DARK);
return createTheme({
palette: {
mode: darkMode ? THEME_DARK : THEME_LIGHT,
mode: isDarkMode ? THEME_DARK : THEME_LIGHT,
},
typography: {
htmlFontSize,
},
...options,
});
}, [darkMode, options]);
}, [darkMode, options, systemMode]);
return (
<ThemeProvider theme={theme}>

View File

@@ -1,8 +1,16 @@
import { useState, useEffect } from "react";
import TextField from "@mui/material/TextField";
import { limitNumber } from "../libs/utils";
import { limitNumber, limitFloat } from "../libs/utils";
function ValidationInput({ value, onChange, name, min, max, ...props }) {
function ValidationInput({
value,
onChange,
name,
min,
max,
isFloat = false,
...props
}) {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
@@ -21,7 +29,9 @@ function ValidationInput({ value, onChange, name, min, max, ...props }) {
return;
}
const validatedValue = limitNumber(numValue, min, max);
const validatedValue = isFloat
? limitFloat(numValue, min, max)
: limitNumber(numValue, min, max);
if (validatedValue !== numValue) {
setLocalValue(validatedValue);

29
src/hooks/WindowSize.js Normal file
View File

@@ -0,0 +1,29 @@
import { useState, useEffect } from "react";
import { useDebouncedCallback } from "./DebouncedCallback";
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
w: window.innerWidth,
h: window.innerHeight,
});
const debounceWindowResize = useDebouncedCallback(() => {
setWindowSize({
w: window.innerWidth,
h: window.innerHeight,
});
}, 200);
useEffect(() => {
debounceWindowResize();
window.addEventListener("resize", debounceWindowResize);
return () => {
window.removeEventListener("resize", debounceWindowResize);
};
}, [debounceWindowResize]);
return windowSize;
}
export default useWindowSize;

View File

@@ -0,0 +1,3 @@
import { shadowRootInjector } from "./injectors/shadowroot";
shadowRootInjector();

3
src/injector-subtitle.js Normal file
View File

@@ -0,0 +1,3 @@
import { XMLHttpRequestInjector } from "./injectors/xmlhttp";
XMLHttpRequestInjector();

View File

@@ -1,21 +0,0 @@
import { MSG_XHR_DATA_YOUTUBE } from "./config";
(function () {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: MSG_XHR_DATA_YOUTUBE,
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
})();

27
src/injectors/index.js Normal file
View File

@@ -0,0 +1,27 @@
import { browser } from "../libs/browser";
import { isExt } from "../libs/client";
import { injectExternalJs, injectInlineJs } from "../libs/injector";
import { shadowRootInjector } from "./shadowroot";
import { XMLHttpRequestInjector } from "./xmlhttp";
export const INJECTOR = {
subtitle: "injector-subtitle.js",
shadowroot: "injector-shadowroot.js",
};
const injectorMap = {
[INJECTOR.subtitle]: XMLHttpRequestInjector,
[INJECTOR.shadowroot]: shadowRootInjector,
};
export function injectJs(name, id = "kiss-translator-inject-js") {
const injector = injectorMap[name];
if (!injector) return;
if (isExt) {
const src = browser.runtime.getURL(name);
injectExternalJs(src, id);
} else {
injectInlineJs(`(${injector})()`, id);
}
}

View File

@@ -0,0 +1,12 @@
export const shadowRootInjector = () => {
try {
const orig = Element.prototype.attachShadow;
Element.prototype.attachShadow = function (...args) {
const root = orig.apply(this, args);
window.postMessage({ type: "KISS_SHADOW_ROOT_CREATED" }, "*");
return root;
};
} catch (err) {
console.log("shadowRootInjector", err);
}
};

23
src/injectors/xmlhttp.js Normal file
View File

@@ -0,0 +1,23 @@
export const XMLHttpRequestInjector = () => {
try {
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args) {
const url = args[1];
if (typeof url === "string" && url.includes("timedtext")) {
this.addEventListener("load", function () {
window.postMessage(
{
type: "KISS_XHR_DATA_YOUTUBE",
url: this.responseURL,
response: this.responseText,
},
window.location.origin
);
});
}
return originalOpen.apply(this, args);
};
} catch (err) {
console.log("XMLHttpRequestInjector", err);
}
};

View File

@@ -1,6 +1,7 @@
import {
CACHE_NAME,
DEFAULT_CACHE_TIMEOUT,
MSG_CLEAR_CACHES,
MSG_GET_HTTPCACHE,
MSG_PUT_HTTPCACHE,
} from "../config";
@@ -15,7 +16,11 @@ import { blobToBase64 } from "./utils";
*/
export const tryClearCaches = async () => {
try {
caches.delete(CACHE_NAME);
if (isExt && !isBg) {
await sendBgMsg(MSG_CLEAR_CACHES);
} else {
await caches.delete(CACHE_NAME);
}
} catch (err) {
kissLog("clean caches", err);
}

18
src/libs/fabManager.js Normal file
View File

@@ -0,0 +1,18 @@
import ShadowDomManager from "./shadowDomManager";
import { APP_CONSTS } from "../config";
import ContentFab from "../views/Action/ContentFab";
export class FabManager extends ShadowDomManager {
constructor({ processActions, fabConfig }) {
super({
id: APP_CONSTS.fabID,
className: "notranslate",
reactComponent: ContentFab,
props: { processActions, fabConfig },
});
if (!fabConfig?.isHide) {
this.show();
}
}
}

View File

@@ -41,6 +41,12 @@ export const fetchGM = async (
});
},
onerror: reject,
onabort: () => {
reject(new Error("GM request onabort."));
},
ontimeout: () => {
reject(new Error("GM request timeout."));
},
});
});

View File

@@ -1,28 +1,41 @@
// Function to inject inline JavaScript code
export const injectInlineJs = (code) => {
const el = document.createElement("script");
el.setAttribute("data-source", "kiss-inject injectInlineJs");
el.setAttribute("type", "text/javascript");
el.textContent = code;
document.body?.appendChild(el);
};
import { trustedTypesHelper } from "./trustedTypes";
// Function to inject external JavaScript file
export const injectExternalJs = (src, id = "kiss-translator-injector") => {
// Function to inject inline JavaScript code
export const injectInlineJs = (code, id = "kiss-translator-inline-js") => {
if (document.getElementById(id)) {
return;
}
// const el = document.createElement("script");
// el.setAttribute("data-source", "kiss-inject injectExternalJs");
// el.setAttribute("type", "text/javascript");
// el.setAttribute("src", src);
// el.setAttribute("id", id);
// document.body?.appendChild(el);
const script = document.createElement("script");
script.id = id;
script.src = src;
(document.head || document.documentElement).appendChild(script);
const el = document.createElement("script");
el.type = "text/javascript";
el.id = id;
el.textContent = trustedTypesHelper.createScript(code);
(document.head || document.documentElement).appendChild(el);
};
export const injectInlineJsBg = (code, id = "kiss-translator-inline-js") => {
if (document.getElementById(id)) {
return;
}
const el = document.createElement("script");
el.type = "text/javascript";
el.id = id;
el.textContent = code;
(document.head || document.documentElement).appendChild(el);
};
// Function to inject external JavaScript file
export const injectExternalJs = (src, id = "kiss-translator-external-js") => {
if (document.getElementById(id)) {
return;
}
const el = document.createElement("script");
el.type = "text/javascript";
el.id = id;
el.src = trustedTypesHelper.createScriptURL(src);
(document.head || document.documentElement).appendChild(el);
};
// Function to inject internal CSS code

View File

@@ -4,11 +4,11 @@ import {
OPT_LANGS_LIST,
DEFAULT_API_SETTING,
} from "../config";
import { genEventName, removeEndchar, matchInputStr } from "./utils";
import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils";
import { stepShortcutRegister } from "./shortcut";
import { apiTranslate } from "../apis";
import { createLoadingSVG } from "./svg";
import { kissLog } from "./log";
import { logger } from "./log";
function isInputNode(node) {
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
@@ -18,21 +18,94 @@ function isEditAbleNode(node) {
return node.hasAttribute("contenteditable");
}
function replaceContentEditableText(node, newText) {
node.focus();
const selection = window.getSelection();
if (!selection) return;
async function replaceContentEditableText(node, newText) {
try {
logger.debug("try replace editable 1: pasteEvent");
const range = document.createRange();
range.selectNodeContents(node);
selection.removeAllRanges();
selection.addRange(range);
node.focus();
range.deleteContents();
const textNode = document.createTextNode(newText);
range.insertNode(textNode);
const selection = window.getSelection();
if (!selection) throw new Error("window.getSelection() is not available.");
selection.collapseToEnd();
const targetNode = node.querySelector("p") || node;
const range = document.createRange();
range.selectNodeContents(targetNode);
selection.removeAllRanges();
selection.addRange(range);
const dataTransfer = new DataTransfer();
dataTransfer.setData("text/plain", newText);
const pasteEvent = new ClipboardEvent("paste", {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true,
});
node.dispatchEvent(pasteEvent);
await sleep(50);
if (node.innerText.trim() === newText) {
return true;
}
throw new Error("Strategy 1 failed to replace text correctly.");
} catch (error) {
logger.debug("Strategy 1 Failed:", error.message);
}
try {
logger.debug("try replace editable 2: execCommand");
node.focus();
const selection = window.getSelection();
if (!selection) throw new Error("window.getSelection() is not available.");
const targetNode = node.querySelector("p") || node;
const range = document.createRange();
range.selectNodeContents(targetNode);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("insertText", false, newText);
await sleep(50);
if (node.innerText.trim() === newText) {
return true;
}
throw new Error("Strategy 2 failed to replace text correctly.");
} catch (error) {
logger.debug("Strategy 2 Failed:", error.message);
}
try {
logger.debug("try replace editable 3: textContent");
node.focus();
const targetNode = node.querySelector("p") || node;
const textSpan = targetNode.querySelector('span[data-lexical-text="true"]');
if (textSpan) {
textSpan.textContent = newText;
} else {
targetNode.textContent = newText;
}
node.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
await sleep(50);
if (node.innerText.trim() === newText) {
return true;
}
throw new Error("Strategy 3 failed to replace text correctly.");
} catch (error) {
logger.debug("Strategy 3 Failed:", error.message);
}
return false;
}
function getNodeText(node) {
@@ -107,7 +180,7 @@ export class InputTranslator {
);
this.#isEnabled = true;
kissLog("Input Translator enabled.");
logger.info("Input Translator enabled.");
}
/**
@@ -122,7 +195,7 @@ export class InputTranslator {
this.#unregisterShortcut = null;
}
this.#isEnabled = false;
kissLog("Input Translator disabled.");
logger.info("Input Translator disabled.");
}
/**
@@ -193,25 +266,30 @@ export class InputTranslator {
try {
addLoading(node, loadingId);
const [trText, isSame] = await apiTranslate({
const { trText, isSame } = await apiTranslate({
text,
fromLang,
toLang,
apiSetting,
});
if (!trText || isSame) return;
const newText = trText?.trim() || "";
if (!newText || isSame) return;
if (isInputNode(node)) {
node.value = trText;
node.value = newText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
} else {
replaceContentEditableText(node, trText);
const success = await replaceContentEditableText(node, newText);
if (!success) {
// todo: 提示可以黏贴
logger.info("Replace editable text failed");
}
}
} catch (err) {
kissLog("Translate input error:", err);
logger.info("Translate input error:", err);
} finally {
removeLoading(loadingId);
}

View File

@@ -1,6 +1,6 @@
import Sval from "sval";
const interpreter = new Sval({
export const interpreter = new Sval({
// ECMA Version of the code
// 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15
// or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024
@@ -12,5 +12,3 @@ const interpreter = new Sval({
// Whether the code runs in a sandbox
sandBox: true,
});
export default interpreter;

26
src/libs/popupManager.js Normal file
View File

@@ -0,0 +1,26 @@
import ShadowDomManager from "./shadowDomManager";
import { APP_CONSTS, EVENT_KISS, MSG_POPUP_TOGGLE } from "../config";
import Action from "../views/Action";
export class PopupManager extends ShadowDomManager {
constructor({ translator, processActions }) {
super({
id: APP_CONSTS.popupID,
className: "notranslate",
reactComponent: Action,
props: { translator, processActions },
});
}
toggle(props) {
if (this.isVisible) {
document.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: { action: MSG_POPUP_TOGGLE },
})
);
} else {
this.show(props || this._props);
}
}
}

View File

@@ -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";
@@ -52,11 +54,13 @@ export const matchRule = async (href, { injectRules, subrulesList }) => {
"ignoreSelector",
"terms",
"aiTerms",
"termsStyle",
"highlightStyle",
"selectStyle",
"parentStyle",
"grandStyle",
"injectJs",
"injectCss",
// "injectCss",
// "fixerSelector",
"transStartHook",
"transEndHook",
@@ -81,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;
// }
@@ -136,11 +148,13 @@ export const checkRules = (rules) => {
ignoreSelector,
terms,
aiTerms,
termsStyle,
highlightStyle,
selectStyle,
parentStyle,
grandStyle,
injectJs,
injectCss,
// injectCss,
apiSlug,
fromLang,
toLang,
@@ -162,6 +176,9 @@ export const checkRules = (rules) => {
transStartHook,
transEndHook,
// transRemoveHook,
splitParagraph,
splitLength,
highlightWords,
}) => ({
pattern: pattern.trim(),
selector: type(selector) === "string" ? selector : "",
@@ -170,11 +187,13 @@ export const checkRules = (rules) => {
ignoreSelector: type(ignoreSelector) === "string" ? ignoreSelector : "",
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 : "",
injectJs: type(injectJs) === "string" ? injectJs : "",
injectCss: type(injectCss) === "string" ? injectCss : "",
// injectCss: type(injectCss) === "string" ? injectCss : "",
bgColor: type(bgColor) === "string" ? bgColor : "",
textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "",
apiSlug:
@@ -200,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
),
})
);

View File

@@ -0,0 +1,128 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { CacheProvider } from "@emotion/react";
import createCache from "@emotion/cache";
import { logger } from "./log";
export default class ShadowDomManager {
#hostElement = null;
#reactRoot = null;
#isVisible = false;
#isProcessing = false;
_id;
_className;
_ReactComponent;
_props;
constructor({ id, className = "", reactComponent, props = {} }) {
if (!id || !reactComponent) {
throw new Error("ID and a React Component must be provided.");
}
this._id = id;
this._className = className;
this._ReactComponent = reactComponent;
this._props = props;
}
get isVisible() {
return this.#isVisible;
}
show(props) {
if (this.#isVisible || this.#isProcessing) {
return;
}
if (!this.#hostElement) {
this.#isProcessing = true;
try {
this.#mount(props || this._props);
} catch (error) {
logger.warn(`Failed to mount component with id "${this._id}":`, error);
this.#isProcessing = false;
return;
} finally {
this.#isProcessing = false;
}
}
this.#hostElement.style.display = "";
this.#isVisible = true;
}
hide() {
if (!this.#isVisible || !this.#hostElement) {
return;
}
this.#hostElement.style.display = "none";
this.#isVisible = false;
}
destroy() {
if (!this.#hostElement) {
return;
}
this.#isProcessing = true;
if (this.#reactRoot) {
this.#reactRoot.unmount();
}
this.#hostElement.remove();
this.#hostElement = null;
this.#reactRoot = null;
this.#isVisible = false;
this.#isProcessing = false;
logger.info(`Component with id "${this._id}" has been destroyed.`);
}
toggle(props) {
if (this.#isVisible) {
this.hide();
} else {
this.show(props || this._props);
}
}
#mount(props) {
const host = document.createElement("div");
host.id = this._id;
if (this._className) {
host.className = this._className;
}
host.style.display = "none";
document.body.parentElement.appendChild(host);
this.#hostElement = host;
const shadowContainer = host.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const appRoot = document.createElement("div");
appRoot.className = `${this._id}_wrapper`;
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(appRoot);
const cache = createCache({
key: this._id,
prepend: true,
container: emotionRoot,
});
const enhancedProps = {
...props,
onClose: this.hide.bind(this),
};
const ComponentToRender = this._ReactComponent;
this.#reactRoot = ReactDOM.createRoot(appRoot);
this.#reactRoot.render(
<React.StrictMode>
<CacheProvider value={cache}>
<ComponentToRender {...enhancedProps} />
</CacheProvider>
</React.StrictMode>
);
}
}

View File

@@ -1,56 +0,0 @@
import { kissLog } from "./log";
/**
* @class ShadowRootMonitor
* @description 通过覆写 Element.prototype.attachShadow 来监控页面上所有新创建的 Shadow DOM
*/
export class ShadowRootMonitor {
/**
* @param {function(ShadowRoot): void} callback - 当一个新的 shadowRoot 被创建时调用的回调函数。
*/
constructor(callback) {
if (typeof callback !== "function") {
throw new Error("Callback must be a function.");
}
this.callback = callback;
this.isMonitoring = false;
this.originalAttachShadow = Element.prototype.attachShadow;
}
/**
* 开始监控 shadowRoot 的创建。
*/
start() {
if (this.isMonitoring) {
return;
}
const monitorInstance = this;
Element.prototype.attachShadow = function (...args) {
const shadowRoot = monitorInstance.originalAttachShadow.apply(this, args);
if (shadowRoot) {
try {
monitorInstance.callback(shadowRoot);
} catch (error) {
kissLog("Error in ShadowRootMonitor callback", error);
}
}
return shadowRoot;
};
this.isMonitoring = true;
}
/**
* 停止监控,并恢复原始的 attachShadow 方法。
*/
stop() {
if (!this.isMonitoring) {
return;
}
Element.prototype.attachShadow = this.originalAttachShadow;
this.isMonitoring = false;
}
}

View File

@@ -34,12 +34,18 @@ export const shortcutListener = (
pressedKeys.delete(e.code);
};
const handleBlur = () => {
pressedKeys.clear();
};
target.addEventListener("keydown", handleKeyDown);
target.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
target.removeEventListener("keydown", handleKeyDown);
target.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
pressedKeys.clear();
};
};
@@ -57,8 +63,8 @@ export const shortcutRegister = (targetKeys = [], fn, target = document) => {
const targetKeySet = new Set(targetKeys);
const onKeyDown = (pressedKeys, event) => {
if (isSameSet(targetKeySet, pressedKeys)) {
event.preventDefault();
event.stopPropagation();
// event.preventDefault(); // 阻止浏览器的默认行为
// event.stopPropagation(); // 阻止事件继续(向父元素)冒泡
fn();
}
};

View File

@@ -5,6 +5,7 @@ import {
STOKEY_RULES_OLD,
STOKEY_WORDS,
STOKEY_FAB,
STOKEY_TRANBOX,
STOKEY_SYNC,
STOKEY_MSAUTH,
STOKEY_BDAUTH,
@@ -135,6 +136,13 @@ export const getFabWithDefault = async () => (await getFab()) || {};
export const setFab = (obj) => setObj(STOKEY_FAB, obj);
export const putFab = (obj) => putObj(STOKEY_FAB, obj);
/**
* tranbox位置大小
*/
export const getTranBox = () => getObj(STOKEY_TRANBOX);
export const putTranBox = (obj) => putObj(STOKEY_TRANBOX, obj);
export const debouncePutTranBox = debounce(putTranBox, 300);
/**
* 数据同步
*/

View File

@@ -84,7 +84,7 @@ const genStyles = ({
// 虚线框
[OPT_STYLE_DASHBOX]: `
border: 2px dashed ${bgColor || DEFAULT_COLOR};
display: inline-block;
display: block;
padding: 0.2em 0.4em;
box-sizing: border-box;
`,

View File

@@ -67,9 +67,9 @@ export function createLoadingSVG() {
* @returns
*/
export function createLogoSVG({
width = "100%",
height = "100%",
viewBox = "-20 -20 70 70",
width = "24",
height = "24",
viewBox = "-5 -5 40 40",
isSelected = false,
} = {}) {
const svg = createSVGElement("svg", {
@@ -80,30 +80,26 @@ export function createLogoSVG({
version: "1.1",
});
const primaryColor = "#209CEE";
const secondaryColor = "#E9F5FD";
const path1Fill = isSelected ? primaryColor : secondaryColor;
const path2Fill = isSelected ? secondaryColor : primaryColor;
const path1 = createSVGElement("path", {
d: "M0 0 C10.56 0 21.12 0 32 0 C32 10.56 32 21.12 32 32 C21.44 32 10.88 32 0 32 C0 21.44 0 10.88 0 0 Z ",
fill: "#209CEE",
fill: path1Fill,
transform: "translate(0,0)",
});
const path2 = createSVGElement("path", {
d: "M0 0 C0.66 0 1.32 0 2 0 C2 2.97 2 5.94 2 9 C2.969375 8.2575 3.93875 7.515 4.9375 6.75 C5.48277344 6.33234375 6.02804688 5.9146875 6.58984375 5.484375 C8.39053593 3.83283924 8.39053593 3.83283924 9 0 C13.95 0 18.9 0 24 0 C24 0.99 24 1.98 24 3 C22.68 3 21.36 3 20 3 C20 9.27 20 15.54 20 22 C19.01 22 18.02 22 17 22 C17 15.73 17 9.46 17 3 C15.35 3 13.7 3 12 3 C11.731875 3.598125 11.46375 4.19625 11.1875 4.8125 C10.01506533 6.97224808 8.80630718 8.35790256 7 10 C8.01790655 12.27071461 8.77442829 13.80784632 10.6875 15.4375 C11.120625 15.953125 11.55375 16.46875 12 17 C11.6875 19.6875 11.6875 19.6875 11 22 C10.34 22 9.68 22 9 22 C8.773125 21.236875 8.54625 20.47375 8.3125 19.6875 C6.73268318 16.45263699 5.16717283 15.58358642 2 14 C2 16.64 2 19.28 2 22 C1.34 22 0.68 22 0 22 C0 14.74 0 7.48 0 0 Z ",
fill: "#E9F5FD",
fill: path2Fill,
transform: "translate(4,5)",
});
svg.appendChild(path1);
svg.appendChild(path2);
if (isSelected) {
const redLine = createSVGElement("path", {
d: "M0 36 L32 36",
stroke: "red",
"stroke-width": "3",
"stroke-linecap": "round",
});
svg.appendChild(redLine);
}
return svg;
}

View File

@@ -1,12 +1,47 @@
export function touchTapListener(fn, touchsLength) {
export function touchTapListener(fn, options = {}) {
const config = {
taps: 2,
fingers: 1,
delay: 300,
...options,
};
let maxTouches = 0;
let tapCount = 0;
let tapTimer = null;
const handleTouchStart = (e) => {
maxTouches = Math.max(maxTouches, e.touches.length);
};
const handleTouchend = (e) => {
if (e.touches.length === touchsLength) {
fn();
if (e.touches.length === 0) {
if (maxTouches === config.fingers) {
tapCount++;
clearTimeout(tapTimer);
if (tapCount === config.taps) {
fn(e);
tapCount = 0;
} else {
tapTimer = setTimeout(() => {
tapCount = 0;
}, config.delay);
}
} else {
tapCount = 0;
clearTimeout(tapTimer);
}
maxTouches = 0;
}
};
document.addEventListener("touchstart", handleTouchend);
document.addEventListener("touchstart", handleTouchStart, { passive: true });
document.addEventListener("touchend", handleTouchend, { passive: true });
return () => {
document.removeEventListener("touchstart", handleTouchend);
clearTimeout(tapTimer);
document.removeEventListener("touchstart", handleTouchStart);
document.removeEventListener("touchend", handleTouchend);
};
}

View File

@@ -1,42 +1,31 @@
import {
APP_NAME,
APP_UPNAME,
APP_LCNAME,
APP_CONSTS,
MSG_INJECT_JS,
MSG_INJECT_CSS,
OPT_STYLE_FUZZY,
GLOBLA_RULE,
DEFAULT_SETTING,
// DEFAULT_MOUSEHOVER_KEY,
OPT_STYLE_NONE,
DEFAULT_API_SETTING,
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_TRANBOX,
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";
import { interpreter } from "./interpreter";
import { clearFetchPool } from "./pool";
import { debounce, scheduleIdle, genEventName, truncateWords } from "./utils";
import { apiTranslate } from "../apis";
import { sendBgMsg } from "./msg";
import { isExt } from "./client";
import { injectInlineJs, injectInternalCss } from "./injector";
import { kissLog } from "./log";
import { clearAllBatchQueue } from "./batchQueue";
import { genTextClass } from "./style";
import { createLoadingSVG } from "./svg";
import { shortcutRegister } from "./shortcut";
import { tryDetectLang } from "./detect";
import { browser } from "./browser";
import { isIframe, sendIframeMsg } from "./iframe";
import { TransboxManager } from "./tranbox";
import { InputTranslator } from "./inputTranslate";
import { trustedTypesHelper } from "./trustedTypes";
import { injectJs, INJECTOR } from "../injectors";
/**
* @class Translator
@@ -83,7 +72,7 @@ export class Translator {
"VIDEO",
]),
INLINE: new Set([
"A",
// "A",
"ABBR",
"ACRONYM",
"B",
@@ -112,7 +101,7 @@ export class Translator {
"SCRIPT",
"SELECT",
"SMALL",
"SPAN",
// "SPAN",
"STRONG",
"SUB",
"SUP",
@@ -163,6 +152,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`,
};
// 内置跳过翻译文本
@@ -210,11 +201,17 @@ export class Translator {
// 14. 包含常见扩展名的文件名 (例如: document.pdf, image.jpeg)
/^[^\s\\/:]+?\.[a-zA-Z0-9]{2,5}$/,
// todo: 数字和特殊字符组成的字符串
];
static DEFAULT_OPTIONS = DEFAULT_SETTING; // 默认配置
static DEFAULT_RULE = GLOBLA_RULE; // 默认规则
static isElement(el) {
return el instanceof Element;
}
static isElementOrFragment(el) {
return el instanceof Element || el instanceof DocumentFragment;
}
@@ -225,6 +222,7 @@ export class Translator {
if (Translator.TAGS.INLINE.has(el.nodeName)) return false;
if (Translator.TAGS.BLOCK.has(el.nodeName)) return true;
if (el.attributes?.display?.value?.includes("inline")) return false;
if (Translator.displayCache.has(el)) {
return Translator.displayCache.get(el);
@@ -235,11 +233,22 @@ export class Translator {
return isBlock;
}
// 判断是否包含块级子元素
static hasBlockNode(el) {
if (!Translator.isElementOrFragment(el)) return false;
for (const child of el.childNodes) {
if (Translator.isBlockNode(child)) {
return true;
}
}
return false;
}
// 判断是否直接包含非空文本节点
static hasTextNode(el) {
if (!Translator.isElementOrFragment(el)) return false;
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE && /\S/.test(node.nodeValue)) {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE && /\S/.test(child.nodeValue)) {
return true;
}
}
@@ -252,18 +261,23 @@ export class Translator {
}
// 内置忽略元素
static BUILTIN_IGNORE_SELECTOR = `abbr, address, area, audio, br, canvas, code,
data, datalist, dfn, embed, head, iframe, img, input, kbd, noscript, map,
object, option, output, param, picture, progress,
samp, select, script, style, sub, sup, svg, track, time, textarea, template,
var, video, wbr, .notranslate, [contenteditable], [translate='no'],
${APP_LCNAME}, #${APP_CONSTS.fabID}, #${APP_CONSTS.boxID},
.${APP_CONSTS.fabID}_warpper, .${APP_CONSTS.boxID}_warpper`;
static KISS_IGNORE_SELECTOR = `${APP_LCNAME}, .kiss-caption-container, .kiss-subtitle-controls
#${APP_CONSTS.fabID}, .${APP_CONSTS.fabID}_warpper,
#${APP_CONSTS.boxID}, .${APP_CONSTS.boxID}_warpper,
#${APP_CONSTS.popupID}, .${APP_CONSTS.popupID}_warpper`;
static BUILTIN_IGNORE_SELECTOR = `address, area, audio, br, canvas,
data, datalist, embed, head, iframe, input, noscript, map,
object, option, param, picture, progress,
select, script, style, track, textarea, template,
video, wbr, .notranslate, [contenteditable], [translate='no'],
${Translator.KISS_IGNORE_SELECTOR}`;
#setting; // 设置选项
#rule; // 规则
#isInitialized = false; // 初始化状态
#isJsInjected = false; // 注入用户JS
#isShadowRootJsInjected = false; //
#mouseHoverEnabled = false; // 鼠标悬停翻译
#enabled = false; // 全局默认状态
#runId = 0; // 用于中止过期的异步请求
@@ -271,49 +285,55 @@ export class Translator {
#combinedTermsRegex; // 专业术语正则表达式
#combinedSkipsRegex; // 跳过文本正则表达式
#placeholderRegex; // 恢复htnml正则表达式
#translationTagName = APP_NAME; // 翻译容器的标签名
#translationTagName = APP_UPNAME; // 翻译容器的标签名
#eventName = ""; // 通信事件名称
#docInfo = {}; // 网页信息
#glossary = {}; // AI词典
#textClass = {}; // 译文样式class
#textSheet = ""; // 译文样式字典
#isUserscript = false;
#transboxManager = null; // 划词翻译
#inputTranslator = null; // 输入框翻译
#apisMap = new Map(); // 用于接口快速查找
#favWords = []; // 收藏词汇
#observedNodes = new WeakSet(); // 存储所有被识别出的、可翻译的 DOM 节点单元
#translationNodes = new WeakMap(); // 存储所有插入到页面的译文节点
#viewNodes = new Set(); // 当前在可视范围内的单元
#processedNodes = new WeakMap(); // 已处理已执行翻译DOM操作的单元
#rootNodes = new Set(); // 已监控的根节点
#skipMoNodes = new WeakSet(); // 忽略变化的节点
#removeKeydownHandler; // 快捷键清理函数
#hoveredNode = null; // 存储当前悬停的可翻译节点
#boundMouseMoveHandler; // 鼠标事件
#boundKeyDownHandler; // 键盘事件
#windowMessageHandler = null;
#debouncedFindShadowRoot = null;
#io; // IntersectionObserver
#mo; // MutationObserver
#dmm; // DebounceMouseMover
#srm; // ShadowRootMonitor
#rescanQueue = new Set(); // “脏容器”队列
#isQueueProcessing = false; // 队列处理状态标志
// 忽略元素
get #ignoreSelector() {
if (this.#rule.autoScan === "false") {
return `${Translator.KISS_IGNORE_SELECTOR}, ${this.#rule.ignoreSelector}`;
}
return `${Translator.BUILTIN_IGNORE_SELECTOR}, ${this.#rule.ignoreSelector}`;
}
// 接口参数
// todo: 不用频繁查找计算
get #apiSetting() {
return (
this.#setting.transApis.find(
(api) => api.apiSlug === this.#rule.apiSlug
) || DEFAULT_API_SETTING
);
// return (
// this.#setting.transApis.find(
// (api) => api.apiSlug === this.#rule.apiSlug
// ) || DEFAULT_API_SETTING
// );
return this.#apisMap.get(this.#rule.apiSlug) || DEFAULT_API_SETTING;
}
// 占位符
@@ -327,11 +347,14 @@ export class Translator {
};
}
constructor(rule = {}, setting = {}, isUserscript = false) {
constructor({ rule = {}, setting = {}, favWords = [] }) {
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])
);
this.#isUserscript = isUserscript;
this.#eventName = genEventName();
this.#docInfo = {
title: document.title,
@@ -351,31 +374,18 @@ export class Translator {
this.#io = this.#createIntersectionObserver();
this.#mo = this.#createMutationObserver();
this.#dmm = this.#createDebounceMouseMover();
this.#srm = this.#createShadowRootMonitor();
// 监控shadowroot
if (this.#rule.hasShadowroot === "true") {
this.#srm.start();
}
this.#windowMessageHandler = this.#handleWindowMessage.bind(this);
this.#debouncedFindShadowRoot = debounce(
this.#findAndObserveShadowRoot.bind(this),
300
);
// 鼠标悬停翻译
if (this.#setting.mouseHoverSetting.useMouseHover) {
this.#enableMouseHover();
}
if (!isIframe) {
// 监听后端事件
if (!isUserscript) {
this.#runtimeListener();
}
// 划词翻译
this.#transboxManager = new TransboxManager(this.setting);
// 输入框翻译
this.#inputTranslator = new InputTranslator(this.setting);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => this.#run());
} else {
@@ -406,53 +416,42 @@ export class Translator {
this.#startObserveRoot(root);
});
// 查找现有的所有shadowroot
if (this.#rule.hasShadowroot === "true") {
try {
this.#findAllShadowRoots().forEach((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
} catch (err) {
kissLog("findAllShadowRoots", err);
}
this.#attachShadowRootListener();
this.#findAndObserveShadowRoot();
}
}
// 监听后端事件
#runtimeListener() {
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
switch (action) {
case MSG_TRANS_TOGGLE:
this.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
break;
case MSG_TRANS_TOGGLE_STYLE:
this.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:
this.updateRule(args);
sendIframeMsg(MSG_TRANS_PUTRULE, args);
break;
case MSG_OPEN_TRANBOX:
window.dispatchEvent(new CustomEvent(MSG_OPEN_TRANBOX));
break;
case MSG_TRANSBOX_TOGGLE:
this.toggleTransbox();
break;
case MSG_MOUSEHOVER_TOGGLE:
this.toggleMouseHover();
break;
case MSG_TRANSINPUT_TOGGLE:
this.toggleInputTranslate();
break;
default:
return { error: `message action is unavailable: ${action}` };
}
return { rule: this.rule, setting: this.setting };
});
#handleWindowMessage(event) {
if (event.data?.type === "KISS_SHADOW_ROOT_CREATED") {
this.#debouncedFindShadowRoot();
}
}
#attachShadowRootListener() {
if (!this.#isShadowRootJsInjected) {
const id = "kiss-translator-inject-shadowroot-js";
injectJs(INJECTOR.shadowroot, id);
this.#isShadowRootJsInjected = true;
}
window.addEventListener("message", this.#windowMessageHandler);
}
#removeShadowRootListener() {
window.removeEventListener("message", this.#windowMessageHandler);
}
// 查找现有的所有shadowroot
#findAndObserveShadowRoot() {
try {
this.#findAllShadowRoots().forEach((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
} catch (err) {
kissLog("findAllShadowRoots", err);
}
}
#createPlaceholderRegex() {
@@ -553,11 +552,13 @@ export class Translator {
// 监控翻译单元的可见性
#createIntersectionObserver() {
const { transInterval, rootMargin = 500 } = this.#setting;
const pending = new Set();
const flush = debounce(() => {
pending.forEach((node) => this.#performSyncNode(node));
pending.clear();
}, this.#setting.transInterval);
}, transInterval);
return new IntersectionObserver(
(entries) => {
@@ -571,7 +572,7 @@ export class Translator {
}
});
},
{ threshold: 0.01 }
{ threshold: 0.01, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px` }
);
}
@@ -580,28 +581,34 @@ export class Translator {
return new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (
mutation.type === "characterData" &&
mutation.oldValue !== mutation.target.nodeValue
this.#skipMoNodes.has(mutation.target) ||
mutation.nextSibling?.tagName === this.#translationTagName
) {
this.#queueForRescan(mutation.target.parentElement);
} else if (mutation.type === "childList") {
if (mutation.nextSibling?.tagName === this.#translationTagName) {
// 恢复原文时插入元素,忽略
continue;
}
continue;
}
if (mutation.type === "characterData") {
if (
mutation.oldValue !== mutation.target.nodeValue &&
!this.#combinedSkipsRegex.test(mutation.target.nodeValue)
) {
this.#queueForRescan(mutation.target.parentElement);
}
} else if (mutation.type === "childList") {
let nodes = new Set();
let hasText = false;
mutation.addedNodes.forEach((node) => {
if (/\S/.test(node.nodeValue)) {
if (node.nodeType === Node.TEXT_NODE) {
hasText = true;
} else if (
Translator.isElementOrFragment(node) &&
node.nodeName !== this.#translationTagName
) {
nodes.add(node);
}
if (
this.#skipMoNodes.has(node) ||
node.nodeName === this.#translationTagName
) {
return;
}
if (node.nodeType === Node.TEXT_NODE) {
hasText = true;
} else if (Translator.isElementOrFragment(node)) {
nodes.add(node);
}
});
if (hasText) {
@@ -638,13 +645,6 @@ export class Translator {
}, 100);
}
// 创建shadowroot的回调
#createShadowRootMonitor() {
return new ShadowRootMonitor((shadowRoot) => {
this.#startObserveShadowRoot(shadowRoot);
});
}
// 跟踪鼠标下的可翻译节点
#handleMouseMove(event) {
let targetNode = event.composedPath()[0];
@@ -774,6 +774,13 @@ export class Translator {
// 开始/重新监控节点
#startObserveNode(node) {
// todo: DocumentFragment 无法被 this.#io.observe
if (!Translator.isElement(node)) return;
if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_BEFORETRANS) {
this.#highlightWordsDeeply(node);
}
if (
!this.#observedNodes.has(node) &&
this.#enabled &&
@@ -815,6 +822,7 @@ export class Translator {
#scanNode(rootNode) {
if (
!Translator.isElementOrFragment(rootNode) ||
// rootNode.matches?.(this.#rule.keepSelector) ||
rootNode.matches?.(this.#ignoreSelector)
) {
return;
@@ -826,13 +834,24 @@ export class Translator {
}
const hasText = Translator.hasTextNode(rootNode);
if (hasText) {
if (!hasText && rootNode.children.length === 1) {
this.#scanNode(rootNode.children[0]);
return;
}
const hasBlock = Translator.hasBlockNode(rootNode);
if (hasText || !hasBlock) {
this.#startObserveNode(rootNode);
}
for (const child of rootNode.children) {
if (!hasText || Translator.isBlockNode(child)) {
this.#scanNode(child);
if (hasBlock) {
for (const child of rootNode.children) {
const isBlock = Translator.isBlockNode(child);
if (!hasText || isBlock) {
this.#scanNode(child);
}
}
}
}
@@ -855,7 +874,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);
@@ -870,6 +894,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);
@@ -889,6 +918,171 @@ 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();
}
// 移除br
#removeBrTags(parentNode) {
if (!parentNode) return;
parentNode
.querySelectorAll(`.${Translator.KISS_CLASS.br}`)
.forEach((br) => br.remove());
parentNode.normalize();
}
// 判断是否需要换行
#shouldBreak(node) {
if (!Translator.isElementOrFragment(node)) return false;
@@ -896,6 +1090,7 @@ export class Translator {
if (
Translator.TAGS.BREAK_LINE.has(node.nodeName) ||
node.matches?.(this.#ignoreSelector) ||
node.nodeName === this.#translationTagName
) {
return true;
@@ -955,15 +1150,16 @@ export class Translator {
const {
transTag,
textStyle,
transStartHook,
transEndHook,
transOnly,
termsStyle,
selectStyle,
parentStyle,
grandStyle,
// detectRemote,
// toLang,
// skipLangs = [],
highlightWords,
} = this.#rule;
const {
newlineLength,
@@ -972,23 +1168,11 @@ export class Translator {
const parentNode = hostNode.parentElement;
const hideOrigin = transOnly === "true";
// 翻译开始钩子函数
if (transStartHook?.trim()) {
try {
interpreter.run(`exports.transStartHook = ${transStartHook}`);
interpreter.exports.transStartHook({
hostNode,
parentNode,
nodes,
});
} catch (err) {
kissLog("transStartHook", err);
}
}
try {
const [processedString, placeholderMap] =
this.#serializeForTranslation(nodes);
const [processedString, placeholderMap] = this.#serializeForTranslation(
nodes,
termsStyle
);
// console.log("processedString", processedString);
if (this.#isInvalidText(processedString)) return;
@@ -1008,10 +1192,8 @@ export class Translator {
nodes[nodes.length - 1].after(wrapper);
const currentRunId = this.#runId;
const [translatedText, isSameLang] = await this.#translateFetch(
processedString,
deLang
);
const { trText: translatedText, isSame: isSameLang } =
await this.#translateFetch(processedString, deLang);
if (this.#runId !== currentRunId) {
throw new Error("Request terminated");
}
@@ -1021,10 +1203,19 @@ export class Translator {
return;
}
inner.innerHTML = this.#restoreFromTranslation(
const htmlString = this.#restoreFromTranslation(
translatedText,
placeholderMap
);
const trustedHTML = trustedTypesHelper.createHTML(htmlString);
// const parser = new DOMParser();
// const doc = parser.parseFromString(trustedHTML, "text/html");
// const innerElement = doc.body.firstChild;
// inner.replaceChildren(innerElement);
inner.innerHTML = trustedHTML;
this.#translationNodes.set(wrapper, {
nodes,
isHide: hideOrigin,
@@ -1044,6 +1235,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 {
@@ -1068,7 +1264,7 @@ export class Translator {
}
// 处理节点转为翻译字符串
#serializeForTranslation(nodes) {
#serializeForTranslation(nodes, termsStyle) {
let replaceCounter = 0; // {{n}}
let wrapCounter = 0; // <tagn>
const placeholderMap = new Map();
@@ -1090,10 +1286,7 @@ export class Translator {
}
// 文本节点
if (
this.#rule.hasRichText === "false" ||
node.nodeType === Node.TEXT_NODE
) {
if (node.nodeType === Node.TEXT_NODE) {
let text = node.textContent;
// 专业术语替换
@@ -1108,7 +1301,7 @@ export class Translator {
const termValue = this.#termValues[matchedIndex];
return pushReplace(
`<i class="${Translator.KISS_CLASS.term}">${termValue || fullMatch}</i>`
`<i class="${Translator.KISS_CLASS.term}" style="${termsStyle}">${termValue || fullMatch}</i>`
);
});
}
@@ -1119,8 +1312,10 @@ export class Translator {
// 元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
if (
Translator.TAGS.REPLACE.has(node.tagName) ||
(this.#rule.hasRichText === "true" &&
Translator.TAGS.REPLACE.has(node.tagName)) ||
node.matches(this.#rule.keepSelector) ||
// node.matches(this.#ignoreSelector) ||
!node.textContent.trim()
) {
if (node.tagName === "IMG" || node.tagName === "SVG") {
@@ -1135,7 +1330,10 @@ export class Translator {
innerContent += traverse(child);
});
if (Translator.TAGS.WARP.has(node.tagName)) {
if (
this.#rule.hasRichText === "true" &&
Translator.TAGS.WARP.has(node.tagName)
) {
wrapCounter++;
const startPlaceholder = `<${this.#placeholder.tagName}${wrapCounter}>`;
const endPlaceholder = `</${this.#placeholder.tagName}${wrapCounter}>`;
@@ -1181,16 +1379,39 @@ export class Translator {
// 发起翻译请求
#translateFetch(text, deLang = "") {
const { fromLang, toLang } = this.#rule;
const { toLang, transStartHook } = this.#rule;
const fromLang = deLang || this.#rule.fromLang;
const apiSetting = { ...this.#apiSetting };
const docInfo = { ...this.#docInfo };
const glossary = { ...this.#glossary };
const apisMap = this.#apisMap;
return apiTranslate({
const args = {
text,
fromLang: deLang || fromLang,
fromLang,
toLang,
apiSetting: this.#apiSetting,
docInfo: this.#docInfo,
glossary: this.#glossary,
});
apiSetting,
docInfo,
glossary,
};
// 翻译开始钩子函数
if (transStartHook?.trim()) {
try {
interpreter.run(`exports.transStartHook = ${transStartHook}`);
const hookResult = interpreter.exports.transStartHook({
...args,
apisMap,
});
if (hookResult) {
Object.assign(args, ...hookResult);
}
} catch (err) {
kissLog("transStartHook", err);
}
}
return apiTranslate(args);
}
// 查找指定节点下所有译文节点
@@ -1219,7 +1440,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) || {};
@@ -1229,6 +1451,12 @@ export class Translator {
this.#translationNodes.delete(el);
el.remove();
// todo: 可能不应深度清除
if (this.#rule.highlightWords === OPT_HIGHLIGHT_WORDS_AFTERTRANS) {
this.#removeHighlights(parentElement);
}
this.#removeBrTags(parentElement);
}
// 恢复原文
@@ -1332,6 +1560,8 @@ export class Translator {
// 停止监听,重置参数
#resetOptions() {
this.#removeShadowRootListener();
this.#io.disconnect();
this.#mo.disconnect();
this.#viewNodes.clear();
@@ -1377,13 +1607,35 @@ export class Translator {
this.#isJsInjected = true;
try {
const { injectJs, injectCss } = this.#rule;
if (isExt) {
injectJs && sendBgMsg(MSG_INJECT_JS, injectJs);
injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);
} else {
injectJs && injectInlineJs(injectJs);
injectCss && injectInternalCss(injectCss);
// const { injectJs, injectCss } = this.#rule;
// if (isExt) {
// injectJs && sendBgMsg(MSG_INJECT_JS, injectJs);
// injectCss && sendBgMsg(MSG_INJECT_CSS, injectCss);
// } else {
// injectJs &&
// injectInlineJs(injectJs, "kiss-translator-userinit-injector");
// injectCss && injectInternalCss(injectCss);
// }
const { injectJs, toLang } = this.#rule;
if (injectJs?.trim()) {
const apiSetting = { ...this.#apiSetting };
const docInfo = { ...this.#docInfo };
const glossary = { ...this.#glossary };
const apisMap = this.#apisMap;
const apiDectect = tryDetectLang;
interpreter.import({
KT: {
apiTranslate,
apiDectect,
apiSetting,
apisMap,
toLang,
docInfo,
glossary,
},
});
interpreter.run(injectJs);
}
} catch (err) {
kissLog("inject js", err);
@@ -1434,8 +1686,8 @@ export class Translator {
try {
const deLang = await tryDetectLang(title);
const [translatedTitle] = await this.#translateFetch(title, deLang);
document.title = translatedTitle || title;
const { trText } = await this.#translateFetch(title, deLang);
document.title = trText || title;
} catch (err) {
kissLog("tanslate title", err);
}
@@ -1490,20 +1742,17 @@ export class Translator {
toggleTransbox() {
this.#setting.tranboxSetting.transOpen =
!this.#setting.tranboxSetting.transOpen;
this.#transboxManager?.toggle();
}
// 切换输入框翻译
toggleInputTranslate() {
this.#setting.inputRule.transOpen = !this.#setting.inputRule.transOpen;
this.#inputTranslator?.toggle();
}
// 停止运行
stop() {
this.disable();
this.#resetOptions();
this.#srm.stop();
this.#disableMouseHover();
this.#removeInjector();
this.#isInitialized = false;

View File

@@ -0,0 +1,291 @@
import { browser } from "./browser";
import { Translator } from "./translator";
import { InputTranslator } from "./inputTranslate";
import { TransboxManager } from "./tranbox";
import { shortcutRegister } from "./shortcut";
import { sendIframeMsg } from "./iframe";
import { EVENT_KISS, newI18n } from "../config";
import { touchTapListener } from "./touch";
import { PopupManager } from "./popupManager";
import { FabManager } from "./fabManager";
import {
OPT_SHORTCUT_TRANSLATE,
OPT_SHORTCUT_STYLE,
OPT_SHORTCUT_POPUP,
OPT_SHORTCUT_SETTING,
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_TRANBOX,
MSG_TRANSBOX_TOGGLE,
MSG_POPUP_TOGGLE,
MSG_MOUSEHOVER_TOGGLE,
MSG_TRANSINPUT_TOGGLE,
} from "../config";
import { logger } from "./log";
export default class TranslatorManager {
#clearShortcuts = [];
#menuCommandIds = [];
#clearTouchListeners = [];
#isActive = false;
#isUserscript;
#isIframe;
#windowMessageHandler = null;
#browserMessageHandler = null;
_translator;
_transboxManager;
_inputTranslator;
_popupManager;
_fabManager;
constructor({ setting, rule, fabConfig, favWords, isIframe, isUserscript }) {
this.#isIframe = isIframe;
this.#isUserscript = isUserscript;
this._translator = new Translator({
rule,
setting,
favWords,
isUserscript,
isIframe,
});
this._transboxManager = new TransboxManager(setting);
if (!isIframe) {
this._inputTranslator = new InputTranslator(setting);
this._popupManager = new PopupManager({
translator: this._translator,
processActions: this.#processActions.bind(this),
});
this._fabManager = new FabManager({
processActions: this.#processActions.bind(this),
fabConfig,
});
}
this.#windowMessageHandler = this.#handleWindowMessage.bind(this);
this.#browserMessageHandler = this.#handleBrowserMessage.bind(this);
}
start() {
if (this.#isActive) {
logger.info("TranslatorManager is already started.");
return;
}
this.#setupMessageListeners();
this.#setupTouchOperations();
if (!this.#isIframe && this.#isUserscript) {
this.#registerShortcuts();
this.#registerMenus();
}
this.#isActive = true;
logger.info("TranslatorManager started.");
}
stop() {
if (!this.#isActive) {
logger.info("TranslatorManager is not running.");
return;
}
// 移除消息监听器
if (this.#isUserscript) {
window.removeEventListener("message", this.#windowMessageHandler);
} else if (
browser.runtime.onMessage.hasListener(this.#browserMessageHandler)
) {
browser.runtime.onMessage.removeListener(this.#browserMessageHandler);
}
// 已注册的快捷键
this.#clearShortcuts.forEach((clear) => clear());
this.#clearShortcuts = [];
// 触屏
this.#clearTouchListeners.forEach((clear) => clear());
this.#clearTouchListeners = [];
// 油猴菜单
if (globalThis.GM && this.#menuCommandIds.length > 0) {
this.#menuCommandIds.forEach((id) =>
globalThis.GM.unregisterMenuCommand(id)
);
this.#menuCommandIds = [];
}
// 子模块
this._popupManager?.destroy();
this._fabManager?.destroy();
this._transboxManager?.disable();
this._inputTranslator?.disable();
this._translator.stop();
this.#isActive = false;
logger.info("TranslatorManager stopped.");
}
#setupMessageListeners() {
if (this.#isUserscript) {
window.addEventListener("message", this.#windowMessageHandler);
} else {
browser.runtime.onMessage.addListener(this.#browserMessageHandler);
if (this.#isIframe) {
window.addEventListener("message", this.#windowMessageHandler);
}
}
}
#setupTouchOperations() {
if (this.#isIframe) return;
const { touchModes = [2] } = this._translator.setting;
if (touchModes.length === 0) {
return;
}
const handleTap = () => {
this.#processActions({ action: MSG_TRANS_TOGGLE });
};
const handleListener = (mode) => {
let options = null;
switch (mode) {
case 2:
case 3:
case 4:
options = { taps: 1, fingers: mode };
break;
case 5:
options = { taps: 2, fingers: 1 };
break;
case 6:
options = { taps: 3, fingers: 1 };
break;
case 7:
options = { taps: 2, fingers: 2 };
break;
default:
}
if (options) {
this.#clearTouchListeners.push(touchTapListener(handleTap, options));
}
};
touchModes.forEach((mode) => handleListener(mode));
}
#handleWindowMessage(event) {
this.#processActions(event.data);
}
#handleBrowserMessage(message, sender, sendResponse) {
const result = this.#processActions(message, true);
const response = result || {
rule: this._translator.rule,
setting: this._translator.setting,
};
sendResponse(response);
return true;
}
#registerShortcuts() {
const { shortcuts } = this._translator.setting;
this.#clearShortcuts = [
shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () =>
this.#processActions({ action: MSG_TRANS_TOGGLE })
),
shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () =>
this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE })
),
shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () =>
this.#processActions({ action: MSG_POPUP_TOGGLE })
),
shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () =>
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank")
),
];
}
#registerMenus() {
if (!globalThis.GM) return;
const { contextMenuType, uiLang } = this._translator.setting;
if (contextMenuType === 0) return;
const i18n = newI18n(uiLang || "zh");
const GM = globalThis.GM;
this.#menuCommandIds = [
GM.registerMenuCommand(
i18n("translate_switch"),
() => this.#processActions({ action: MSG_TRANS_TOGGLE }),
"Q"
),
GM.registerMenuCommand(
i18n("toggle_style"),
() => this.#processActions({ action: MSG_TRANS_TOGGLE_STYLE }),
"C"
),
GM.registerMenuCommand(
i18n("open_menu"),
() => this.#processActions({ action: MSG_POPUP_TOGGLE }),
"K"
),
GM.registerMenuCommand(
i18n("open_setting"),
() => window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank"),
"O"
),
];
}
#processActions({ action, args } = {}, fromExt = false) {
if (!fromExt) {
sendIframeMsg(action, args);
}
switch (action) {
case MSG_TRANS_TOGGLE:
this._translator.toggle();
break;
case MSG_TRANS_TOGGLE_STYLE:
this._translator.toggleStyle();
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:
this._translator.updateRule(args);
break;
case MSG_OPEN_TRANBOX:
document.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: { action: MSG_OPEN_TRANBOX },
})
);
break;
case MSG_POPUP_TOGGLE:
this._popupManager?.toggle();
break;
case MSG_TRANSBOX_TOGGLE:
this._transboxManager?.toggle();
this._translator.toggleTransbox();
break;
case MSG_MOUSEHOVER_TOGGLE:
this._translator.toggleMouseHover();
break;
case MSG_TRANSINPUT_TOGGLE:
this._inputTranslator?.toggle();
this._translator.toggleInputTranslate();
break;
default:
logger.info(`Message action is unavailable: ${action}`);
return { error: `Message action is unavailable: ${action}` };
}
}
}

35
src/libs/trustedTypes.js Normal file
View File

@@ -0,0 +1,35 @@
import { logger } from "./log";
export const trustedTypesHelper = (() => {
const POLICY_NAME = "kiss-translator-policy";
let policy = null;
if (globalThis.trustedTypes && globalThis.trustedTypes.createPolicy) {
try {
policy = globalThis.trustedTypes.createPolicy(POLICY_NAME, {
createHTML: (string) => string,
createScript: (string) => string,
createScriptURL: (string) => string,
});
} catch (err) {
if (err.message.includes("already exists")) {
policy = globalThis.trustedTypes.policies.get(POLICY_NAME);
} else {
logger.info("cont create Trusted Types", err);
}
}
}
return {
createHTML: (htmlString) => {
return policy ? policy.createHTML(htmlString) : htmlString;
},
createScript: (scriptString) => {
return policy ? policy.createScript(scriptString) : scriptString;
},
createScriptURL: (urlString) => {
return policy ? policy.createScriptURL(urlString) : urlString;
},
isEnabled: () => policy !== null,
};
})();

View File

@@ -15,7 +15,7 @@ export const limitNumber = (num, min = 0, max = 100) => {
return number;
};
export const limitFloat = (num, min = 0, max = 100) => {
export const limitFloat = (num, min = 0.0, max = 100.0) => {
const number = parseFloat(num);
if (Number.isNaN(number) || number < min) {
return min;

View File

@@ -1,158 +0,0 @@
/**
* 修复程序类型
*/
export const FIXER_NONE = "-";
export const FIXER_BR = "br";
export const FIXER_BN = "bn";
export const FIXER_BR_DIV = "brToDiv";
export const FIXER_BN_DIV = "bnToDiv";
export const FIXER_ALL = [
FIXER_NONE,
FIXER_BR,
FIXER_BN,
FIXER_BR_DIV,
FIXER_BN_DIV,
];
/**
* 修复过的标记
*/
const fixedSign = "kiss-fixed";
/**
* 采用 `br` 换行网站的修复函数
* 目标是将 `br` 替换成 `p`
* @param {*} node
* @returns
*/
function brFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
const gapTags = ["BR", "WBR"];
const newlineTags = [
"DIV",
"UL",
"OL",
"LI",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"P",
"HR",
"PRE",
"TABLE",
"BLOCKQUOTE",
];
let html = "";
node.childNodes.forEach(function (child, index) {
if (index === 0) {
html += `<${tag} class="kiss-p">`;
}
if (gapTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}><${tag} class="kiss-p">`;
} else if (newlineTags.indexOf(child.nodeName) !== -1) {
html += `</${tag}>${child.outerHTML}<${tag} class="kiss-p">`;
} else if (child.outerHTML) {
html += child.outerHTML;
} else if (child.textContent) {
html += child.textContent;
}
if (index === node.childNodes.length - 1) {
html += `</${tag}>`;
}
});
node.innerHTML = html;
}
function brDivFixer(node) {
return brFixer(node, "div");
}
/**
* 目标是将 `\n` 替换成 `p`
* @param {*} node
* @returns
*/
function bnFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
node.setAttribute(fixedSign, "true");
node.innerHTML = node.innerHTML
.split("\n")
.map((item) => `<${tag} class="kiss-p">${item || "&nbsp;"}</${tag}>`)
.join("");
}
function bnDivFixer(node) {
return bnFixer(node, "div");
}
/**
* 查找、监听节点,并执行修复函数
* @param {*} selector
* @param {*} fixer
* @param {*} rootSelector
*/
function run(selector, fixer, rootSelector) {
const mutaObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (addNode) {
if (addNode && addNode.querySelectorAll) {
addNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
}
});
});
});
let rootNodes = [document];
if (rootSelector) {
rootNodes = document.querySelectorAll(rootSelector);
}
rootNodes.forEach(function (rootNode) {
rootNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
mutaObserver.observe(rootNode, {
childList: true,
subtree: true,
});
});
}
/**
* 修复程序映射
*/
const fixerMap = {
[FIXER_BR]: brFixer,
[FIXER_BN]: bnFixer,
[FIXER_BR_DIV]: brDivFixer,
[FIXER_BN_DIV]: bnDivFixer,
};
/**
* 执行fixer
* @param {*} param0
*/
export function runFixer(selector, fixer = "-", rootSelector) {
try {
if (Object.keys(fixerMap).includes(fixer)) {
run(selector, fixerMap[fixer], rootSelector);
}
} catch (err) {
console.error(`[kiss-webfix run]: ${err.message}`);
}
}

View File

@@ -1,5 +1,6 @@
import { logger } from "../libs/log.js";
import { truncateWords } from "../libs/utils.js";
import { apiTranslate } from "../apis/index.js";
/**
* @class BilingualSubtitleManager
@@ -8,7 +9,6 @@ import { truncateWords } from "../libs/utils.js";
export class BilingualSubtitleManager {
#videoEl;
#formattedSubtitles = [];
#translationService;
#captionWindowEl = null;
#paperEl = null;
#currentSubtitleIndex = -1;
@@ -20,14 +20,12 @@ export class BilingualSubtitleManager {
* @param {object} options
* @param {HTMLVideoElement} options.videoEl - 页面上的 video 元素。
* @param {Array<object>} options.formattedSubtitles - 已格式化好的字幕数组。
* @param {(text: string, toLang: string) => Promise<string>} options.translationService - 外部翻译函数。
* @param {object} options.setting - 配置对象,如目标翻译语言。
*/
constructor({ videoEl, formattedSubtitles, translationService, setting }) {
constructor({ videoEl, formattedSubtitles, setting }) {
this.#setting = setting;
this.#videoEl = videoEl;
this.#formattedSubtitles = formattedSubtitles;
this.#translationService = translationService;
this.onTimeUpdate = this.onTimeUpdate.bind(this);
this.onSeek = this.onSeek.bind(this);
@@ -128,15 +126,14 @@ export class BilingualSubtitleManager {
let initialBottom;
let dragElementHeight;
const onMouseDown = (e) => {
e.stopPropagation();
e.preventDefault();
const onDragStart = (e) => {
if (e.type === "mousedown" && e.button !== 0) return;
if (e.button !== 0) return;
e.preventDefault();
isDragging = true;
handleElement.style.cursor = "grabbing";
startY = e.clientY;
startY = e.type === "touchstart" ? e.touches[0].clientY : e.clientY;
initialBottom =
boundaryContainer.getBoundingClientRect().bottom -
@@ -144,17 +141,23 @@ export class BilingualSubtitleManager {
dragElementHeight = dragElement.offsetHeight;
document.addEventListener("mousemove", onMouseMove, { capture: true });
document.addEventListener("mouseup", onMouseUp, { capture: true });
document.addEventListener("mousemove", onDragMove, { capture: true });
document.addEventListener("touchmove", onDragMove, {
capture: true,
passive: false,
});
document.addEventListener("mouseup", onDragEnd, { capture: true });
document.addEventListener("touchend", onDragEnd, { capture: true });
};
const onMouseMove = (e) => {
const onDragMove = (e) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
const deltaY = e.clientY - startY;
const currentY =
e.type === "touchmove" ? e.touches[0].clientY : e.clientY;
const deltaY = currentY - startY;
let newBottom = initialBottom - deltaY;
const containerHeight = boundaryContainer.clientHeight;
@@ -167,17 +170,18 @@ export class BilingualSubtitleManager {
dragElement.style.bottom = `${newBottom}px`;
};
const onMouseUp = (e) => {
const onDragEnd = (e) => {
if (!isDragging) return;
e.preventDefault();
e.stopPropagation();
isDragging = false;
handleElement.style.cursor = "grab";
document.removeEventListener("mousemove", onMouseMove, { capture: true });
document.removeEventListener("mouseup", onMouseUp, { capture: true });
document.removeEventListener("mousemove", onDragMove, { capture: true });
document.removeEventListener("touchmove", onDragMove, { capture: true });
document.removeEventListener("mouseup", onDragEnd, { capture: true });
document.removeEventListener("touchend", onDragEnd, { capture: true });
const finalBottomPx = dragElement.style.bottom;
setTimeout(() => {
@@ -185,7 +189,10 @@ export class BilingualSubtitleManager {
}, 50);
};
handleElement.addEventListener("mousedown", onMouseDown);
handleElement.addEventListener("mousedown", onDragStart);
handleElement.addEventListener("touchstart", onDragStart, {
passive: false,
});
}
/**
@@ -258,7 +265,7 @@ export class BilingualSubtitleManager {
p1.textContent = truncateWords(subtitle.text);
const p2 = document.createElement("p");
p2.style.cssText = this.#setting.originStyle;
p2.style.cssText = this.#setting.translationStyle;
p2.textContent = truncateWords(subtitle.translation) || "...";
if (this.#setting.isBilingual) {
@@ -300,13 +307,13 @@ export class BilingualSubtitleManager {
subtitle.isTranslating = true;
try {
const { fromLang, toLang, apiSetting } = this.#setting;
const [translatedText] = await this.#translationService({
const { trText } = await apiTranslate({
text: subtitle.text,
fromLang,
toLang,
apiSetting,
});
subtitle.translation = translatedText;
subtitle.translation = trText;
} catch (error) {
logger.info("Translation failed for:", subtitle.text, error);
subtitle.translation = "[Translation failed]";

View File

@@ -1,5 +1,5 @@
import { logger } from "../libs/log.js";
import { apiSubtitle, apiTranslate } from "../apis/index.js";
import { apiSubtitle } from "../apis/index.js";
import { BilingualSubtitleManager } from "./BilingualSubtitleManager.js";
import {
MSG_XHR_DATA_YOUTUBE,
@@ -10,7 +10,7 @@ import {
import { sleep } from "../libs/utils.js";
import { createLogoSVG } from "../libs/svg.js";
import { randomBetween } from "../libs/utils.js";
import { i18n } from "../config";
import { newI18n } from "../config";
const VIDEO_SELECT = "#container video";
const CONTORLS_SELECT = ".ytp-right-controls";
@@ -33,12 +33,11 @@ class YouTubeCaptionProvider {
constructor(setting = {}) {
this.#setting = setting;
this.#i18n = i18n(setting.uiLang || "zh");
this.#i18n = newI18n(setting.uiLang || "zh");
}
initialize() {
window.addEventListener("message", (event) => {
if (event.source !== window) return;
if (event.data?.type === MSG_XHR_DATA_YOUTUBE) {
const { url, response } = event.data;
if (url && response) {
@@ -66,23 +65,54 @@ class YouTubeCaptionProvider {
});
}
get #videoEl() {
return document.querySelector(VIDEO_SELECT);
}
#moAds(adContainer) {
const adSlector = ".ytp-ad-player-overlay-layout";
const { skipAd = false } = this.#setting;
const adLayoutSelector = ".ytp-ad-player-overlay-layout";
const skipBtnSelector =
".ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern";
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
const videoEl = this.#videoEl;
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches(adLayoutSelector)) {
logger.debug("Youtube Provider: AD start playing!", node);
// todo: 顺带把广告快速跳过
if (videoEl && skipAd) {
videoEl.playbackRate = 16;
videoEl.currentTime = videoEl.duration;
}
if (this.#managerInstance) {
this.#managerInstance.setIsAdPlaying(true);
}
} else if (node.matches(skipBtnSelector) && skipAd) {
logger.debug("Youtube Provider: AD skip button!", node);
node.click();
}
if (skipAd) {
const skipBtn = node?.querySelector(skipBtnSelector);
if (skipBtn) {
logger.debug("Youtube Provider: AD skip button!!", skipBtn);
skipBtn.click();
}
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === 1 && node.matches(adSlector)) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.matches(adLayoutSelector)) {
logger.debug("Youtube Provider: Ad ends!");
if (videoEl && skipAd) {
videoEl.playbackRate = 1;
}
if (this.#managerInstance) {
this.#managerInstance.setIsAdPlaying(false);
}
@@ -135,14 +165,13 @@ class YouTubeCaptionProvider {
this.#ytControls = ytControls;
const kissControls = document.createElement("div");
kissControls.className = "kiss-bilingual-subtitle-controls";
kissControls.className = "notranslate kiss-subtitle-controls";
Object.assign(kissControls.style, {
height: "100%",
});
const toggleButton = document.createElement("button");
toggleButton.className =
"ytp-button notranslate kiss-bilingual-subtitle-button";
toggleButton.className = "ytp-button kiss-subtitle-button";
toggleButton.title = APP_NAME;
Object.assign(toggleButton.style, {
color: "white",
@@ -173,7 +202,7 @@ class YouTubeCaptionProvider {
}
};
this.#toggleButton = toggleButton;
this.#ytControls?.before(kissControls);
this.#ytControls?.prepend(kissControls);
}
#isSameLang(lang1, lang2) {
@@ -468,7 +497,7 @@ class YouTubeCaptionProvider {
return;
}
const videoEl = document.querySelector(VIDEO_SELECT);
const videoEl = this.#videoEl;
if (!videoEl) {
logger.warn("Youtube Provider: No video element found");
return;
@@ -479,7 +508,6 @@ class YouTubeCaptionProvider {
this.#managerInstance = new BilingualSubtitleManager({
videoEl,
formattedSubtitles: this.#subtitles,
translationService: apiTranslate,
setting: { ...this.#setting, fromLang: this.#fromLang },
});
this.#managerInstance.start();
@@ -564,7 +592,7 @@ class YouTubeCaptionProvider {
return subtitles;
}
#isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.1) {
#isQualityPoor(lines, lengthThreshold = 250, percentageThreshold = 0.2) {
if (lines.length === 0) return false;
const longLinesCount = lines.filter(
(line) => line.text.length > lengthThreshold
@@ -879,7 +907,7 @@ class YouTubeCaptionProvider {
textAlign: "center",
});
const videoEl = document.querySelector(VIDEO_SELECT);
const videoEl = this.#videoEl;
const videoContainer = videoEl?.parentElement?.parentElement;
if (videoContainer) {
videoContainer.appendChild(notificationEl);
@@ -887,7 +915,7 @@ class YouTubeCaptionProvider {
}
}
#showNotification(message, duration = 3000) {
#showNotification(message, duration = 2000) {
if (!this.#notificationEl) this.#createNotificationElement();
this.#notificationEl.textContent = message;
this.#notificationEl.style.opacity = "1";

View File

@@ -1,10 +1,9 @@
import { YouTubeInitializer } from "./YouTubeCaptionProvider.js";
import { browser } from "../libs/browser.js";
import { isMatch } from "../libs/utils.js";
import { DEFAULT_API_SETTING } from "../config/api.js";
import { DEFAULT_SUBTITLE_SETTING } from "../config/setting.js";
import { injectExternalJs } from "../libs/injector.js";
import { logger } from "../libs/log.js";
import { injectJs, INJECTOR } from "../injectors/index.js";
const providers = [
{ pattern: "https://www.youtube.com", start: YouTubeInitializer },
@@ -19,9 +18,8 @@ export function runSubtitle({ href, setting }) {
const provider = providers.find((item) => isMatch(href, item.pattern));
if (provider) {
const id = "kiss-translator-injector";
const src = browser.runtime.getURL("injector.js");
injectExternalJs(src, id);
const id = "kiss-translator-inject-subtitle-js";
injectJs(INJECTOR.subtitle, id);
const apiSetting =
setting.transApis.find(

View File

@@ -0,0 +1,70 @@
import Fab from "@mui/material/Fab";
import TranslateIcon from "@mui/icons-material/Translate";
import ThemeProvider from "../../hooks/Theme";
import Draggable from "./Draggable";
import { useState, useMemo, useCallback } from "react";
import { SettingProvider } from "../../hooks/Setting";
import { MSG_TRANS_TOGGLE, MSG_POPUP_TOGGLE } from "../../config";
import useWindowSize from "../../hooks/WindowSize";
export default function ContentFab({
fabConfig: { x: fabX, y: fabY, fabClickAction = 0 } = {},
processActions,
}) {
const fabWidth = 40;
const windowSize = useWindowSize();
const [moved, setMoved] = useState(false);
const handleStart = useCallback(() => {
setMoved(false);
}, []);
const handleMove = useCallback(() => {
setMoved(true);
}, []);
const handleClick = useCallback(() => {
if (!moved) {
if (fabClickAction === 1) {
processActions({ action: MSG_TRANS_TOGGLE });
} else {
processActions({ action: MSG_POPUP_TOGGLE });
}
}
}, [moved, fabClickAction, processActions]);
const fabProps = useMemo(
() => ({
windowSize,
width: fabWidth,
height: fabWidth,
left: fabX ?? -fabWidth,
top: fabY ?? windowSize.h / 2,
}),
[windowSize, fabWidth, fabX, fabY]
);
return (
<SettingProvider>
<ThemeProvider>
<Draggable
key="fab"
snapEdge
{...fabProps}
onStart={handleStart}
onMove={handleMove}
handler={
<Fab size="small" color="primary" onClick={handleClick}>
<TranslateIcon
sx={{
width: 24,
height: 24,
}}
/>
</Fab>
}
/>
</ThemeProvider>
</SettingProvider>
);
}

View File

@@ -50,7 +50,7 @@ export default function Draggable({
height,
left,
top,
show,
show = true,
snapEdge,
onStart,
onMove,

View File

@@ -1,168 +1,62 @@
import Fab from "@mui/material/Fab";
import TranslateIcon from "@mui/icons-material/Translate";
import ThemeProvider from "../../hooks/Theme";
import Draggable from "./Draggable";
import { useEffect, useState, useMemo, useCallback } from "react";
import { useEffect, useMemo, useCallback, useState } from "react";
import { SettingProvider } from "../../hooks/Setting";
import Popup from "../Popup";
import { debounce } from "../../libs/utils";
import { isGm } from "../../libs/client";
import Header from "../Popup/Header";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import {
DEFAULT_SHORTCUTS,
OPT_SHORTCUT_TRANSLATE,
OPT_SHORTCUT_STYLE,
OPT_SHORTCUT_POPUP,
OPT_SHORTCUT_SETTING,
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
} from "../../config";
import { shortcutRegister } from "../../libs/shortcut";
import { sendIframeMsg } from "../../libs/iframe";
import { kissLog } from "../../libs/log";
import { getI18n } from "../../hooks/I18n";
import useWindowSize from "../../hooks/WindowSize";
import { EVENT_KISS, MSG_OPEN_OPTIONS, MSG_POPUP_TOGGLE } from "../../config";
import PopupCont from "../Popup/PopupCont";
import { isExt } from "../../libs/client";
import { sendBgMsg } from "../../libs/msg";
export default function Action({ translator, fab }) {
const fabWidth = 40;
const [showPopup, setShowPopup] = useState(false);
const [windowSize, setWindowSize] = useState({
w: window.innerWidth,
h: window.innerHeight,
});
const [moved, setMoved] = useState(false);
export default function Action({ translator, processActions }) {
const [showPopup, setShowPopup] = useState(true);
const [rule, setRule] = useState(translator.rule);
const [setting, setSetting] = useState(translator.setting);
const windowSize = useWindowSize();
const { fabClickAction = 0 } = fab || {};
const handleWindowResize = useMemo(
() =>
debounce(() => {
setWindowSize({
w: window.innerWidth,
h: window.innerHeight,
});
}),
[]
);
const handleWindowClick = (e) => {
setShowPopup(false);
};
const handleStart = useCallback(() => {
setMoved(false);
}, []);
const handleMove = useCallback(() => {
setMoved(true);
const handleOpenSetting = useCallback(() => {
if (isExt) {
sendBgMsg(MSG_OPEN_OPTIONS);
} else {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}
}, []);
useEffect(() => {
if (!isGm) {
return;
}
// 注册快捷键
const shortcuts = translator.setting.shortcuts || DEFAULT_SHORTCUTS;
const clearShortcuts = [
shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () => {
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
setShowPopup(false);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () => {
setShowPopup((pre) => !pre);
}),
shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}),
];
return () => {
clearShortcuts.forEach((fn) => {
fn();
});
const handleWindowClick = () => {
setShowPopup(false);
};
}, [translator]);
useEffect(() => {
if (!isGm) {
return;
}
// 注册菜单
try {
const menuCommandIds = [];
const { contextMenuType, uiLang } = translator.setting;
contextMenuType !== 0 &&
menuCommandIds.push(
GM.registerMenuCommand(
getI18n(uiLang, "translate_switch"),
(event) => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
},
"Q"
),
GM.registerMenuCommand(
getI18n(uiLang, "toggle_style"),
(event) => {
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
setShowPopup(false);
},
"C"
),
GM.registerMenuCommand(
getI18n(uiLang, "open_menu"),
(event) => {
setShowPopup((pre) => !pre);
},
"K"
),
GM.registerMenuCommand(
getI18n(uiLang, "open_setting"),
(event) => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
},
"O"
)
);
return () => {
menuCommandIds.forEach((id) => {
GM.unregisterMenuCommand(id);
});
};
} catch (err) {
kissLog("registerMenuCommand", err);
}
}, [translator]);
useEffect(() => {
window.addEventListener("resize", handleWindowResize);
return () => {
window.removeEventListener("resize", handleWindowResize);
};
}, [handleWindowResize]);
useEffect(() => {
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, []);
useEffect(() => {
const handleStatusUpdate = (event) => {
if (event.detail?.action === MSG_POPUP_TOGGLE) {
setShowPopup((pre) => !pre);
}
};
document.addEventListener(EVENT_KISS, handleStatusUpdate);
return () => {
document.removeEventListener(EVENT_KISS, handleStatusUpdate);
};
}, []);
useEffect(() => {
if (showPopup) {
setRule(translator.rule);
setSetting(translator.setting);
}
}, [showPopup, translator]);
const popProps = useMemo(() => {
const width = Math.min(windowSize.w, 300);
const width = Math.min(windowSize.w, 360);
const height = Math.min(windowSize.h, 442);
const left = (windowSize.w - width) / 2;
const top = (windowSize.h - height) / 2;
@@ -175,67 +69,38 @@ export default function Action({ translator, fab }) {
};
}, [windowSize]);
const fabProps = {
windowSize,
width: fabWidth,
height: fabWidth,
left: fab.x ?? -fabWidth,
top: fab.y ?? windowSize.h / 2,
};
return (
<SettingProvider>
<ThemeProvider>
<Draggable
key="pop"
{...popProps}
show={showPopup}
onStart={handleStart}
onMove={handleMove}
usePaper
handler={
<Box style={{ cursor: "move" }}>
<Header setShowPopup={setShowPopup} />
<Divider />
</Box>
}
>
{showPopup && (
<Popup setShowPopup={setShowPopup} translator={translator} />
)}
</Draggable>
<Draggable
key="fab"
snapEdge
{...fabProps}
show={fab.isHide ? false : !showPopup}
onStart={handleStart}
onMove={handleMove}
handler={
<Fab
size="small"
color="primary"
onClick={(e) => {
if (!moved) {
if (fabClickAction === 1) {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
{showPopup && (
<Draggable
key="pop"
{...popProps}
usePaper
handler={
<Box style={{ cursor: "move" }}>
<Header
onClose={() => {
setShowPopup(false);
} else {
setShowPopup((pre) => !pre);
}
}
}}
>
<TranslateIcon
sx={{
width: 24,
height: 24,
}}
}}
/>
<Divider />
</Box>
}
>
<Box width={360}>
<PopupCont
rule={rule}
setting={setting}
setRule={setRule}
setSetting={setSetting}
handleOpenSetting={handleOpenSetting}
processActions={processActions}
isContent={true}
/>
</Fab>
}
/>
</Box>
</Draggable>
)}
</ThemeProvider>
</SettingProvider>
);

View File

@@ -17,6 +17,7 @@ import Alert from "@mui/material/Alert";
import Menu from "@mui/material/Menu";
import Grid from "@mui/material/Grid";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import Link from "@mui/material/Link";
import { useAlert } from "../../hooks/Alert";
import { useApiList, useApiItem } from "../../hooks/Api";
import { useConfirm } from "../../hooks/Confirm";
@@ -26,7 +27,7 @@ import ReusableAutocomplete from "./ReusableAutocomplete";
import ShowMoreButton from "./ShowMoreButton";
import {
OPT_TRANS_DEEPLX,
OPT_TRANS_OLLAMA,
// OPT_TRANS_OLLAMA,
OPT_TRANS_CUSTOMIZE,
OPT_TRANS_NIUTRANS,
OPT_TRANS_BUILTINAI,
@@ -37,12 +38,14 @@ import {
DEFAULT_BATCH_SIZE,
DEFAULT_BATCH_LENGTH,
DEFAULT_CONTEXT_SIZE,
OPT_ALL_TYPES,
OPT_ALL_TRANS_TYPES,
API_SPE_TYPES,
BUILTIN_STONES,
BUILTIN_PLACEHOLDERS,
BUILTIN_PLACETAGS,
OPT_TRANS_AZUREAI,
defaultNobatchPrompt,
defaultNobatchUserPrompt,
} from "../../config";
import ValidationInput from "../../hooks/ValidationInput";
@@ -53,18 +56,25 @@ function TestButton({ api }) {
const handleApiTest = async () => {
try {
setLoading(true);
const [text] = await apiTranslate({
text: "hello world",
const text = "hello world";
const { trText } = await apiTranslate({
text,
fromLang: "en",
toLang: "zh-CN",
apiSetting: { ...api },
useCache: false,
usePool: false,
});
if (!text) {
if (!trText) {
throw new Error("empty result");
}
alert.success(i18n("test_success"));
alert.success(
<>
<div>{i18n("test_success")}</div>
<div>{text}</div>
<div>{trText}</div>
</>
);
} catch (err) {
// alert.error(`${i18n("test_failed")}: ${err.message}`);
let msg = err.message;
@@ -76,24 +86,7 @@ function TestButton({ api }) {
alert.error(
<>
<div>{i18n("test_failed")}</div>
{msg === err.message ? (
<div
style={{
maxWidth: 400,
}}
>
{msg}
</div>
) : (
<pre
style={{
maxWidth: 400,
overflow: "auto",
}}
>
{msg}
</pre>
)}
{msg === err.message ? <div>{msg}</div> : <pre>{msg}</pre>}
</>
);
} finally {
@@ -180,12 +173,14 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
model = "",
apiType,
systemPrompt = "",
nobatchPrompt = defaultNobatchPrompt,
nobatchUserPrompt = defaultNobatchUserPrompt,
subtitlePrompt = "",
// userPrompt = "",
customHeader = "",
customBody = "",
think = false,
thinkIgnore = "",
// think = false,
// thinkIgnore = "",
fetchLimit = DEFAULT_FETCH_LIMIT,
fetchInterval = DEFAULT_FETCH_INTERVAL,
httpTimeout = DEFAULT_HTTP_TIMEOUT,
@@ -194,7 +189,7 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
reqHook = "",
resHook = "",
temperature = 0,
maxTokens = 256,
maxTokens = 20480,
apiName = "",
isDisabled = false,
useBatchFetch = false,
@@ -263,7 +258,7 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
/>
)}
{API_SPE_TYPES.ai.has(apiType) && (
{(API_SPE_TYPES.ai.has(apiType) || apiType === OPT_TRANS_CUSTOMIZE) && (
<>
<Box>
<Grid container spacing={2} columns={12}>
@@ -299,37 +294,62 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
name="temperature"
value={temperature}
onChange={handleChange}
min={0}
max={2}
min={0.0}
max={2.0}
isFloat={true}
/>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<ValidationInput
size="small"
fullWidth
label={"Max Tokens"}
label={"Max Tokens (0-1000000)"}
type="number"
name="maxTokens"
value={maxTokens}
onChange={handleChange}
min={0}
max={2 ** 15}
max={1000000}
/>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}></Grid>
</Grid>
</Box>
<TextField
size="small"
label={"SYSTEM PROMPT"}
name="systemPrompt"
value={systemPrompt}
onChange={handleChange}
multiline
maxRows={10}
helperText={i18n("system_prompt_helper")}
/>
{useBatchFetch ? (
<TextField
size="small"
label={"BATCH SYSTEM PROMPT"}
name="systemPrompt"
value={systemPrompt}
onChange={handleChange}
multiline
maxRows={10}
helperText={i18n("system_prompt_helper")}
/>
) : (
<>
<TextField
size="small"
label={"SYSTEM PROMPT"}
name="nobatchPrompt"
value={nobatchPrompt}
onChange={handleChange}
multiline
maxRows={10}
/>
<TextField
size="small"
label={"USER PROMPT"}
name="nobatchUserPrompt"
value={nobatchUserPrompt}
onChange={handleChange}
multiline
maxRows={10}
/>
</>
)}
<TextField
size="small"
label={"SUBTITLE PROMPT"}
@@ -352,7 +372,7 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
</>
)}
{apiType === OPT_TRANS_OLLAMA && (
{/* {apiType === OPT_TRANS_OLLAMA && (
<>
<TextField
select
@@ -373,7 +393,7 @@ function ApiFields({ apiSlug, isUserApi, deleteApi }) {
onChange={handleChange}
/>
</>
)}
)} */}
{apiType === OPT_TRANS_NIUTRANS && (
<>
@@ -773,7 +793,7 @@ export default function Apis() {
const apiTypes = useMemo(
() =>
OPT_ALL_TYPES.map((type) => ({
OPT_ALL_TRANS_TYPES.map((type) => ({
type,
label: type,
})),
@@ -805,6 +825,12 @@ export default function Apis() {
{i18n("about_api_2")}
<br />
{i18n("about_api_3")}
<Link
href="https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md"
target="_blank"
>
{i18n("goto_custom_api_example")}
</Link>
</Alert>
<Box>

View File

@@ -2,12 +2,19 @@ import IconButton from "@mui/material/IconButton";
import { useDarkMode } from "../../hooks/ColorMode";
import LightModeIcon from "@mui/icons-material/LightMode";
import DarkModeIcon from "@mui/icons-material/DarkMode";
import BrightnessAutoIcon from "@mui/icons-material/BrightnessAuto";
export default function DarkModeButton() {
const { darkMode, toggleDarkMode } = useDarkMode();
return (
<IconButton onClick={toggleDarkMode} color="inherit">
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
<IconButton sx={{ ml: 1 }} onClick={toggleDarkMode} color="inherit">
{darkMode === "dark" ? (
<DarkModeIcon />
) : darkMode === "light" ? (
<LightModeIcon />
) : (
<BrightnessAutoIcon />
)}
</IconButton>
);
}

View File

@@ -40,7 +40,7 @@ export default function Layout() {
/>
</Box>
<Box component="main" sx={{ flex: 1, p: 2 }}>
<Box component="main" sx={{ flex: 1, p: 2, width: "100%" }}>
<Outlet />
</Box>
</Box>

View File

@@ -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";
@@ -97,11 +102,13 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
ignoreSelector = "",
terms = "",
aiTerms = "",
termsStyle = "",
highlightStyle = "color: red;",
selectStyle = "",
parentStyle = "",
grandStyle = "",
injectJs = "",
injectCss = "",
// injectCss = "",
apiSlug,
fromLang,
toLang,
@@ -123,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(() => {
@@ -422,6 +432,59 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="splitParagraph"
value={splitParagraph}
label={i18n("split_paragraph")}
disabled={disabled}
onChange={handleChange}
>
{GlobalItem}
{OPT_SPLIT_PARAGRAPH_ALL.map((item) => (
<MenuItem key={item} value={item}>
{i18n(item)}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<ValidationInput
fullWidth
size="small"
label={i18n("split_length")}
type="number"
name="splitLength"
value={splitLength}
disabled={disabled}
onChange={handleChange}
min={0}
max={1000}
/>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="highlightWords"
value={highlightWords}
label={i18n("highlight_words")}
disabled={disabled}
onChange={handleChange}
>
{GlobalItem}
{OPT_HIGHLIGHT_WORDS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{i18n(item)}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
@@ -547,10 +610,29 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
maxRows={10}
/>
<TextField
size="small"
label={i18n("terms_style")}
name="termsStyle"
value={termsStyle}
disabled={disabled}
onChange={handleChange}
maxRows={10}
multiline
/>
<TextField
size="small"
label={i18n("highlight_style")}
name="highlightStyle"
value={highlightStyle}
disabled={disabled}
onChange={handleChange}
maxRows={10}
multiline
/>
<TextField
size="small"
label={i18n("selector_style")}
helperText={i18n("selector_style_helper")}
name="selectStyle"
value={selectStyle}
disabled={disabled}
@@ -561,7 +643,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
<TextField
size="small"
label={i18n("selector_parent_style")}
helperText={i18n("selector_style_helper")}
name="parentStyle"
value={parentStyle}
disabled={disabled}
@@ -572,7 +653,6 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
<TextField
size="small"
label={i18n("selector_grand_style")}
helperText={i18n("selector_style_helper")}
name="grandStyle"
value={grandStyle}
disabled={disabled}
@@ -615,7 +695,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
maxRows={10}
/> */}
<TextField
{/* <TextField
size="small"
label={i18n("inject_css")}
helperText={i18n("inject_css_helper")}
@@ -625,7 +705,7 @@ function RuleFields({ rule, rules, setShow, setKeyword }) {
onChange={handleChange}
maxRows={10}
multiline
/>
/> */}
<TextField
size="small"
label={i18n("inject_js")}
@@ -867,9 +947,9 @@ function UserRules({ subRules, rules }) {
<UploadButton text={i18n("import")} handleImport={handleImport} />
<DownloadButton
handleData={() => JSON.stringify([...rules.list].reverse(), null, 2)}
handleData={() => JSON.stringify([...rules.list], null, 2)}
text={i18n("export")}
fileName={`kiss-rules_${Date.now()}.json`}
fileName={`kiss-rules_v2_${Date.now()}.json`}
/>
<DownloadButton
handleData={async () => JSON.stringify(await getRulesOld(), null, 2)}

View File

@@ -94,7 +94,7 @@ export default function Settings() {
newlineLength = TRANS_NEWLINE_LENGTH,
httpTimeout = DEFAULT_HTTP_TIMEOUT,
contextMenuType = 1,
touchTranslate = 2,
touchModes = [2],
blacklist = DEFAULT_BLACKLIST.join(",\n"),
csplist = DEFAULT_CSPLIST.join(",\n"),
orilist = DEFAULT_ORILIST.join(",\n"),
@@ -105,6 +105,7 @@ export default function Settings() {
skipLangs = [],
// detectRemote = true,
transAllnow = false,
rootMargin = 500,
} = setting;
const { isHide = false, fabClickAction = 0 } = fab || {};
@@ -124,7 +125,7 @@ export default function Settings() {
<DownloadButton
handleData={() => JSON.stringify(setting, null, 2)}
text={i18n("export")}
fileName={`kiss-setting_${Date.now()}.json`}
fileName={`kiss-setting_v2_${Date.now()}.json`}
/>
<DownloadButton
handleData={async () =>
@@ -268,12 +269,15 @@ export default function Settings() {
select
fullWidth
size="small"
name="touchTranslate"
value={touchTranslate}
name="touchModes"
value={touchModes}
label={i18n("touch_translate_shortcut")}
onChange={handleChange}
SelectProps={{
multiple: true,
}}
>
{[0, 2, 3, 4].map((item) => (
{[0, 2, 3, 4, 5, 6, 7].map((item) => (
<MenuItem key={item} value={item}>
{i18n(`touch_tap_${item}`)}
</MenuItem>
@@ -295,34 +299,6 @@ export default function Settings() {
<MenuItem value={2}>{i18n("secondary_context_menus")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="transAllnow"
value={transAllnow}
label={i18n("trigger_mode")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("mk_pagescroll")}</MenuItem>
<MenuItem value={true}>{i18n("mk_pageopen")}</MenuItem>
</TextField>
</Grid>
{/* <Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="detectRemote"
value={detectRemote}
label={i18n("detect_lang_remote")}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid> */}
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
@@ -341,6 +317,47 @@ export default function Settings() {
))}
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="transAllnow"
value={transAllnow}
label={i18n("trigger_mode")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("mk_pagescroll")}</MenuItem>
<MenuItem value={true}>{i18n("mk_pageopen")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<ValidationInput
fullWidth
size="small"
label={i18n("pagescroll_root_margin")}
type="number"
name="rootMargin"
value={rootMargin}
onChange={handleChange}
min={0}
max={10000}
/>
</Grid>
{/* <Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
size="small"
fullWidth
name="detectRemote"
value={detectRemote}
label={i18n("detect_lang_remote")}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid> */}
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
select
@@ -379,18 +396,19 @@ export default function Settings() {
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={i18n("translate_blacklist")}
helperText={i18n("pattern_helper")}
name="blacklist"
value={blacklist}
onChange={handleChange}
maxRows={10}
multiline
/>
{isExt ? (
<>
<TextField
size="small"
label={i18n("disabled_orilist")}
helperText={i18n("pattern_helper")}
name="orilist"
value={orilist}
onChange={handleChange}
multiline
/>
<TextField
select
fullWidth
@@ -409,6 +427,15 @@ export default function Settings() {
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
</TextField>
<TextField
size="small"
label={i18n("disabled_orilist")}
helperText={i18n("pattern_helper")}
name="orilist"
value={orilist}
onChange={handleChange}
multiline
/>
<TextField
size="small"
label={i18n("disabled_csplist")}
@@ -453,17 +480,6 @@ export default function Settings() {
</Box>
</>
)}
<TextField
size="small"
label={i18n("translate_blacklist")}
helperText={i18n("pattern_helper")}
name="blacklist"
value={blacklist}
onChange={handleChange}
maxRows={10}
multiline
/>
</Stack>
</Box>
);

View File

@@ -32,6 +32,7 @@ export default function SubtitleSetting() {
chunkLength,
toLang,
isBilingual,
skipAd = false,
windowStyle,
originStyle,
translationStyle,
@@ -145,6 +146,20 @@ export default function SubtitleSetting() {
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
fullWidth
select
size="small"
name="skipAd"
value={skipAd}
label={i18n("is_skip_ad")}
onChange={handleChange}
>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
</TextField>
</Grid>
</Grid>
</Box>

View File

@@ -68,6 +68,7 @@ export default function Tranbox() {
hideClickAway = false,
simpleStyle = false,
followSelection = false,
autoHeight = false,
triggerMode = OPT_TRANBOX_TRIGGER_CLICK,
// extStyles = "",
enDict = OPT_DICT_BING,
@@ -330,6 +331,20 @@ export default function Tranbox() {
max={200}
/>
</Grid>
<Grid item xs={12} sm={12} md={6} lg={3}>
<TextField
fullWidth
select
size="small"
name="autoHeight"
value={autoHeight}
label={i18n("tranbox_auto_height")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</TextField>
</Grid>
{!isExt && (
<Grid item xs={12} sm={12} md={6} lg={3}>
<ShortcutInput

View File

@@ -5,7 +5,7 @@ import Stack from "@mui/material/Stack";
import DarkModeButton from "../Options/DarkModeButton";
import Typography from "@mui/material/Typography";
export default function Header({ setShowPopup }) {
export default function Header({ onClose }) {
const handleHomepage = () => {
window.open(process.env.REACT_APP_HOMEPAGE, "_blank");
};
@@ -33,10 +33,10 @@ export default function Header({ setShowPopup }) {
</Typography>
</Stack>
{setShowPopup ? (
{onClose ? (
<IconButton
onClick={() => {
setShowPopup(false);
onClose();
}}
>
<CloseIcon />

View File

@@ -0,0 +1,422 @@
import { useState, useEffect, useMemo } from "react";
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import { sendBgMsg, sendTabMsg, getCurTab } from "../../libs/msg";
import { isExt } from "../../libs/client";
import { useI18n } from "../../hooks/I18n";
import TextField from "@mui/material/TextField";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_PUTRULE,
MSG_SAVE_RULE,
MSG_COMMAND_SHORTCUTS,
MSG_TRANSBOX_TOGGLE,
MSG_MOUSEHOVER_TOGGLE,
MSG_TRANSINPUT_TOGGLE,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
} from "../../config";
import { saveRule } from "../../libs/rules";
import { tryClearCaches } from "../../libs/cache";
import { kissLog } from "../../libs/log";
import { parseUrlPattern } from "../../libs/utils";
export default function PopupCont({
rule,
setting,
setRule,
setSetting,
handleOpenSetting,
processActions,
isContent = false,
}) {
const i18n = useI18n();
const [commands, setCommands] = useState({});
const handleTransToggle = async (e) => {
try {
setRule({ ...rule, transOpen: e.target.checked ? "true" : "false" });
if (!processActions) {
await sendTabMsg(MSG_TRANS_TOGGLE);
} else {
processActions({ action: MSG_TRANS_TOGGLE });
}
} catch (err) {
kissLog("toggle trans", err);
}
};
const handleTransboxToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
tranboxSetting: { ...pre.tranboxSetting, transOpen: e.target.checked },
}));
if (!processActions) {
await sendTabMsg(MSG_TRANSBOX_TOGGLE);
} else {
processActions({ action: MSG_TRANSBOX_TOGGLE });
}
} catch (err) {
kissLog("toggle transbox", err);
}
};
const handleMousehoverToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
mouseHoverSetting: {
...pre.mouseHoverSetting,
useMouseHover: e.target.checked,
},
}));
if (!processActions) {
await sendTabMsg(MSG_MOUSEHOVER_TOGGLE);
} else {
processActions({ action: MSG_MOUSEHOVER_TOGGLE });
}
} catch (err) {
kissLog("toggle mousehover", err);
}
};
const handleInputTransToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
inputRule: {
...pre.inputRule,
transOpen: e.target.checked,
},
}));
if (!processActions) {
await sendTabMsg(MSG_TRANSINPUT_TOGGLE);
} else {
processActions({ action: MSG_TRANSINPUT_TOGGLE });
}
} catch (err) {
kissLog("toggle inputtrans", err);
}
};
const handleChange = async (e) => {
try {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
if (!processActions) {
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} else {
processActions({ action: MSG_TRANS_PUTRULE, args: { [name]: value } });
}
} catch (err) {
kissLog("update rule", err);
}
};
const handleClearCache = () => {
tryClearCaches();
};
const handleSaveRule = async () => {
try {
let href = "";
if (!isContent) {
const tab = await getCurTab();
href = tab.url;
} else {
href = window.location?.href;
}
if (!href || typeof href !== "string") {
return;
}
const pattern = parseUrlPattern(href);
const curRule = { ...rule, pattern };
if (isExt && isContent) {
sendBgMsg(MSG_SAVE_RULE, curRule);
} else {
saveRule(curRule);
}
} catch (err) {
kissLog("save rule", err);
}
};
useEffect(() => {
(async () => {
try {
const commands = {};
if (isExt) {
const res = await sendBgMsg(MSG_COMMAND_SHORTCUTS);
res.forEach(({ name, shortcut }) => {
commands[name] = shortcut;
});
} else {
const shortcuts = setting.shortcuts;
if (shortcuts) {
Object.entries(shortcuts).forEach(([key, val]) => {
commands[key] = val.join("+");
});
}
}
setCommands(commands);
} catch (err) {
kissLog("query cmds", err);
}
})();
}, [setting.shortcuts]);
const optApis = useMemo(
() =>
setting.transApis
.filter((api) => !api.isDisabled)
.map((api) => ({
key: api.apiSlug,
name: api.apiName || api.apiSlug,
})),
[setting.transApis]
);
const tranboxEnabled = setting.tranboxSetting.transOpen;
const mouseHoverEnabled = setting.mouseHoverSetting.useMouseHover;
const inputTransEnabled = setting.inputRule.transOpen;
const {
transOpen,
apiSlug,
fromLang,
toLang,
textStyle,
autoScan,
transOnly,
hasRichText,
hasShadowroot,
} = rule;
return (
<Stack sx={{ p: 2 }} spacing={2}>
<Grid container columns={12} spacing={1}>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={transOpen === "true"}
onChange={handleTransToggle}
/>
}
label={
commands["toggleTranslate"]
? `${i18n("translate_alt")}(${commands["toggleTranslate"]})`
: i18n("translate_alt")
}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="autoScan"
value={autoScan === "true" ? "false" : "true"}
checked={autoScan === "true"}
onChange={handleChange}
/>
}
label={i18n("autoscan_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="hasShadowroot"
value={hasShadowroot === "true" ? "false" : "true"}
checked={hasShadowroot === "true"}
onChange={handleChange}
/>
}
label={i18n("shadowroot_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="hasRichText"
value={hasRichText === "true" ? "false" : "true"}
checked={hasRichText === "true"}
onChange={handleChange}
/>
}
label={i18n("richtext_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="transOnly"
value={transOnly === "true" ? "false" : "true"}
checked={transOnly === "true"}
onChange={handleChange}
/>
}
label={i18n("transonly_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="tranboxEnabled"
value={!tranboxEnabled}
checked={tranboxEnabled}
onChange={handleTransboxToggle}
/>
}
label={i18n("selection_translate")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="mouseHoverEnabled"
value={!mouseHoverEnabled}
checked={mouseHoverEnabled}
onChange={handleMousehoverToggle}
/>
}
label={i18n("mousehover_translate")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="inputTransEnabled"
value={!inputTransEnabled}
checked={inputTransEnabled}
onChange={handleInputTransToggle}
/>
}
label={i18n("input_translate")}
/>
</Grid>
</Grid>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={handleChange}
>
{optApis.map(({ key, name }) => (
<MenuItem key={key} value={key}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={fromLang}
name="fromLang"
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={toLang}
name="toLang"
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={textStyle}
name="textStyle"
label={
commands["toggleStyle"]
? `${i18n("text_style_alt")}(${commands["toggleStyle"]})`
: i18n("text_style_alt")
}
onChange={handleChange}
>
{OPT_STYLE_ALL.map((item) => (
<MenuItem key={item} value={item}>
{i18n(item)}
</MenuItem>
))}
</TextField>
{/* {OPT_STYLE_USE_COLOR.includes(textStyle) && (
<TextField
size="small"
name="bgColor"
value={bgColor}
label={i18n("bg_color")}
onChange={handleChange}
/>
)} */}
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={2}
>
<Button variant="text" onClick={handleSaveRule}>
{i18n("save_rule")}
</Button>
<Button variant="text" onClick={handleClearCache}>
{i18n("clear_cache")}
</Button>
<Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")}
</Button>
</Stack>
</Stack>
);
}

View File

@@ -1,182 +1,26 @@
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useCallback } from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import MenuItem from "@mui/material/MenuItem";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import { sendBgMsg, sendTabMsg, getCurTab } from "../../libs/msg";
import { sendTabMsg } from "../../libs/msg";
import { browser } from "../../libs/browser";
import { isExt } from "../../libs/client";
import { useI18n } from "../../hooks/I18n";
import TextField from "@mui/material/TextField";
import Divider from "@mui/material/Divider";
import Header from "./Header";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_OPTIONS,
MSG_SAVE_RULE,
MSG_COMMAND_SHORTCUTS,
MSG_TRANSBOX_TOGGLE,
MSG_MOUSEHOVER_TOGGLE,
MSG_TRANSINPUT_TOGGLE,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
} from "../../config";
import { sendIframeMsg } from "../../libs/iframe";
import { saveRule } from "../../libs/rules";
import { tryClearCaches } from "../../libs/cache";
import { MSG_TRANS_GETRULE } from "../../config";
import { kissLog } from "../../libs/log";
import { parseUrlPattern } from "../../libs/utils";
import PopupCont from "./PopupCont";
// 插件popup没有参数
// 网页弹框有
export default function Popup({ setShowPopup, translator }) {
export default function Popup() {
const i18n = useI18n();
const [rule, setRule] = useState(translator?.rule);
const [setting, setSetting] = useState(translator?.setting);
const [commands, setCommands] = useState({});
const [rule, setRule] = useState(null);
const [setting, setSetting] = useState(null);
const handleOpenSetting = () => {
if (!translator) {
browser?.runtime.openOptionsPage();
} else if (isExt) {
sendBgMsg(MSG_OPEN_OPTIONS);
} else {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}
setShowPopup && setShowPopup(false);
};
const handleTransToggle = async (e) => {
try {
setRule({ ...rule, transOpen: e.target.checked ? "true" : "false" });
if (!translator) {
await sendTabMsg(MSG_TRANS_TOGGLE);
} else {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
}
} catch (err) {
kissLog("toggle trans", err);
}
};
const handleTransboxToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
tranboxSetting: { ...pre.tranboxSetting, transOpen: e.target.checked },
}));
if (!translator) {
await sendTabMsg(MSG_TRANSBOX_TOGGLE);
} else {
translator.toggleTransbox();
sendIframeMsg(MSG_TRANSBOX_TOGGLE);
}
} catch (err) {
kissLog("toggle transbox", err);
}
};
const handleMousehoverToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
mouseHoverSetting: {
...pre.mouseHoverSetting,
useMouseHover: e.target.checked,
},
}));
if (!translator) {
await sendTabMsg(MSG_MOUSEHOVER_TOGGLE);
} else {
translator.toggleMouseHover();
sendIframeMsg(MSG_MOUSEHOVER_TOGGLE);
}
} catch (err) {
kissLog("toggle mousehover", err);
}
};
const handleInputTransToggle = async (e) => {
try {
setSetting((pre) => ({
...pre,
inputRule: {
...pre.inputRule,
transOpen: e.target.checked,
},
}));
if (!translator) {
await sendTabMsg(MSG_TRANSINPUT_TOGGLE);
} else {
translator.toggleInputTranslate();
sendIframeMsg(MSG_TRANSINPUT_TOGGLE);
}
} catch (err) {
kissLog("toggle inputtrans", err);
}
};
const handleChange = async (e) => {
try {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
if (!translator) {
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} else {
translator.updateRule({ [name]: value });
sendIframeMsg(MSG_TRANS_PUTRULE, { [name]: value });
}
} catch (err) {
kissLog("update rule", err);
}
};
const handleClearCache = () => {
tryClearCaches();
};
const handleSaveRule = async () => {
try {
let href = "";
if (!translator) {
const tab = await getCurTab();
href = tab.url;
} else {
href = window.location?.href;
}
if (!href || typeof href !== "string") {
return;
}
const pattern = parseUrlPattern(href);
const curRule = { ...rule, pattern };
if (isExt && translator) {
sendBgMsg(MSG_SAVE_RULE, curRule);
} else {
saveRule(curRule);
}
} catch (err) {
kissLog("save rule", err);
}
};
const handleOpenSetting = useCallback(() => {
browser?.runtime.openOptionsPage();
}, []);
useEffect(() => {
if (translator) {
return;
}
(async () => {
try {
const res = await sendTabMsg(MSG_TRANS_GETRULE);
@@ -188,299 +32,27 @@ export default function Popup({ setShowPopup, translator }) {
kissLog("query rule", err);
}
})();
}, [translator]);
}, []);
useEffect(() => {
(async () => {
try {
const commands = {};
if (isExt) {
const res = await sendBgMsg(MSG_COMMAND_SHORTCUTS);
res.forEach(({ name, shortcut }) => {
commands[name] = shortcut;
});
} else {
const shortcuts = translator.setting.shortcuts;
if (shortcuts) {
Object.entries(shortcuts).forEach(([key, val]) => {
commands[key] = val.join("+");
});
}
}
setCommands(commands);
} catch (err) {
kissLog("query cmds", err);
}
})();
}, [translator]);
const optApis = useMemo(
() =>
setting?.transApis
.filter((api) => !api.isDisabled)
.map((api) => ({
key: api.apiSlug,
name: api.apiName || api.apiSlug,
})),
[setting]
);
const tranboxEnabled = setting?.tranboxSetting.transOpen;
const mouseHoverEnabled = setting?.mouseHoverSetting.useMouseHover;
const inputTransEnabled = setting?.inputRule.transOpen;
if (!rule) {
return (
<Box minWidth={300}>
{!translator && (
<>
<Header />
<Divider />
</>
)}
return (
<Box width={360}>
<Header />
<Divider />
{rule && setting ? (
<PopupCont
rule={rule}
setting={setting}
setRule={setRule}
setSetting={setSetting}
handleOpenSetting={handleOpenSetting}
/>
) : (
<Stack sx={{ p: 2 }} spacing={3}>
<Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")}
</Button>
</Stack>
</Box>
);
}
const {
transOpen,
apiSlug,
fromLang,
toLang,
textStyle,
autoScan,
transOnly,
hasRichText,
hasShadowroot,
} = rule;
return (
<Box width={360}>
{!translator && (
<>
<Header />
<Divider />
</>
)}
<Stack sx={{ p: 2 }} spacing={2}>
<Grid container columns={12} spacing={1}>
<Grid item xs={12}>
<FormControlLabel
control={
<Switch
checked={transOpen === "true"}
onChange={handleTransToggle}
/>
}
label={
commands["toggleTranslate"]
? `${i18n("translate_alt")}(${commands["toggleTranslate"]})`
: i18n("translate_alt")
}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="autoScan"
value={autoScan === "true" ? "false" : "true"}
checked={autoScan === "true"}
onChange={handleChange}
/>
}
label={i18n("autoscan_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="hasShadowroot"
value={hasShadowroot === "true" ? "false" : "true"}
checked={hasShadowroot === "true"}
onChange={handleChange}
/>
}
label={i18n("shadowroot_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="hasRichText"
value={hasRichText === "true" ? "false" : "true"}
checked={hasRichText === "true"}
onChange={handleChange}
/>
}
label={i18n("richtext_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="transOnly"
value={transOnly === "true" ? "false" : "true"}
checked={transOnly === "true"}
onChange={handleChange}
/>
}
label={i18n("transonly_alt")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="tranboxEnabled"
value={!tranboxEnabled}
checked={tranboxEnabled}
onChange={handleTransboxToggle}
/>
}
label={i18n("selection_translate")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="mouseHoverEnabled"
value={!mouseHoverEnabled}
checked={mouseHoverEnabled}
onChange={handleMousehoverToggle}
/>
}
label={i18n("mousehover_translate")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="inputTransEnabled"
value={!inputTransEnabled}
checked={inputTransEnabled}
onChange={handleInputTransToggle}
/>
}
label={i18n("input_translate")}
/>
</Grid>
</Grid>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={handleChange}
>
{optApis.map(({ key, name }) => (
<MenuItem key={key} value={key}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={fromLang}
name="fromLang"
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={toLang}
name="toLang"
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={textStyle}
name="textStyle"
label={
commands["toggleStyle"]
? `${i18n("text_style_alt")}(${commands["toggleStyle"]})`
: i18n("text_style_alt")
}
onChange={handleChange}
>
{OPT_STYLE_ALL.map((item) => (
<MenuItem key={item} value={item}>
{i18n(item)}
</MenuItem>
))}
</TextField>
{/* {OPT_STYLE_USE_COLOR.includes(textStyle) && (
<TextField
size="small"
name="bgColor"
value={bgColor}
label={i18n("bg_color")}
onChange={handleChange}
/>
)} */}
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={2}
>
<Button variant="text" onClick={handleSaveRule}>
{i18n("save_rule")}
</Button>
{!isExt && (
<Button variant="text" onClick={handleClearCache}>
{i18n("clear_cache")}
</Button>
)}
<Button variant="text" onClick={handleOpenSetting}>
{i18n("setting")}
</Button>
</Stack>
</Stack>
</Box>
);
}

View File

@@ -150,6 +150,7 @@ export default function DraggableResizable({
setPosition,
onChangeSize,
onChangePosition,
autoHeight,
...props
}) {
const lineWidth = 4;
@@ -222,11 +223,19 @@ export default function DraggableResizable({
</Pointer>
<Box
className="KT-draggable-container"
style={{
width: size.w,
height: size.h,
overflow: "hidden auto",
}}
style={
autoHeight
? {
width: size.w,
maxHeight: size.h,
overflow: "hidden auto",
}
: {
width: size.w,
height: size.h,
overflow: "hidden auto",
}
}
>
{children}
</Box>

View File

@@ -20,7 +20,7 @@ import { isMobile } from "../../libs/mobile";
import TranForm from "./TranForm.js";
function Header({
setShowPopup,
setShowBox,
simpleStyle,
setSimpleStyle,
hideClickAway,
@@ -98,7 +98,7 @@ function Header({
<IconButton
size="small"
onClick={() => {
setShowPopup(false);
setShowBox(false);
}}
>
<CloseIcon fontSize="small" />
@@ -111,10 +111,19 @@ function Header({
}
export default function TranBox({
showBox,
text,
setText,
setShowBox,
tranboxSetting: { enDict, enSug, apiSlugs, fromLang, toLang, toLang2 },
tranboxSetting: {
enDict,
enSug,
apiSlugs,
fromLang,
toLang,
toLang2,
autoHeight,
},
transApis,
boxSize,
setBoxSize,
@@ -134,43 +143,46 @@ export default function TranBox({
return (
<SettingProvider>
<ThemeProvider styles={extStyles}>
<DraggableResizable
position={boxPosition}
size={boxSize}
setSize={setBoxSize}
setPosition={setBoxPosition}
header={
<Header
setShowPopup={setShowBox}
simpleStyle={simpleStyle}
setSimpleStyle={setSimpleStyle}
hideClickAway={hideClickAway}
setHideClickAway={setHideClickAway}
followSelection={followSelection}
setFollowSelection={setFollowSelection}
mouseHover={mouseHover}
/>
}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setMouseHover(true)}
onMouseLeave={() => setMouseHover(false)}
>
<Box sx={{ p: simpleStyle ? 1 : 2 }}>
<TranForm
text={text}
setText={setText}
apiSlugs={apiSlugs}
fromLang={fromLang}
toLang={toLang}
toLang2={toLang2}
transApis={transApis}
simpleStyle={simpleStyle}
langDetector={langDetector}
enDict={enDict}
enSug={enSug}
/>
</Box>
</DraggableResizable>
{showBox && (
<DraggableResizable
position={boxPosition}
size={boxSize}
setSize={setBoxSize}
setPosition={setBoxPosition}
autoHeight={autoHeight}
header={
<Header
setShowBox={setShowBox}
simpleStyle={simpleStyle}
setSimpleStyle={setSimpleStyle}
hideClickAway={hideClickAway}
setHideClickAway={setHideClickAway}
followSelection={followSelection}
setFollowSelection={setFollowSelection}
mouseHover={mouseHover}
/>
}
onClick={(e) => e.stopPropagation()}
onMouseEnter={() => setMouseHover(true)}
onMouseLeave={() => setMouseHover(false)}
>
<Box sx={{ p: simpleStyle ? 1 : 2 }}>
<TranForm
text={text}
setText={setText}
apiSlugs={apiSlugs}
fromLang={fromLang}
toLang={toLang}
toLang2={toLang2}
transApis={transApis}
simpleStyle={simpleStyle}
langDetector={langDetector}
enDict={enDict}
enSug={enSug}
/>
</Box>
</DraggableResizable>
)}
</ThemeProvider>
</SettingProvider>
);

View File

@@ -38,7 +38,7 @@ export default function TranCont({
setTrText("");
setError("");
const [trText] = await apiTranslate({
const { trText } = await apiTranslate({
text,
fromLang,
toLang,
@@ -72,7 +72,7 @@ export default function TranCont({
<Box>
<TextField
size="small"
label={`${i18n("translated_text")} - ${apiSetting.apiSlug}`}
label={`${i18n("translated_text")} - ${apiSetting.apiName}`}
// disabled
fullWidth
multiline

View File

@@ -10,10 +10,12 @@ import {
OPT_TRANBOX_TRIGGER_CLICK,
OPT_TRANBOX_TRIGGER_HOVER,
OPT_TRANBOX_TRIGGER_SELECT,
EVENT_KISS,
} from "../../config";
import { isMobile } from "../../libs/mobile";
import { kissLog } from "../../libs/log";
import { useLangMap } from "../../hooks/I18n";
import { debouncePutTranBox, getTranBox } from "../../libs/storage";
export default function Slection({
contextMenuType,
@@ -106,6 +108,29 @@ export default function Slection({
return "onMouseUp";
}, [triggerMode]);
useEffect(() => {
(async () => {
try {
const { w, h, x, y } = (await getTranBox()) || {};
if (w !== undefined && h !== undefined) {
setBoxSize({ w, h });
}
if (x !== undefined && y !== undefined) {
setBoxPosition({
x: limitNumber(x, 0, window.innerWidth),
y: limitNumber(y, 0, window.innerHeight),
});
}
} catch (err) {
//
}
})();
}, []);
useEffect(() => {
debouncePutTranBox({ ...boxSize, ...boxPosition });
}, [boxSize, boxPosition]);
useEffect(() => {
async function handleMouseup(e) {
e.stopPropagation();
@@ -167,12 +192,26 @@ export default function Slection({
};
}, [tranboxShortcut, handleTranbox]);
const handleToggle = useCallback(() => {
if (showBox) {
setShowBox(false);
} else {
handleTranbox();
}
}, [showBox, handleTranbox]);
useEffect(() => {
window.addEventListener(MSG_OPEN_TRANBOX, handleTranbox);
return () => {
window.removeEventListener(MSG_OPEN_TRANBOX, handleTranbox);
const handleStatusUpdate = (event) => {
if (event.detail?.action === MSG_OPEN_TRANBOX) {
handleToggle();
}
};
}, [handleTranbox]);
document.addEventListener(EVENT_KISS, handleStatusUpdate);
return () => {
document.removeEventListener(EVENT_KISS, handleStatusUpdate);
};
}, [handleToggle]);
useEffect(() => {
if (!isGm) {
@@ -217,8 +256,9 @@ export default function Slection({
return (
<>
{showBox && (
{
<TranBox
showBox={showBox}
text={text}
setText={setText}
boxSize={boxSize}
@@ -237,7 +277,7 @@ export default function Slection({
// extStyles={extStyles}
langDetector={langDetector}
/>
)}
}
{showBtn && (
<TranBtn