Compare commits

...

141 Commits

Author SHA1 Message Date
Gabe Yuan
beca31f55d v1.7.15 2023-12-21 14:24:08 +08:00
Gabe Yuan
c7df103950 feat: add gemini translator 2023-12-21 14:15:14 +08:00
Gabe Yuan
4bf7972ad5 fix: fab button fontsize 2023-12-19 15:34:40 +08:00
Gabe Yuan
534eaed1ed refactor: input translate 2023-12-18 11:46:37 +08:00
Gabe Yuan
7e014e7385 fix: contextMenus setting 2023-12-15 11:43:01 +08:00
Gabe Yuan
34adb2660b fix: grant GM.unregisterMenuCommand 2023-12-15 10:58:49 +08:00
Gabe Yuan
b6bc165cf0 fix: context munus 2023-12-11 17:26:49 +08:00
Gabe Yuan
bdd5ed7fc7 feat: mutual translation effect with the target language 2023-12-11 15:54:54 +08:00
Gabe Yuan
95d19417c3 fix: touch tap limit 2023-12-11 11:40:20 +08:00
Gabe Yuan
30ebebdd71 fix: tranbtn position: absolute 2023-12-11 11:38:47 +08:00
Gabe Yuan
e9c557776d feat: context menus setting 2023-12-11 11:25:02 +08:00
Gabe Yuan
535a43b698 release: v1.7.14 2023-11-30 16:44:56 +08:00
Gabe Yuan
59752ed4aa fix: set FormControl small size 2023-11-30 15:11:06 +08:00
Gabe Yuan
b3e7b8f3f1 fix: readme 2023-11-28 15:15:02 +08:00
Gabe Yuan
c4e9365512 fix: clipboard.writeText run with async 2023-11-28 14:59:31 +08:00
Gabe Yuan
7d3972d3a8 perf: merge Translate Popup/Selected shortcut 2023-11-28 13:36:40 +08:00
Gabe Yuan
52ca4306fd feat: blockquote style 2023-11-28 11:41:45 +08:00
Gabe Yuan
da368ee612 feat: disable languages 2023-11-28 11:11:59 +08:00
Gabe Yuan
22c50e7765 feat: translate blacklist 2023-11-24 17:07:29 +08:00
Gabe Yuan
7bc39dd1bc fix: default shortcut: open setting page 2023-11-23 17:47:50 +08:00
Gabe Yuan
c80ead6116 v1.7.13 2023-11-22 15:24:54 +08:00
Gabe Yuan
67e76e4009 update readme 2023-11-22 15:08:26 +08:00
Gabe Yuan
b213218a30 update readme 2023-11-22 15:04:52 +08:00
Gabe Yuan
c629a1252c GM registerMenuCommand: open transbox, translate selected 2023-11-22 14:45:01 +08:00
Gabe Yuan
64d2481e93 update rules 2023-11-22 13:38:49 +08:00
Gabe Yuan
e7d6a6add8 update readme 2023-11-22 12:27:38 +08:00
Gabe Yuan
edc25f7da4 add touch option: four finger tap 2023-11-22 11:27:41 +08:00
Gabe Yuan
5bff84ace1 add context menus: open tranbox 2023-11-22 11:02:48 +08:00
Gabe Yuan
f8bfcba317 fix html fontsize 2023-11-22 10:23:14 +08:00
Gabe Yuan
013a05201b add context menus 2023-11-21 11:36:46 +08:00
Gabe Yuan
433e811821 add context menus 2023-11-21 11:20:05 +08:00
Gabe Yuan
df4cfc0fbc update readme 2023-11-17 11:38:42 +08:00
Gabe Yuan
1bfb465fd6 v1.7.12 2023-11-14 22:30:53 +08:00
Gabe Yuan
d5d5ec3fef fix text 2023-11-14 16:55:44 +08:00
Gabe Yuan
0a32f94d32 update webfix 2023-11-14 15:15:19 +08:00
Gabe Yuan
8067f34ce6 update readme 2023-11-14 10:50:48 +08:00
Gabe Yuan
214c189a7c fix text 2023-11-14 10:41:44 +08:00
Gabe Yuan
1f67afc8d8 fix error: addNode.querySelectorAll 2023-11-14 10:26:07 +08:00
Gabe Yuan
7d4af27919 user webfix rules 2023-11-14 10:19:23 +08:00
Gabe Yuan
2d651abfdd user webfix rules... 2023-11-13 18:03:38 +08:00
Gabe Yuan
6e06fe79cd bug fix: repace mouseover to mouseenter 2023-11-13 11:02:25 +08:00
Gabe Yuan
6093577591 sleep mouseup 2023-11-11 17:22:43 +08:00
Gabe Yuan
4b23ee733f touch operation 2023-11-11 16:59:38 +08:00
Gabe Yuan
46428b7c7f touch operation... 2023-11-10 18:00:34 +08:00
Gabe Yuan
6805340a9a remove wrangler script 2023-11-10 10:56:17 +08:00
Gabe Yuan
df36ca8d8b fix bug 2023-11-07 17:57:17 +08:00
Gabe Yuan
fe13de7c30 trans api return early 2023-11-07 17:52:33 +08:00
Gabe Yuan
b00f636b72 fix bug 2023-11-07 17:47:42 +08:00
Gabe Yuan
8d074e63e1 throw fetch error 2023-11-06 15:42:21 +08:00
Gabe Yuan
37989b0089 fix text 2023-11-06 12:06:10 +08:00
Gabe Yuan
477361eb40 replace getElementsByTagName to querySelectorAll 2023-11-05 16:20:44 +08:00
Gabe Yuan
94288b5dc3 show detail error to api test 2023-11-02 23:35:36 +08:00
Gabe Yuan
84de1e0f12 replace getElementsByName to getElementsByTagName 2023-11-02 10:59:26 +08:00
Gabe Yuan
06f93c1c10 repace querySelectorAll to getElementsByName 2023-11-02 10:56:46 +08:00
Gabe Yuan
450283b80a v1.7.11 2023-10-27 17:14:13 +08:00
Gabe Yuan
44aeed03a6 fix dict ui 2023-10-27 14:51:53 +08:00
Gabe Yuan
fa4569415d fix dict ui 2023-10-27 14:07:22 +08:00
Gabe Yuan
a341bf30ba fix tranbox apisetting bug 2023-10-27 13:55:24 +08:00
Gabe Yuan
34a7354c84 fix dict tags stack 2023-10-27 13:18:42 +08:00
Gabe Yuan
21b5dfbe98 update readme 2023-10-27 00:11:44 +08:00
Gabe Yuan
c1920f5cdd v1.7.10 2023-10-27 00:01:48 +08:00
Gabe Yuan
3e24568df9 add upload button for fav words 2023-10-26 23:55:05 +08:00
Gabe Yuan
b785cfe854 add download button for fav words 2023-10-26 17:59:49 +08:00
Gabe Yuan
15367bd117 add fav words page 2023-10-26 17:32:55 +08:00
Gabe Yuan
d7eaac5aca update nav icon 2023-10-26 13:22:01 +08:00
Gabe Yuan
d4526d605c fix i18n text 2023-10-26 13:18:02 +08:00
Gabe Yuan
52979356ca fix i18n text 2023-10-26 13:10:25 +08:00
Gabe Yuan
c6d3d6454f add copy button to tranbox 2023-10-26 12:24:24 +08:00
Gabe Yuan
0d7112187d update readme 2023-10-26 11:17:10 +08:00
Gabe Yuan
045ff3c3d9 support: cloudflare ai 2023-10-26 11:13:50 +08:00
Gabe Yuan
dd68a73efd set overflowWrap for long url 2023-10-26 10:41:04 +08:00
Gabe Yuan
5947dc182e remove log 2023-10-25 16:19:58 +08:00
Gabe Yuan
e185bbdb4d v1.7.9 2023-10-25 16:08:06 +08:00
Gabe Yuan
9368320c38 fix btn offset bug 2023-10-25 16:07:09 +08:00
Gabe Yuan
f65314bc2d v1.7.8 2023-10-25 14:55:08 +08:00
Gabe Yuan
1791e36038 fix readme 2023-10-25 13:36:46 +08:00
Gabe Yuan
8d93094af5 fix mouseover translate 2023-10-25 13:26:31 +08:00
Gabe Yuan
43f34fe6ed tranbox 2023-10-25 11:20:05 +08:00
Gabe Yuan
83911b2164 tranbox 2023-10-25 11:14:18 +08:00
Gabe Yuan
94c7494e90 tranbox 2023-10-25 11:13:56 +08:00
Gabe Yuan
65f2177299 tranbox 2023-10-24 23:50:07 +08:00
Gabe Yuan
f033b11e63 tranbox 2023-10-24 23:37:53 +08:00
Gabe Yuan
02f26af592 tranbox... 2023-10-24 17:58:37 +08:00
Gabe Yuan
4125aba808 tranbox... 2023-10-23 18:02:42 +08:00
Gabe Yuan
e89da9120c add common.js 2023-10-23 13:35:57 +08:00
Gabe Yuan
160fc218fc add more translators: baidu/tencent/deeplfree 2023-10-21 11:54:04 +08:00
Gabe Yuan
507d54dba0 add more translators... 2023-10-20 17:44:48 +08:00
Gabe Yuan
f3029a0f76 update task pool 2023-10-19 14:28:18 +08:00
Gabe Yuan
53181588cf support deeplx api 2023-10-17 11:33:26 +08:00
Gabe Yuan
f88aa159fc fix deepl bug: remove split_sentences param 2023-10-16 16:16:33 +08:00
Gabe Yuan
fb2b517a67 add webfix: FIXER_BN 2023-10-16 15:48:53 +08:00
Gabe Yuan
6e952a9530 update readme 2023-10-13 15:14:43 +08:00
Gabe Yuan
d9acef8d56 v1.7.7 2023-10-13 14:41:25 +08:00
Gabe Yuan
113a4d8eca modify langs map 2023-10-13 11:31:11 +08:00
Gabe Yuan
6d5b93c01b modify langs map 2023-10-13 10:48:01 +08:00
Gabe Yuan
5746911651 update app description & readme 2023-10-13 10:20:14 +08:00
Gabe Yuan
7173692db7 correct function name 2023-10-12 17:00:18 +08:00
Gabe Yuan
b13a63e568 fix: openai default url 2023-10-11 14:28:00 +08:00
Gabe Yuan
791ec65579 detect lang remote 2023-10-11 10:27:51 +08:00
Gabe Yuan
dd99fddc07 detect lang remote 2023-10-11 09:48:52 +08:00
Gabe Yuan
5cd6977a6e detect lang remote 2023-10-10 18:03:05 +08:00
Gabe Yuan
5af66204c4 rm wrangler package 2023-10-07 16:21:17 +08:00
Gabe Yuan
595efe808f rm yarn packageManager 2023-10-07 16:17:21 +08:00
Gabe Yuan
7b4b3b020c v1.7.6 2023-10-07 15:09:12 +08:00
Gabe Yuan
3e96540b56 fix createCache options key 2023-10-07 15:08:10 +08:00
Gabe Yuan
88b791bd73 fix createCache options key 2023-10-07 15:01:23 +08:00
Gabe Yuan
7817019e70 fix createCache options key 2023-10-07 14:57:36 +08:00
Gabe Yuan
6013bbd32c v1.7.5 2023-09-28 16:19:39 +08:00
Gabe Yuan
40e0b96f39 fix GM.xmlHttpRequest response 2023-09-28 16:18:28 +08:00
Gabe Yuan
16560fbdf0 fix install links 2023-09-27 15:39:57 +08:00
Gabe Yuan
16fdd704aa v1.7.4 2023-09-27 15:08:45 +08:00
Gabe Yuan
44e84d9259 minor revision 2023-09-25 11:56:46 +08:00
Gabe Yuan
18d29461ce minor revision 2023-09-25 11:47:12 +08:00
Gabe Yuan
31fb749e93 minor revision 2023-09-25 11:43:14 +08:00
Gabe Yuan
e17931493b add fab for ext 2023-09-23 19:16:51 +08:00
Gabe Yuan
a395f0b31b change newline length to 20 2023-09-22 16:37:35 +08:00
Gabe Yuan
c819896b43 change newline length to 30 2023-09-22 16:33:00 +08:00
Gabe Yuan
733ec92c9c v1.7.3 2023-09-22 15:40:02 +08:00
Gabe Yuan
7c67bb7181 change shortcut e.key to e.code 2023-09-22 15:33:37 +08:00
Gabe Yuan
87f099dd7f Solve the problem of multi-layer shadowroot selector and input box translation 2023-09-22 15:33:02 +08:00
Gabe Yuan
5306d81284 fix shortcut bug 2023-09-22 11:21:38 +08:00
Gabe Yuan
471a4a3159 fix workflows: replace yarn to pnpm 2023-09-21 16:58:49 +08:00
Gabe Yuan
a2f99da3b4 v1.7.2 2023-09-21 16:48:51 +08:00
Gabe Yuan
accab22d56 update readme 2023-09-21 16:41:49 +08:00
Gabe Yuan
6ea5228a5f fix sync 2023-09-21 16:13:00 +08:00
Gabe Yuan
a07d2cafb6 fix sync 2023-09-21 11:47:22 +08:00
Gabe Yuan
1b38f19cc1 replace yarn to pnpm 2023-09-21 10:31:45 +08:00
Gabe Yuan
aa5b286e0b replace yarn to pnpm 2023-09-21 10:15:03 +08:00
Gabe Yuan
6b6bbed330 fix sync 2023-09-20 22:15:09 +08:00
Gabe Yuan
489bc9534b fix sync 2023-09-20 17:47:23 +08:00
Gabe Yuan
01ebc184ad add globalThis.ContextType 2023-09-20 16:02:17 +08:00
Gabe Yuan
f591d66365 fix clear cache 2023-09-19 12:18:01 +08:00
Gabe Yuan
80782287d8 sync webdav cors 2023-09-19 11:56:19 +08:00
Gabe Yuan
3494bb1297 sync webdav 2023-09-18 17:36:10 +08:00
Gabe Yuan
92ffda5220 sync by worker 2023-09-18 15:45:32 +08:00
Gabe Yuan
fbaeff6b7b update api pathname 2023-09-18 13:28:36 +08:00
Gabe Yuan
248d3726dd fix storage hook 2023-09-17 22:34:28 +08:00
Gabe Yuan
1553559b1a fix storage hook 2023-09-17 21:50:17 +08:00
Gabe Yuan
8935ced75a fix iframe bug 2023-09-17 20:45:05 +08:00
Gabe Yuan
a865d6d74f update readme 2023-09-16 21:16:04 +08:00
Gabe Yuan
6d976554fd update readme 2023-09-16 20:11:10 +08:00
77 changed files with 14664 additions and 16628 deletions

2
.env
View File

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

View File

@@ -10,12 +10,15 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8.7.6
- uses: actions/setup-node@v3
with:
node-version: "18.17.0"
cache: "yarn"
- run: yarn install
- run: yarn build
cache: "pnpm"
- run: pnpm install
- run: pnpm build
- uses: actions/upload-artifact@v3
with:
name: build-artifacts

View File

@@ -1 +0,0 @@
nodeLinker: node-modules

View File

@@ -1,19 +1,9 @@
# KISS Translator
A minimalist [bilingual translation Extension & Greasemonkey Script](https://github.com/fishjar/kiss-translator).
A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator).
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
## Inspiration
The inspiration for this project comes from [Immersive Translate](https://github.com/immersive-translate/immersive-translate). After trying it out, I found that it can be used together with the [Webpage Word Translation Extension](https://github.com/fishjar/kiss-dictionary) developed by me earlier, which just forms a very good supplement.
But the function of this extension is a bit complicated for me, and only the compiled and obfuscated installation package is provided, and the source code is not provided, which cannot meet some of my personalized customization needs.
It just so happens that I am obsessed with translation tools. Based on the concept of "mainly for personal use, as long as you can use it", I made one. At present, the first version is completed, which basically meets the needs of personal use.
If you also like a little more simplicity, welcome to pick it up.
## Features
- [x] Keep it simple, smart
@@ -22,21 +12,35 @@ If you also like a little more simplicity, welcome to pick it up.
- [x] Chrome/Edge/Firefox/Kiwi
- [ ] Safari
- [x] Supports multiple translation services
- [x] Google/Microsoft/DeepL/OpenAI
- [x] Google/Microsoft/DeepL/OpenAI/Gemini/CloudflareAI/Baidu/Tencent
- [x] Custom translation interface
- [x] Support input box translation
- [x] Support YouTube subtitle translation
- [x] Data synchronization function
- [x] Custom rules + rule subscription
- [x] Custom style
- [x] Covers common translation scenarios
- [x] Web bilingual translation
- [x] Input box translation
- [x] Seletction translation
- [x] Favorite Words
- [x] Mouseover translation
- [x] YouTube subtitle translation
- [x] Cross-client data synchronization
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
- [x] Custom translation rules
- [x] Rule subscription/rule sharing
- [x] Custom translation style
- [x] Custom shortcut keys
- `Alt+Q` Toggle Translation
- `Alt+C` Toggle Styles
- `Alt+K` Open Popup
- `Alt+O` Open Options
- `Alt+K` Open Setting Popup
- `Alt+S` Open Translate Popup / Translate Selected Text
- `Alt+O` Open Options Page
- `Alt+I` Input Box Translation
## Download
## Install
> Note: For the following reasons, it is recommended to use browser extensions first
>
> - 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
- [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
@@ -44,11 +48,11 @@ If you also like a little more simplicity, welcome to pick it up.
- [x] Firefox [Installation address](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
- [ ] Safari
- [x] GreaseMonkey Script
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link 1](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)、 [Installation link 2](https://kiss-translator.rayjar.com/kiss-translator.user.js)
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
- Greasy Fork [Installation address](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link 1](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)、 [Installation link 2](https://kiss-translator.rayjar.com/kiss-translator.user-ios-safari.js)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
## Associated ProjectS
## Associated Projects
- Data synchronization service: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
- Data synchronization service available for this project.
@@ -73,8 +77,8 @@ If you also like a little more simplicity, welcome to pick it up.
```sh
git clone https://github.com/fishjar/kiss-translator.git
cd kiss-translator
yarn install
yarn build
pnpm install
pnpm build
```
## Discussion

View File

@@ -1,20 +1,10 @@
# 简约翻译
一个简约的 [双语网页翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。
一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
## 缘由
本项目灵感来源于 [Immersive Translate](https://github.com/immersive-translate/immersive-translate),在试用了后,发现搭配本人早前开发的 [网页划词翻译扩展](https://github.com/fishjar/kiss-dictionary) 一起使用,刚好形成很好补充。
但该扩展的功能对我来说有些繁杂了,而且只提供编译混淆后的安装包,没有提供源代码,无法满足我的一些个性化定制需求。
恰巧本人对翻译类工具有些执念,本着`“自用为主,能用就行”`的理念,于是动手撸了一个,目前初版完成,基本达到个人使用需求。
如果你也喜欢简约一点的,欢迎自取。
## 特点
## 特性
- [x] 保持简约
- [x] 开放源代码
@@ -22,21 +12,35 @@
- [x] Chrome/Edge/Firefox/Kiwi
- [ ] Safari
- [x] 支持多种翻译服务
- [x] Google/Microsoft/DeepL/OpenAI
- [x] Google/Microsoft/DeepL/OpenAI/Gemini/CloudflareAI/Baidu/Tencent
- [x] 自定义翻译接口
- [x] 支持输入框翻译
- [x] 支持 YouTube 字幕翻译
- [x] 数据同步功能
- [x] 自定义规则 + 规则订阅
- [x] 自定义样式
- [x] 覆盖常见翻译场景
- [x] 网页双语对照翻译
- [x] 输入框翻译
- [x] 划词翻译
- [x] 收藏词汇
- [x] 鼠标悬停翻译
- [x] YouTube 字幕翻译
- [x] 跨客户端数据同步
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
- [x] 自定义翻译规则
- [x] 规则订阅/规则分享
- [x] 自定义译文样式
- [x] 自定义快捷键
- `Alt+Q` 开启翻译
- `Alt+C` 切换样式
- `Alt+K` 打开弹窗
- `Alt+O` 打开设置
- `Alt+K` 打开设置弹窗
- `Alt+S` 打开翻译弹窗/翻译选中文字
- `Alt+O` 打开设置页面
- `Alt+I` 输入框翻译
## 下载
## 安装
> 注:基于以下原因,建议优先使用浏览器扩展
>
> - 浏览器扩展的功能更完整(本地语言识别、右键菜单等)
> - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等)
- [x] 浏览器扩展
- [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
@@ -44,9 +48,9 @@
- [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
- [ ] Safari
- [x] 油猴脚本
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接 1](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)、 [安装链接 2](https://kiss-translator.rayjar.com/kiss-translator.user.js)
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
- Greasy Fork [安装地址](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接 1](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)、 [安装链接 2](https://kiss-translator.rayjar.com/kiss-translator.user-ios-safari.js)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
## 关联项目
@@ -61,7 +65,7 @@
- 针对一些特殊网站的修正脚本。
- 以便翻译软件得到更好的展示效果。
- 翻译接口代理: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy)
- 如果访问某个翻译接口遇到网络问题,这个代理服务也许可以帮到你。
- 如果访问某个翻译接口遇到网络问题,这个代理服务也许可以帮到你。
- 自己部署,自己管理。
- 简约词典插件: [https://github.com/fishjar/kiss-dictionary](https://github.com/fishjar/kiss-dictionary)
- 搭配本项目一起使用的划词翻译插件。
@@ -73,8 +77,8 @@
```sh
git clone https://github.com/fishjar/kiss-translator.git
cd kiss-translator
yarn install
yarn build
pnpm install
pnpm build
```
## 交流

View File

@@ -75,7 +75,7 @@ const userscriptWebpack = (config, env) => {
// @name ${process.env.REACT_APP_NAME}
// @namespace ${process.env.REACT_APP_HOMEPAGE}
// @version ${process.env.REACT_APP_VERSION}
// @description A minimalist bilingual translation Extension & Greasemonkey Script (一个简约的双语网页翻译扩展 & 油猴脚本)
// @description A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本)
// @author Gabe<yugang2002@gmail.com>
// @homepageURL ${process.env.REACT_APP_HOMEPAGE}
// @license GPL-3.0
@@ -85,6 +85,7 @@ const userscriptWebpack = (config, env) => {
// @updateURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}
// @grant GM.xmlHttpRequest
// @grant GM.registerMenuCommand
// @grant GM.unregisterMenuCommand
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
@@ -95,14 +96,22 @@ const userscriptWebpack = (config, env) => {
// @connect edge.microsoft.com
// @connect api-free.deepl.com
// @connect api.deepl.com
// @connect www2.deepl.com
// @connect api.openai.com
// @connect generativelanguage.googleapis.com
// @connect openai.azure.com
// @connect workers.dev
// @connect github.io
// @connect githubusercontent.com
// @connect kiss-translator.rayjar.com
// @connect ghproxy.com
// @connect dav.jianguoyun.com
// @connect fanyi.baidu.com
// @connect transmart.qq.com
// @connect localhost:3000
// @connect 127.0.0.1:3000
// @connect localhost:1188
// @connect 127.0.0.1:1188
// @run-at document-end
// ==/UserScript==

View File

@@ -1,10 +1,11 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "1.7.1",
"version": "1.7.15",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.10.8",
"@mui/icons-material": "^5.11.11",
@@ -15,6 +16,7 @@
"react-markdown": "^8.0.7",
"react-router-dom": "^6.10.0",
"react-scripts": "5.0.1",
"webdav": "^5.3.0",
"webextension-polyfill": "^0.10.0"
},
"scripts": {
@@ -27,8 +29,7 @@
"build:userscript-ios": "file1=build/web/kiss-translator.user.js file2=build/web/kiss-translator-ios-safari.user.js && cp $file1 $file2 && sed -i 's|// @grant unsafeWindow|// @inject-into content|g' $file2",
"build:userscript": "rm -rf build/userscript && mkdir build/userscript && cp build/web/*.user.js build/userscript/",
"build:rules": "babel-node src/rules.js",
"build": "yarn build:chrome && yarn build:edge && yarn build:firefox && yarn build:web && yarn build:userscript-ios && yarn build:userscript && yarn build:rules",
"deploy:web": "wrangler pages deploy ./build/web --project-name kiss-translator",
"build": "pnpm build:chrome && pnpm build:edge && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:userscript && pnpm build:rules",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
@@ -39,7 +40,8 @@
],
"globals": {
"GM": true,
"unsafeWindow": true
"unsafeWindow": true,
"globalThis": true
}
},
"browserslist": {
@@ -59,7 +61,6 @@
"@babel/node": "^7.22.10",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-env": "^7.22.10",
"react-app-rewired": "^2.2.1",
"wrangler": "^3.4.0"
"react-app-rewired": "^2.2.1"
}
}

10267
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,15 +3,18 @@
"message": "KISS Translator"
},
"app_description": {
"message": "A minimalist bilingual translation Extension & Greasemonkey Script"
"message": "A simple bilingual translation extension & Greasemonkey script"
},
"toggle_translate": {
"message": "Toggle Translate"
"message": "Toggle Translate (Alt+Q)"
},
"toggle_style": {
"message": "Toggle Style"
"message": "Toggle Style (Alt+C)"
},
"open_options": {
"message": "Open Options"
"message": "Open Options (Alt+O)"
},
"open_tranbox": {
"message": "Translate Popup/Selected (Alt+S)"
}
}

View File

@@ -3,15 +3,18 @@
"message": "简约翻译"
},
"app_description": {
"message": "一个简约的双语网页翻译扩展 & 油猴脚本"
"message": "一个简约的双语对照翻译扩展 & 油猴脚本"
},
"toggle_translate": {
"message": "开启翻译"
"message": "开启翻译 (Alt+Q)"
},
"toggle_style": {
"message": "切换样式"
"message": "切换样式 (Alt+C)"
},
"open_options": {
"message": "打开设置"
"message": "打开设置 (Alt+O)"
},
"open_tranbox": {
"message": "翻译弹窗/选中文字 (Alt+S)"
}
}

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.7.1",
"version": "1.7.15",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -41,7 +41,7 @@
"description": "__MSG_open_options__"
}
},
"permissions": ["<all_urls>", "storage"],
"permissions": ["<all_urls>", "storage", "contextMenus"],
"icons": {
"16": "images/logo16.png",
"32": "images/logo32.png",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "1.7.1",
"version": "1.7.15",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",
@@ -42,7 +42,7 @@
"description": "__MSG_open_options__"
}
},
"permissions": ["storage"],
"permissions": ["storage", "contextMenus"],
"host_permissions": ["<all_urls>"],
"icons": {
"16": "images/logo16.png",

230
src/apis/baidu.js Normal file
View File

@@ -0,0 +1,230 @@
import queryString from "query-string";
import { getBdauth, setBdauth } from "../libs/storage";
import { URL_BAIDU_WEB, URL_BAIDU_TRAN } from "../config";
import { fetchApi } from "../libs/fetch";
/* eslint-disable */
function n(t, e) {
for (var n = 0; n < e.length - 2; n += 3) {
var r = e.charAt(n + 2);
(r = "a" <= r ? r.charCodeAt(0) - 87 : Number(r)),
(r = "+" === e.charAt(n + 1) ? t >>> r : t << r),
(t = "+" === e.charAt(n) ? (t + r) & 4294967295 : t ^ r);
}
return t;
}
function e(t, e) {
(null == e || e > t.length) && (e = t.length);
for (var n = 0, r = new Array(e); n < e; n++) r[n] = t[n];
return r;
}
/* eslint-disable */
function getSign(t, gtk, r = null) {
var o,
i = t.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g);
if (null === i) {
var a = t.length;
a > 30 &&
(t = ""
.concat(t.substr(0, 10))
.concat(t.substr(Math.floor(a / 2) - 5, 10))
.concat(t.substr(-10, 10)));
} else {
for (
var s = t.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/),
c = 0,
u = s.length,
l = [];
c < u;
c++
)
"" !== s[c] &&
l.push.apply(
l,
(function (t) {
if (Array.isArray(t)) return e(t);
})((o = s[c].split(""))) ||
(function (t) {
if (
("undefined" != typeof Symbol && null != t[Symbol.iterator]) ||
null != t["@@iterator"]
)
return Array.from(t);
})(o) ||
(function (t, n) {
if (t) {
if ("string" == typeof t) return e(t, n);
var r = Object.prototype.toString.call(t).slice(8, -1);
return (
"Object" === r && t.constructor && (r = t.constructor.name),
"Map" === r || "Set" === r
? Array.from(t)
: "Arguments" === r ||
/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)
? e(t, n)
: void 0
);
}
})(o) ||
(function () {
throw new TypeError(
"Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."
);
})()
),
c !== u - 1 && l.push(i[c]);
var p = l.length;
p > 30 &&
(t =
l.slice(0, 10).join("") +
l.slice(Math.floor(p / 2) - 5, Math.floor(p / 2) + 5).join("") +
l.slice(-10).join(""));
}
for (
var d = ""
.concat(String.fromCharCode(103))
.concat(String.fromCharCode(116))
.concat(String.fromCharCode(107)),
h = (null !== r ? r : (r = gtk || "") || "").split("."),
f = Number(h[0]) || 0,
m = Number(h[1]) || 0,
g = [],
y = 0,
v = 0;
v < t.length;
v++
) {
var _ = t.charCodeAt(v);
_ < 128
? (g[y++] = _)
: (_ < 2048
? (g[y++] = (_ >> 6) | 192)
: (55296 == (64512 & _) &&
v + 1 < t.length &&
56320 == (64512 & t.charCodeAt(v + 1))
? ((_ = 65536 + ((1023 & _) << 10) + (1023 & t.charCodeAt(++v))),
(g[y++] = (_ >> 18) | 240),
(g[y++] = ((_ >> 12) & 63) | 128))
: (g[y++] = (_ >> 12) | 224),
(g[y++] = ((_ >> 6) & 63) | 128)),
(g[y++] = (63 & _) | 128));
}
for (
var b = f,
w =
""
.concat(String.fromCharCode(43))
.concat(String.fromCharCode(45))
.concat(String.fromCharCode(97)) +
""
.concat(String.fromCharCode(94))
.concat(String.fromCharCode(43))
.concat(String.fromCharCode(54)),
k =
""
.concat(String.fromCharCode(43))
.concat(String.fromCharCode(45))
.concat(String.fromCharCode(51)) +
""
.concat(String.fromCharCode(94))
.concat(String.fromCharCode(43))
.concat(String.fromCharCode(98)) +
""
.concat(String.fromCharCode(43))
.concat(String.fromCharCode(45))
.concat(String.fromCharCode(102)),
x = 0;
x < g.length;
x++
)
b = n((b += g[x]), w);
return (
(b = n(b, k)),
(b ^= m) < 0 && (b = 2147483648 + (2147483647 & b)),
"".concat((b %= 1e6).toString(), ".").concat(b ^ f)
);
}
const getToken = async () => {
const res = await fetchApi({
input: URL_BAIDU_WEB,
init: {
headers: {
"Content-type": "text/html; charset=utf-8",
},
},
});
if (!res.ok) {
throw new Error(res.statusText);
}
const text = await res.text();
const token = text.match(/token: '(.*)',/)[1];
const gtk = text.match(/gtk = "(.*)";/)[1];
const exp = Date.now() + 8 * 60 * 60 * 1000;
if (!token || !gtk) {
throw new Error("[baidu] get token error");
}
return { token, gtk, exp };
};
/**
* 闭包缓存token减少对storage查询
* @returns
*/
const _bdAuth = () => {
let store;
return async () => {
const now = Date.now();
// 查询内存缓存
if (store && store.exp > now) {
return store;
}
// 查询storage缓存
store = await getBdauth();
if (store && store.exp > now) {
return store;
}
// 缓存没有或失效,查询接口
store = await getToken();
await setBdauth(store);
return store;
};
};
const bdAuth = _bdAuth();
export const genBaidu = async ({ text, from, to }) => {
const { token, gtk } = await bdAuth();
const sign = getSign(text, gtk);
const data = {
from,
to,
query: text,
simple_means_flag: 3,
sign,
token,
domain: "common",
ts: Date.now(),
};
const input = `${URL_BAIDU_TRAN}?from=${from}&to=${to}`;
const init = {
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
method: "POST",
body: queryString.stringify(data),
};
return [input, init];
};

58
src/apis/deepl.js Normal file
View File

@@ -0,0 +1,58 @@
import { URL_DEEPLFREE_TRAN } from "../config";
let id = 1e4 * Math.round(1e4 * Math.random());
export const genDeeplFree = ({ text, from, to }) => {
const iCount = (text.match(/[i]/g) || []).length + 1;
let timestamp = Date.now();
timestamp = timestamp + (iCount - (timestamp % iCount));
id++;
let body = JSON.stringify({
jsonrpc: "2.0",
method: "LMT_handle_texts",
params: {
splitting: "newlines",
lang: {
target_lang: to,
source_lang_user_selected: from,
},
commonJobParams: {
wasSpoken: false,
transcribe_as: "",
},
id,
timestamp,
texts: [
{
text,
requestAlternatives: 3,
},
],
},
});
body = body.replace(
'method":"',
(id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "'
);
const init = {
headers: {
"Content-Type": "application/json",
Accept: "*/*",
"x-app-os-name": "iOS",
"x-app-os-version": "16.3.0",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"x-app-device": "iPhone13,2",
"User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)",
"x-app-build": "510265",
"x-app-version": "2.9.1",
},
method: "POST",
body,
};
return [URL_DEEPLFREE_TRAN, init];
};

View File

@@ -4,14 +4,22 @@ import {
OPT_TRANS_GOOGLE,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_DEEPLX,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_CUSTOMIZE,
OPT_LANGS_SPECIAL,
PROMPT_PLACE_FROM,
PROMPT_PLACE_TO,
URL_CACHE_TRAN,
KV_SALT_SYNC,
URL_BAIDU_LANGDETECT,
OPT_LANGS_BAIDU,
URL_TENCENT_TRANSMART,
OPT_LANGS_TENCENT,
OPT_LANGS_SPECIAL,
} from "../config";
import { tryDetectLang } from "../libs";
import { sha256 } from "../libs/utils";
/**
@@ -21,7 +29,7 @@ import { sha256 } from "../libs/utils";
* @param {*} data
* @returns
*/
export const apiSyncData = async (url, key, data, isBg = false) =>
export const apiSyncData = async (url, key, data) =>
fetchPolyfill(url, {
headers: {
"Content-type": "application/json",
@@ -29,214 +37,62 @@ export const apiSyncData = async (url, key, data, isBg = false) =>
},
method: "POST",
body: JSON.stringify(data),
isBg,
});
/**
* 下载数据
* @param {*} url
* @param {*} isBg
* @returns
*/
export const apiFetch = (url, isBg = false) => fetchPolyfill(url, { isBg });
export const apiFetch = (url) => fetchPolyfill(url);
/**
* 谷歌翻译
* 百度语言识别
* @param {*} text
* @param {*} to
* @param {*} from
* @returns
*/
const apiGoogleTranslate = async (
translator,
text,
to,
from,
{ url, key, useCache = true }
) => {
const params = {
client: "gtx",
dt: "t",
dj: 1,
ie: "UTF-8",
sl: from,
tl: to,
q: text,
};
const input = `${url}?${queryString.stringify(params)}`;
const res = await fetchPolyfill(input, {
headers: {
"Content-type": "application/json",
},
useCache,
usePool: true,
translator,
token: key,
});
const trText = res.sentences.map((item) => item.trans).join(" ");
const isSame = to === res.src;
return [trText, isSame];
};
/**
* 微软翻译
* @param {*} text
* @param {*} to
* @param {*} from
* @returns
*/
const apiMicrosoftTranslate = async (
translator,
text,
to,
from,
{ url, useCache = true }
) => {
const params = {
from,
to,
"api-version": "3.0",
};
const input = `${url}?${queryString.stringify(params)}`;
const res = await fetchPolyfill(input, {
export const apiBaiduLangdetect = async (text) => {
const res = await fetchPolyfill(URL_BAIDU_LANGDETECT, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify([{ Text: text }]),
useCache,
usePool: true,
translator,
body: JSON.stringify({
query: text,
}),
useCache: true,
});
const trText = res[0].translations[0].text;
const isSame = to === res[0].detectedLanguage?.language;
return [trText, isSame];
};
/**
* DeepL翻译
* @param {*} text
* @param {*} to
* @param {*} from
* @returns
*/
const apiDeepLTranslate = async (
translator,
text,
to,
from,
{ url, key, useCache = true }
) => {
const data = {
text: [text],
target_lang: to,
split_sentences: "0",
};
if (from) {
data.source_lang = from;
if (res.error === 0) {
return OPT_LANGS_BAIDU.get(res.lan) ?? res.lan;
}
const res = await fetchPolyfill(url, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
useCache,
usePool: true,
translator,
token: key,
});
const trText = res.translations.map((item) => item.text).join(" ");
const isSame = to === res.translations[0].detected_source_language;
return [trText, isSame];
return "";
};
/**
* OpenAI 翻译
* 腾讯语言识别
* @param {*} text
* @param {*} to
* @param {*} from
* @returns
*/
const apiOpenaiTranslate = async (
translator,
text,
to,
from,
{ url, key, model, prompt, useCache = true }
) => {
prompt = prompt
.replaceAll(PROMPT_PLACE_FROM, from)
.replaceAll(PROMPT_PLACE_TO, to);
const res = await fetchPolyfill(url, {
export const apiTencentLangdetect = async (text) => {
const body = JSON.stringify({
header: {
fn: "text_analysis",
},
text,
});
const res = await fetchPolyfill(URL_TENCENT_TRANSMART, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
model: model,
messages: [
{
role: "system",
content: prompt,
},
{
role: "user",
content: text,
},
],
temperature: 0,
max_tokens: 256,
}),
useCache,
usePool: true,
translator,
token: key,
body,
useCache: true,
});
const trText = res?.choices?.[0].message.content;
const sLang = await tryDetectLang(text);
const tLang = await tryDetectLang(trText);
const isSame = text === trText || (sLang && tLang && sLang === tLang);
return [trText, isSame];
};
/**
* 自定义接口 翻译
* @param {*} text
* @param {*} to
* @param {*} from
* @returns
*/
const apiCustomTranslate = async (
translator,
text,
to,
from,
{ url, key, useCache = true }
) => {
const res = await fetchPolyfill(url, {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify({
text,
from,
to,
}),
useCache,
usePool: true,
translator,
token: key,
});
const trText = res.text;
const isSame = to === res.from;
return [trText, isSame];
return OPT_LANGS_TENCENT.get(res.language) ?? res.language;
};
/**
@@ -244,29 +100,102 @@ const apiCustomTranslate = async (
* @param {*} param0
* @returns
*/
export const apiTranslate = ({
export const apiTranslate = async ({
translator,
text,
fromLang,
toLang,
apiSetting,
apiSetting = {},
useCache = true,
usePool = true,
}) => {
const from = OPT_LANGS_SPECIAL[translator]?.get(fromLang) ?? fromLang;
const to = OPT_LANGS_SPECIAL[translator]?.get(toLang) ?? toLang;
const callApi = (api) => api(translator, text, to, from, apiSetting);
let trText = "";
let isSame = false;
if (!text) {
return [trText, true];
}
const from =
OPT_LANGS_SPECIAL[translator].get(fromLang) ??
OPT_LANGS_SPECIAL[translator].get("auto");
const to = OPT_LANGS_SPECIAL[translator].get(toLang);
if (!to) {
console.log(`[trans] target lang: ${toLang} not support`);
return [trText, isSame];
}
const cacheOpts = {
translator,
text,
fromLang,
toLang,
};
const transOpts = {
translator,
text,
from,
to,
};
const res = await fetchPolyfill(
`${URL_CACHE_TRAN}?${queryString.stringify(cacheOpts)}`,
{
useCache,
usePool,
transOpts,
apiSetting,
}
);
switch (translator) {
case OPT_TRANS_GOOGLE:
return callApi(apiGoogleTranslate);
trText = res.sentences.map((item) => item.trans).join(" ");
isSame = to === res.src;
break;
case OPT_TRANS_MICROSOFT:
return callApi(apiMicrosoftTranslate);
trText = res[0].translations.map((item) => item.text).join(" ");
isSame = text === trText;
break;
case OPT_TRANS_DEEPL:
return callApi(apiDeepLTranslate);
trText = res.translations.map((item) => item.text).join(" ");
isSame = to === res.translations[0].detected_source_language;
break;
case OPT_TRANS_DEEPLFREE:
trText = res.result?.texts.map((item) => item.text).join(" ");
isSame = to === res.result?.lang;
break;
case OPT_TRANS_DEEPLX:
trText = res.data;
isSame = to === res.source_lang;
break;
case OPT_TRANS_BAIDU:
trText = res.trans_result?.data.map((item) => item.dst).join(" ");
isSame = res.trans_result?.to === res.trans_result?.from;
break;
case OPT_TRANS_TENCENT:
trText = res.auto_translation;
isSame = text === trText;
break;
case OPT_TRANS_OPENAI:
return callApi(apiOpenaiTranslate);
trText = res?.choices?.[0].message.content;
isSame = text === trText;
break;
case OPT_TRANS_GEMINI:
trText = res?.candidates?.[0].content.parts[0].text;
isSame = text === trText;
break;
case OPT_TRANS_CLOUDFLAREAI:
trText = res?.result?.translated_text;
isSame = text === trText;
break;
case OPT_TRANS_CUSTOMIZE:
return callApi(apiCustomTranslate);
trText = res.text;
isSame = to === res.from;
break;
default:
return ["", false];
}
return [trText, isSame, res];
};

View File

@@ -4,10 +4,15 @@ import {
MSG_FETCH_LIMIT,
MSG_FETCH_CLEAR,
MSG_TRANS_TOGGLE,
MSG_OPEN_OPTIONS,
MSG_SAVE_RULE,
MSG_TRANS_TOGGLE_STYLE,
MSG_OPEN_TRANBOX,
MSG_CONTEXT_MENUS,
CMD_TOGGLE_TRANSLATE,
CMD_TOGGLE_STYLE,
CMD_OPEN_OPTIONS,
CMD_OPEN_TRANBOX,
} from "./config";
import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage";
import { trySyncSettingAndRules } from "./libs/sync";
@@ -15,31 +20,83 @@ import { fetchData, fetchPool } from "./libs/fetch";
import { sendTabMsg } from "./libs/msg";
import { trySyncAllSubRules } from "./libs/subRules";
import { tryClearCaches } from "./libs";
import { saveRule } from "./libs/rules";
globalThis.ContextType = "BACKGROUND";
/**
* 添加右键菜单
*/
function addContextMenus() {
browser.contextMenus.create({
id: CMD_TOGGLE_TRANSLATE,
title: browser.i18n.getMessage("toggle_translate"),
contexts: ["all"],
});
browser.contextMenus.create({
id: CMD_TOGGLE_STYLE,
title: browser.i18n.getMessage("toggle_style"),
contexts: ["all"],
});
browser.contextMenus.create({
id: CMD_OPEN_TRANBOX,
title: browser.i18n.getMessage("open_tranbox"),
contexts: ["all"],
});
browser.contextMenus.create({
id: "options_separator",
type: "separator",
contexts: ["all"],
});
browser.contextMenus.create({
id: CMD_OPEN_OPTIONS,
title: browser.i18n.getMessage("open_options"),
contexts: ["all"],
});
}
/**
* 清除右键菜单
*/
function removeContextMenus() {
browser.contextMenus.removeAll();
}
/**
* 插件安装
*/
browser.runtime.onInstalled.addListener(() => {
tryInitDefaultData();
// 右键菜单
addContextMenus();
});
/**
* 浏览器启动
*/
browser.runtime.onStartup.addListener(async () => {
console.log("browser onStartup");
// 同步数据
await trySyncSettingAndRules(true);
await trySyncSettingAndRules();
const {
clearCache,
contextMenus = true,
subrulesList,
} = await getSettingWithDefault();
// 清除缓存
const setting = await getSettingWithDefault();
if (setting.clearCache) {
if (clearCache) {
tryClearCaches();
}
// 右键菜单
if (!contextMenus) {
removeContextMenus();
}
// 同步订阅规则
trySyncAllSubRules(setting, true);
trySyncAllSubRules({ subrulesList });
});
/**
@@ -55,7 +112,7 @@ browser.runtime.onMessage.addListener(
sendResponse({ data });
})
.catch((error) => {
sendResponse({ error: error.message });
sendResponse({ error: error.message, cause: error.cause });
});
break;
case MSG_FETCH_LIMIT:
@@ -67,6 +124,20 @@ browser.runtime.onMessage.addListener(
fetchPool.clear();
sendResponse({ data: "ok" });
break;
case MSG_OPEN_OPTIONS:
browser.runtime.openOptionsPage();
break;
case MSG_SAVE_RULE:
saveRule(args);
break;
case MSG_CONTEXT_MENUS:
const { contextMenus } = args;
if (contextMenus) {
addContextMenus();
} else {
removeContextMenus();
}
break;
default:
sendResponse({ error: `message action is unavailable: ${action}` });
}
@@ -92,3 +163,24 @@ browser.commands.onCommand.addListener((command) => {
default:
}
});
/**
* 监听右键菜单
*/
browser.contextMenus.onClicked.addListener(({ menuItemId }) => {
switch (menuItemId) {
case CMD_TOGGLE_TRANSLATE:
sendTabMsg(MSG_TRANS_TOGGLE);
break;
case CMD_TOGGLE_STYLE:
sendTabMsg(MSG_TRANS_TOGGLE_STYLE);
break;
case CMD_OPEN_TRANBOX:
sendTabMsg(MSG_OPEN_TRANBOX);
break;
case CMD_OPEN_OPTIONS:
browser.runtime.openOptionsPage();
break;
default:
}
});

287
src/common.js Normal file
View File

@@ -0,0 +1,287 @@
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 {
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_TRANBOX,
APP_LCNAME,
DEFAULT_TRANBOX_SETTING,
} from "./config";
import { getFabWithDefault, getSettingWithDefault } from "./libs/storage";
import { Translator } from "./libs/translator";
import { isIframe, sendIframeMsg, sendParentMsg } from "./libs/iframe";
import Slection from "./views/Selection";
import { touchTapListener } from "./libs/touch";
import { debounce, genEventName } from "./libs/utils";
import { handlePing, injectScript } from "./libs/gm";
import { browser } from "./libs/browser";
import { runWebfix } from "./libs/webfix";
import { matchRule } from "./libs/rules";
import { trySyncAllSubRules } from "./libs/subRules";
import { isInBlacklist } from "./libs/blacklist";
import inputTranslate from "./libs/inputTranslate";
/**
* 油猴脚本设置页面
*/
function runSettingPage() {
if (GM?.info?.script?.grant?.includes("unsafeWindow")) {
unsafeWindow.GM = GM;
unsafeWindow.APP_INFO = {
name: process.env.REACT_APP_NAME,
version: process.env.REACT_APP_VERSION,
};
} else {
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);
}
}
/**
* 插件监听后端事件
* @param {*} translator
*/
function runtimeListener(translator) {
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
switch (action) {
case MSG_TRANS_TOGGLE:
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
break;
case MSG_TRANS_TOGGLE_STYLE:
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args);
sendIframeMsg(MSG_TRANS_PUTRULE, args);
break;
case MSG_OPEN_TRANBOX:
window.dispatchEvent(new CustomEvent(MSG_OPEN_TRANBOX));
break;
default:
return { error: `message action is unavailable: ${action}` };
}
return { data: translator.rule };
});
}
/**
* iframe 页面执行
* @param {*} setting
*/
function runIframe(setting) {
let 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:
if (!translator) {
translator = new Translator(args, setting);
} else {
translator.updateRule(args || {});
}
break;
default:
}
});
sendParentMsg(MSG_TRANS_GETRULE);
}
/**
* 悬浮按钮
* @param {*} translator
* @returns
*/
async function showFab(translator) {
const fab = await getFabWithDefault();
if (fab.isHide) {
return;
}
const $action = document.createElement("div");
$action.setAttribute("id", APP_LCNAME);
document.body.parentElement.appendChild($action);
const shadowContainer = $action.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: APP_LCNAME,
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Action translator={translator} fab={fab} />
</CacheProvider>
</React.StrictMode>
);
}
/**
* 划词翻译
* @param {*} param0
* @returns
*/
function showTransbox({
contextMenus = true,
tranboxSetting = DEFAULT_TRANBOX_SETTING,
transApis,
}) {
if (!tranboxSetting?.transOpen) {
return;
}
const $tranbox = document.createElement("div");
$tranbox.setAttribute("id", "kiss-transbox");
document.body.parentElement.appendChild($tranbox);
const shadowContainer = $tranbox.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: "kiss-transbox",
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Slection
contextMenus={contextMenus}
tranboxSetting={tranboxSetting}
transApis={transApis}
/>
</CacheProvider>
</React.StrictMode>
);
}
/**
* 监听来自iframe页面消息
* @param {*} rule
*/
function windowListener(rule) {
window.addEventListener("message", (e) => {
const { action } = e.data || {};
switch (action) {
case MSG_TRANS_GETRULE:
sendIframeMsg(MSG_TRANS_PUTRULE, rule);
break;
default:
}
});
}
/**
* 显示错误信息到页面顶部
* @param {*} message
*/
function showErr(message) {
const $err = document.createElement("div");
$err.innerText = `KISS-Translator: ${message}`;
$err.style.cssText = "background:red; color:#fff;";
document.body.prepend($err);
}
/**
* 监听触屏操作
* @param {*} translator
* @returns
*/
function touchOperation(translator) {
const { touchTranslate = 2 } = translator.setting;
if (touchTranslate === 0) {
return;
}
const handleTap = debounce(() => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
});
touchTapListener(handleTap, touchTranslate);
}
/**
* 入口函数
*/
export async function run(isUserscript = false) {
try {
const href = document.location.href;
// 设置页面
if (
isUserscript &&
(href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) ||
href.includes(process.env.REACT_APP_OPTIONSPAGE) ||
href.includes(process.env.REACT_APP_OPTIONSPAGE2))
) {
runSettingPage();
return;
}
// 读取设置信息
const setting = await getSettingWithDefault();
// 黑名单
if (isInBlacklist(href, setting)) {
return;
}
// 适配iframe
if (isIframe) {
runIframe(setting);
return;
}
// 不规范网页修复
await runWebfix(setting);
// 翻译网页
const rule = await matchRule(href, setting);
const translator = new Translator(rule, setting);
// 监听消息
windowListener(rule);
!isUserscript && runtimeListener(translator);
// 输入框翻译
inputTranslate(setting);
// 划词翻译
showTransbox(setting);
// 浮球按钮
await showFab(translator);
// 触屏操作
touchOperation(translator);
// 同步订阅规则
isUserscript && (await trySyncAllSubRules(setting));
} catch (err) {
console.error("[KISS-Translator]", err);
showErr(err.message);
}
}

View File

@@ -50,20 +50,20 @@ const customApiHelpZH = `/// 自定义翻译源接口说明
method: "POST",
headers: {
"Content-type": "application/json",
"Authorization" = "Bearer {{YOUR_KEY}}"
"Authorization": "Bearer {{YOUR_KEY}}",
},
body: {
text, // 需要翻译的文字
from, // 源语言,可能为空,表示需要接口自动识别语言
to, // 目标语言
text: "", // 需要翻译的文字
from: "", // 源语言,可能为空,表示需要接口自动识别语言
to: "", // 目标语言
}
}
// 返回Response数据需符合下面的JSON规范
{
text, // 翻译后的文字
from, // 识别的源语言
to, // 目标语言(可选)
text: "", // 翻译后的文字
from: "", // 识别的源语言
to: "", // 目标语言(可选)
}
// 支持的语言代码如下
@@ -78,20 +78,20 @@ const customApiHelpEN = `/// Custom translation source interface description
method: "POST",
headers: {
"Content-type": "application/json",
"Authorization" = "Bearer {{YOUR_KEY}}"
"Authorization": "Bearer {{YOUR_KEY}}",
},
body: {
text, // text to be translated
from, // Source language, may be empty
to, // Target language
text: "", // text to be translated
from: "", // Source language, may be empty
to: "", // Target language
}
}
// The returned data must conform to the following JSON specification
{
text, // translated text
from, // Recognized source language
to, // Target language (optional)
text: "", // translated text
from: "", // Recognized source language
to: "", // Target language (optional)
}
// The supported language codes are as follows
@@ -215,6 +215,14 @@ export const I18N = {
zh: `目标语言`,
en: `Target Language`,
},
to_lang2: {
zh: `第二目标语言`,
en: `Target Language 2`,
},
to_lang2_helper: {
zh: `设定后,与目标语言产生互译效果,但依赖远程语言识别。`,
en: `After setting, it will produce mutual translation effect with the target language, but it relies on remote language recognition.`,
},
text_style: {
zh: `文字样式`,
en: `Text Style`,
@@ -335,6 +343,10 @@ export const I18N = {
zh: `高亮`,
en: `Highlight`,
},
blockquote: {
zh: `引用`,
en: `Blockquote`,
},
diy_style: {
zh: `自定义样式`,
en: `Custom Style`,
@@ -352,8 +364,8 @@ export const I18N = {
en: `URL pattern`,
},
pattern_helper: {
zh: `1、支持星号(*)通配符。2、多个URL支持英文逗号“,”分隔。`,
en: `1. The asterisk (*) wildcard is supported. 2. Multiple URLs can be separated by English commas ",".`,
zh: `1、支持星号(*)通配符。2、多个URL英文逗号“,”分隔。`,
en: `1. The asterisk (*) wildcard is supported. 2. Multiple URLs separated by English commas ",".`,
},
selector_helper: {
zh: `1、遵循CSS选择器语法。2、留空表示采用全局设置。3、多个CSS选择器之间用“;”隔开。4、“shadow root”选择器和内部选择器用“>>>”隔开。`,
@@ -375,6 +387,14 @@ export const I18N = {
zh: `选择器`,
en: `Selector`,
},
root_selector: {
zh: `根选择器`,
en: `Root Selector`,
},
fixer_function: {
zh: `修复函数`,
en: `Fixer Function`,
},
import: {
zh: `导入`,
en: `Import`,
@@ -435,10 +455,18 @@ export const I18N = {
zh: `重启浏览器时清除缓存`,
en: `Clear cache when restarting browser`,
},
data_sync_type: {
zh: `数据同步方式`,
en: `Data Sync Type`,
},
data_sync_url: {
zh: `数据同步接口`,
en: `Data Sync API`,
},
data_sync_user: {
zh: `数据同步账户`,
en: `Data Sync User`,
},
data_sync_key: {
zh: `数据同步密钥`,
en: `Data Sync Key`,
@@ -460,8 +488,8 @@ export const I18N = {
en: `Sorry, something went wrong!`,
},
error_sync_setting: {
zh: `您的同步设置未填写,无法在线分享。`,
en: `Your sync settings are missing and cannot be shared online.`,
zh: `您的同步类型必须为“KISS-Worker”且需填写完整`,
en: `Your sync type must be "KISS-Worker" and must be filled in completely`,
},
click_test: {
zh: `点击测试`,
@@ -531,6 +559,10 @@ export const I18N = {
zh: `隐藏悬浮按钮`,
en: `Hide Fab Button`,
},
hide_tran_button: {
zh: `隐藏翻译按钮`,
en: `Hide Translate Button`,
},
show: {
zh: `显示`,
en: `Show`,
@@ -547,11 +579,11 @@ export const I18N = {
zh: `全局规则`,
en: `Global Rule`,
},
input_setting: {
zh: `输入框设置`,
en: `Input Box Setting`,
input_translate: {
zh: `输入框翻译`,
en: `Input Box Translation`,
},
input_box_translation: {
use_input_box_translation: {
zh: `启用输入框翻译`,
en: `Input Box Translation`,
},
@@ -568,8 +600,8 @@ export const I18N = {
en: `Trigger Translation Shortcut Keys`,
},
trigger_trans_shortcut_help: {
zh: `默认为单击“Alt+i`,
en: `Default is "Alt+i"`,
zh: `默认为单击“AltLeft+KeyI`,
en: `Default is "AltLeft+KeyI"`,
},
shortcut_press_count: {
zh: `快捷键连击次数`,
@@ -587,4 +619,92 @@ export const I18N = {
zh: `标识后面可以加目标语言代码,如: “/en 你好”、“/zh hello”`,
en: `The target language code can be added after the sign, such as: "/en 你好", "/zh hello"`,
},
detect_lang_remote: {
zh: `远程语言检测`,
en: `Remote language detection`,
},
detect_lang_remote_help: {
zh: `启用后检测准确度增加,但会降低翻译速度,请酌情开启`,
en: `After enabling, the detection accuracy will increase, but it will reduce the translation speed. Please enable it as appropriate.`,
},
disable: {
zh: `禁用`,
en: `Disable`,
},
enable: {
zh: `启用`,
en: `Enable`,
},
selection_translate: {
zh: `划词翻译`,
en: `Selection Translate`,
},
toggle_selection_translate: {
zh: `启用划词翻译`,
en: `Use Selection Translate`,
},
trigger_tranbox_shortcut: {
zh: `显示翻译框/翻译选中文字快捷键`,
en: `Open Translate Popup/Translate Selected Shortcut`,
},
tranbtn_offset_x: {
zh: `翻译按钮偏移X0-100`,
en: `Translate Button Offset X (0-100)`,
},
tranbtn_offset_y: {
zh: `翻译按钮偏移Y0-100`,
en: `Translate Button Offset Y (0-100)`,
},
translated_text: {
zh: `译文`,
en: `Translated Text`,
},
original_text: {
zh: `原文`,
en: `Original Text`,
},
favorite_words: {
zh: `收藏词汇`,
en: `Favorite Words`,
},
touch_setting: {
zh: `触屏设置`,
en: `Touch Setting`,
},
touch_translate_shortcut: {
zh: `触屏翻译快捷方式`,
en: `Touch Translate Shortcut`,
},
touch_tap_0: {
zh: `禁用`,
en: `Disable`,
},
touch_tap_2: {
zh: `双指轻触`,
en: `Two finger tap`,
},
touch_tap_3: {
zh: `三指轻触`,
en: `Three finger tap`,
},
touch_tap_4: {
zh: `四指轻触`,
en: `Four finger tap`,
},
translate_blacklist: {
zh: `禁用翻译名单`,
en: `Translate Blacklist`,
},
disable_langs: {
zh: `不翻译的语言`,
en: `Disable Languages`,
},
disable_langs_helper: {
zh: `此功能依赖准确的语言检测,建议启用远程语言检测。`,
en: `This feature relies on accurate language detection. It is recommended to enable remote language detection.`,
},
add_context_menus: {
zh: `添加右键菜单`,
en: `Add Context Menus`,
},
};

View File

@@ -20,8 +20,11 @@ export {
};
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
export const STOKEY_BDAUTH = `${APP_NAME}_bdauth`;
export const STOKEY_SETTING = `${APP_NAME}_setting`;
export const STOKEY_RULES = `${APP_NAME}_rules`;
export const STOKEY_WFRULES = `${APP_NAME}_webfix_rules`;
export const STOKEY_WORDS = `${APP_NAME}_words`;
export const STOKEY_SYNC = `${APP_NAME}_sync`;
export const STOKEY_FAB = `${APP_NAME}_fab`;
export const STOKEY_RULESCACHE_PREFIX = `${APP_NAME}_rulescache_`;
@@ -30,6 +33,7 @@ export const STOKEY_WEBFIXCACHE_PREFIX = `${APP_NAME}_webfixcache_`;
export const CMD_TOGGLE_TRANSLATE = "toggleTranslate";
export const CMD_TOGGLE_STYLE = "toggleStyle";
export const CMD_OPEN_OPTIONS = "openOptions";
export const CMD_OPEN_TRANBOX = "openTranbox";
export const CLIENT_WEB = "web";
export const CLIENT_CHROME = "chrome";
@@ -38,9 +42,11 @@ export const CLIENT_FIREFOX = "firefox";
export const CLIENT_USERSCRIPT = "userscript";
export const CLIENT_EXTS = [CLIENT_CHROME, CLIENT_EDGE, CLIENT_FIREFOX];
export const KV_RULES_KEY = "KT_RULES";
export const KV_RULES_SHARE_KEY = "KT_RULES_SHARE";
export const KV_SETTING_KEY = "KT_SETTING";
export const KV_RULES_KEY = "kiss-rules.json";
export const KV_WFRULES_KEY = "kiss-webfix.json";
export const KV_WORDS_KEY = "kiss-words.json";
export const KV_RULES_SHARE_KEY = "kiss-rules-share.json";
export const KV_SETTING_KEY = "kiss-setting.json";
export const KV_SALT_SYNC = "KISS-Translator-SYNC";
export const KV_SALT_SHARE = "KISS-Translator-SHARE";
@@ -49,11 +55,15 @@ export const CACHE_NAME = `${APP_NAME}_cache`;
export const MSG_FETCH = "fetch";
export const MSG_FETCH_LIMIT = "fetch_limit";
export const MSG_FETCH_CLEAR = "fetch_clear";
export const MSG_OPEN_OPTIONS = "open_options";
export const MSG_SAVE_RULE = "save_rule";
export const MSG_TRANS_TOGGLE = "trans_toggle";
export const MSG_TRANS_TOGGLE_STYLE = "trans_toggle_style";
export const MSG_OPEN_TRANBOX = "open_tranbox";
export const MSG_TRANS_GETRULE = "trans_getrule";
export const MSG_TRANS_PUTRULE = "trans_putrule";
export const MSG_TRANS_CURRULE = "trans_currule";
export const MSG_CONTEXT_MENUS = "context_menus";
export const THEME_LIGHT = "light";
export const THEME_DARK = "dark";
@@ -65,18 +75,39 @@ export const URL_KISS_RULES_NEW_ISSUE =
"https://github.com/fishjar/kiss-rules/issues/new";
export const URL_RAW_PREFIX =
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
export const URL_CACHE_TRAN = `https://${APP_LCNAME}/translate`;
export const URL_MICROSOFT_TRAN =
"https://api-edge.cognitive.microsofttranslator.com/translate";
export const URL_MICROSOFT_AUTH = "https://edge.microsoft.com/translate/auth";
export const URL_BAIDU_LANGDETECT = "https://fanyi.baidu.com/langdetect";
export const URL_BAIDU_WEB = "https://fanyi.baidu.com/";
export const URL_BAIDU_TRAN = "https://fanyi.baidu.com/v2transapi";
export const URL_DEEPLFREE_TRAN = "https://www2.deepl.com/jsonrpc";
export const URL_TENCENT_TRANSMART = "https://transmart.qq.com/api/imt";
export const OPT_TRANS_GOOGLE = "Google";
export const OPT_TRANS_MICROSOFT = "Microsoft";
export const OPT_TRANS_DEEPL = "DeepL";
export const OPT_TRANS_DEEPLX = "DeepLX";
export const OPT_TRANS_DEEPLFREE = "DeepLFree";
export const OPT_TRANS_BAIDU = "Baidu";
export const OPT_TRANS_TENCENT = "Tencent";
export const OPT_TRANS_OPENAI = "OpenAI";
export const OPT_TRANS_GEMINI = "Gemini";
export const OPT_TRANS_CLOUDFLAREAI = "CloudflareAI";
export const OPT_TRANS_CUSTOMIZE = "Custom";
export const OPT_TRANS_ALL = [
OPT_TRANS_GOOGLE,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_DEEPLX,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_CUSTOMIZE,
];
@@ -121,7 +152,9 @@ export const OPT_LANGS_TO = [
];
export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO];
export const OPT_LANGS_SPECIAL = {
[OPT_TRANS_GOOGLE]: new Map(OPT_LANGS_FROM.map(([key]) => [key, key])),
[OPT_TRANS_MICROSOFT]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
["zh-CN", "zh-Hans"],
["zh-TW", "zh-Hant"],
@@ -132,12 +165,106 @@ export const OPT_LANGS_SPECIAL = {
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_DEEPLFREE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
["auto", "auto"],
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_DEEPLX]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key.toUpperCase()]),
["auto", ""],
["zh-CN", "ZH"],
["zh-TW", "ZH"],
]),
[OPT_TRANS_BAIDU]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["zh-CN", "zh"],
["zh-TW", "cht"],
["ar", "ara"],
["bg", "bul"],
["ca", "cat"],
["hr", "hrv"],
["da", "dan"],
["fi", "fin"],
["fr", "fra"],
["hi", "mai"],
["ja", "jp"],
["ko", "kor"],
["ms", "may"],
["mt", "mlt"],
["nb", "nor"],
["ro", "rom"],
["ru", "ru"],
["sl", "slo"],
["es", "spa"],
["sv", "swe"],
["ta", "tam"],
["te", "tel"],
["uk", "ukr"],
["vi", "vie"],
]),
[OPT_TRANS_TENCENT]: new Map([
["auto", "auto"],
["zh-CN", "zh"],
["zh-TW", "zh"],
["en", "en"],
["ar", "ar"],
["de", "de"],
["ru", "ru"],
["fr", "fr"],
["fi", "fil"],
["ko", "ko"],
["ms", "ms"],
["pt", "pt"],
["ja", "ja"],
["th", "th"],
["tr", "tr"],
["es", "es"],
["it", "it"],
["hi", "hi"],
["id", "id"],
["vi", "vi"],
]),
[OPT_TRANS_OPENAI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_CUSTOMIZE]: new Map([["auto", ""]]),
[OPT_TRANS_GEMINI]: new Map(
OPT_LANGS_FROM.map(([key, val]) => [key, val.split(" - ")[0]])
),
[OPT_TRANS_CLOUDFLAREAI]: new Map([
["auto", ""],
["zh-CN", "chinese"],
["zh-TW", "chinese"],
["en", "english"],
["ar", "arabic"],
["de", "german"],
["ru", "russian"],
["fr", "french"],
["pt", "portuguese"],
["ja", "japanese"],
["es", "spanish"],
["hi", "hindi"],
]),
[OPT_TRANS_CUSTOMIZE]: new Map([
...OPT_LANGS_FROM.map(([key]) => [key, key]),
["auto", ""],
]),
};
export const OPT_LANGS_LIST = OPT_LANGS_TO.map(([lang]) => lang);
export const OPT_LANGS_BAIDU = new Map(
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_BAIDU].entries()).map(([k, v]) => [
v,
k,
])
);
export const OPT_LANGS_TENCENT = new Map(
Array.from(OPT_LANGS_SPECIAL[OPT_TRANS_TENCENT].entries()).map(([k, v]) => [
v,
k,
])
);
OPT_LANGS_TENCENT.set("zh", "zh-CN");
export const OPT_STYLE_NONE = "style_none"; // 无
export const OPT_STYLE_LINE = "under_line"; // 下划线
@@ -146,6 +273,7 @@ export const OPT_STYLE_DASHLINE = "dash_line"; // 虚线
export const OPT_STYLE_WAVYLINE = "wavy_line"; // 波浪线
export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊
export const OPT_STYLE_HIGHLIGHT = "highlight"; // 高亮
export const OPT_STYLE_BLOCKQUOTE = "blockquote"; // 引用
export const OPT_STYLE_DIY = "diy_style"; // 自定义样式
export const OPT_STYLE_ALL = [
OPT_STYLE_NONE,
@@ -155,6 +283,7 @@ export const OPT_STYLE_ALL = [
OPT_STYLE_WAVYLINE,
OPT_STYLE_FUZZY,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
OPT_STYLE_DIY,
];
export const OPT_STYLE_USE_COLOR = [
@@ -163,6 +292,7 @@ export const OPT_STYLE_USE_COLOR = [
OPT_STYLE_DASHLINE,
OPT_STYLE_WAVYLINE,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
];
export const OPT_MOUSEKEY_DISABLE = "mk_disable";
@@ -183,6 +313,7 @@ export const DEFAULT_FETCH_INTERVAL = 100; // 默认任务间隔时间
export const PROMPT_PLACE_FROM = "{{from}}"; // 占位符
export const PROMPT_PLACE_TO = "{{to}}"; // 占位符
export const PROMPT_PLACE_TEXT = "{{text}}"; // 占位符
export const DEFAULT_COLOR = "#209CEE"; // 默认高亮背景色/线条颜色
@@ -201,7 +332,7 @@ export const GLOBLA_RULE = {
// 输入框翻译
export const OPT_INPUT_TRANS_SIGNS = ["/", "//", "\\", "\\\\", ">", ">>"];
export const DEFAULT_INPUT_SHORTCUT = ["Alt", "i"];
export const DEFAULT_INPUT_SHORTCUT = ["AltLeft", "KeyI"];
export const DEFAULT_INPUT_RULE = {
transOpen: true,
translator: OPT_TRANS_MICROSOFT,
@@ -213,6 +344,20 @@ export const DEFAULT_INPUT_RULE = {
transSign: OPT_INPUT_TRANS_SIGNS[0],
};
// 划词翻译
export const DEFAULT_TRANBOX_SHORTCUT = ["AltLeft", "KeyS"];
export const DEFAULT_TRANBOX_SETTING = {
transOpen: true,
translator: OPT_TRANS_MICROSOFT,
fromLang: "auto",
toLang: "zh-CN",
toLang2: "en",
tranboxShortcut: DEFAULT_TRANBOX_SHORTCUT,
btnOffsetX: 10,
btnOffsetY: 10,
hideTranBtn: false,
};
// 订阅列表
export const DEFAULT_SUBRULES_LIST = [
{
@@ -235,20 +380,30 @@ export const DEFAULT_TRANS_APIS = {
url: "https://translate.googleapis.com/translate_a/single",
key: "",
},
[OPT_TRANS_MICROSOFT]: {
url: "https://api-edge.cognitive.microsofttranslator.com/translate",
authUrl: "https://edge.microsoft.com/translate/auth",
},
[OPT_TRANS_DEEPL]: {
url: "https://api-free.deepl.com/v2/translate",
key: "",
},
[OPT_TRANS_DEEPLX]: {
url: "http://localhost:1188/translate",
key: "",
},
[OPT_TRANS_OPENAI]: {
url: "https://api.openai.com/v1/chat/completion",
url: "https://api.openai.com/v1/chat/completions",
key: "",
model: "gpt-4",
prompt: `You will be provided with a sentence in ${PROMPT_PLACE_FROM}, and your task is to translate it into ${PROMPT_PLACE_TO}.`,
},
[OPT_TRANS_GEMINI]: {
url: "https://generativelanguage.googleapis.com/v1/models",
key: "",
model: "gemini-pro",
prompt: `Translate the following text from ${PROMPT_PLACE_FROM} to ${PROMPT_PLACE_TO}:\n\n${PROMPT_PLACE_TEXT}`,
},
[OPT_TRANS_CLOUDFLAREAI]: {
url: "https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/run/@cf/meta/m2m100-1.2b",
key: "",
},
[OPT_TRANS_CUSTOMIZE]: {
url: "",
key: "",
@@ -261,15 +416,22 @@ export const OPT_SHORTCUT_STYLE = "toggleStyle";
export const OPT_SHORTCUT_POPUP = "togglePopup";
export const OPT_SHORTCUT_SETTING = "openSetting";
export const DEFAULT_SHORTCUTS = {
[OPT_SHORTCUT_TRANSLATE]: ["Alt", "q"],
[OPT_SHORTCUT_STYLE]: ["Alt", "c"],
[OPT_SHORTCUT_POPUP]: ["Alt", "k"],
[OPT_SHORTCUT_SETTING]: ["Alt", "o"],
[OPT_SHORTCUT_TRANSLATE]: ["AltLeft", "KeyQ"],
[OPT_SHORTCUT_STYLE]: ["AltLeft", "KeyC"],
[OPT_SHORTCUT_POPUP]: ["AltLeft", "KeyK"],
[OPT_SHORTCUT_SETTING]: ["AltLeft", "KeyO"],
};
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度
export const TRANS_NEWLINE_LENGTH = 40; // 换行字符数
export const TRANS_NEWLINE_LENGTH = 20; // 换行字符数
export const DEFAULT_BLACKLIST = [
"https://fishjar.github.io/kiss-translator/options.html",
"https://translate.google.com",
"https://www.deepl.com/translator",
"oapi.dingtalk.com",
"login.dingtalk.com",
]; // 禁用翻译名单
export const DEFAULT_SETTING = {
darkMode: false, // 深色模式
@@ -282,24 +444,32 @@ export const DEFAULT_SETTING = {
clearCache: false, // 是否在浏览器下次启动时清除缓存
injectRules: true, // 是否注入订阅规则
injectWebfix: true, // 是否注入修复补丁
detectRemote: false, // 是否使用远程语言检测
contextMenus: true, // 是否添加右键菜单
subrulesList: DEFAULT_SUBRULES_LIST, // 订阅列表
owSubrule: DEFAULT_OW_RULE, // 覆写订阅规则
transApis: DEFAULT_TRANS_APIS, // 翻译接口
mouseKey: OPT_MOUSEKEY_DISABLE, // 鼠标悬停翻译
shortcuts: DEFAULT_SHORTCUTS, // 快捷键
hideFab: false, // 是否隐藏按钮
inputRule: DEFAULT_INPUT_RULE, // 输入框设置
tranboxSetting: DEFAULT_TRANBOX_SETTING, // 划词翻译设置
touchTranslate: 2, // 触屏翻译
blacklist: DEFAULT_BLACKLIST.join(",\n"), // 禁用翻译名单
disableLangs: [], // 不翻译的语言
};
export const DEFAULT_RULES = [GLOBLA_RULE];
export const OPT_SYNCTYPE_WORKER = "KISS-Worker";
export const OPT_SYNCTYPE_WEBDAV = "WebDAV";
export const OPT_SYNCTYPE_ALL = [OPT_SYNCTYPE_WORKER, OPT_SYNCTYPE_WEBDAV];
export const DEFAULT_SYNC = {
syncType: OPT_SYNCTYPE_WORKER, // 同步方式
syncUrl: "", // 数据同步接口
syncUser: "", // 数据同步用户名
syncKey: "", // 数据同步密钥
settingUpdateAt: 0,
settingSyncAt: 0,
rulesUpdateAt: 0,
rulesSyncAt: 0,
syncMeta: {}, // 数据更新及同步信息
subRulesSyncAt: 0, // 订阅规则同步时间
dataCaches: {}, // 缓存同步时间
};

View File

@@ -1,6 +1,4 @@
const els = `li, p, h1, h2, h3, h4, h5, h6, dd, blockquote`;
export const DEFAULT_SELECTOR = `:is(${els})`;
export const DEFAULT_SELECTOR = `:is(li, p, h1, h2, h3, h4, h5, h6, dd, blockquote)`;
export const GLOBAL_KEY = "*";
export const REMAIN_KEY = "-";
@@ -42,145 +40,66 @@ export const DEFAULT_OW_RULE = {
textDiyStyle: DEFAULT_DIY_STYLE,
};
const RULES = [
{
pattern: `www.google.com/search`,
selector: `h3, .IsZvec, .VwiC3b`,
},
{
pattern: `news.google.com`,
selector: `h4`,
},
{
pattern: `www.foxnews.com`,
selector: `h1, h2, .title, .sidebar [data-type="Title"], .article-content ${DEFAULT_SELECTOR}; [data-spotim-module="conversation"]>div >>> [data-spot-im-class="message-text"] p, [data-spot-im-class="message-text"]`,
},
{
pattern: `bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php`,
selector: DEFAULT_SELECTOR,
},
{
pattern: `themessenger.com`,
selector: `.leading-tight, .leading-tighter, .my-2 p, .font-body p, article ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.telegraph.co.uk`,
selector: `article ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.theguardian.com`,
selector: `.show-underline, .dcr-hup5wm div, .dcr-7vl6y8 div, .dcr-12evv1c, figcaption, article ${DEFAULT_SELECTOR}, [data-cy="mostviewed-footer"] h4`,
},
{
pattern: `www.semafor.com`,
selector: `${DEFAULT_SELECTOR}, .styles_intro__IYj__, [class*="styles_description"]`,
},
{
pattern: `www.noemamag.com`,
selector: `.splash__title, .single-card__title, .single-card__type, .single-card__topic, .highlighted-content__title, .single-card__author, article ${DEFAULT_SELECTOR}, .quote__text, .wp-caption-text div`,
},
{
pattern: `restofworld.org`,
selector: `${DEFAULT_SELECTOR}, .recirc-story__headline, .recirc-story__dek`,
},
{
pattern: `www.axios.com`,
selector: `.h7, ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.newyorker.com`,
selector: `.summary-item__hed, .summary-item__dek, .summary-collection-grid__dek, .dqtvfu, .rubric__link, .caption, article ${DEFAULT_SELECTOR}, .HEhan ${DEFAULT_SELECTOR}, .ContributorBioBio-fBolsO`,
},
{
pattern: `https://time.com/`,
selector: `h1, h3, .summary, .video-title, #article-body ${DEFAULT_SELECTOR}, .image-wrap-container .credit.body-caption, .media-heading`,
},
{
pattern: `www.dw.com`,
selector: `.ts-teaser-title a, .news-title a, .title a, .teaser-description a, .hbudab h3, .hbudab p, figcaption ,article ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.bbc.com`,
selector: `h1, h2, .media__link, .media__summary, article ${DEFAULT_SELECTOR}, .ssrcss-y7krbn-Stack, .ssrcss-1mrs5ns-PromoLink, .ssrcss-18cjaf3-Headline, .gs-c-promo-heading__title, .gs-c-promo-summary, .media__content h3, .article__intro`,
},
{
pattern: `www.chinadaily.com.cn`,
selector: `h1, .tMain [shape="rect"], .cMain [shape="rect"], .photo_art [shape="rect"], .mai_r [shape="rect"], .lisBox li, #Content ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.facebook.com`,
selector: `[role="main"] [dir="auto"]`,
},
{
pattern: `www.reddit.com`,
selector: `[slot="title"], [slot="text-body"] ${DEFAULT_SELECTOR}, #-post-rtjson-content p`,
},
{
pattern: `www.quora.com`,
selector: `.qu-wordBreak--break-word`,
},
{
pattern: `edition.cnn.com`,
selector: `.container__title, .container__headline, .headline__text, .image__caption, [data-type="Title"], .article__content ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.reuters.com`,
selector: `#main-content [data-testid="Heading"], #main-content [data-testid="Body"], .article-body__content__17Yit ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.bloomberg.com`,
selector: `[data-component="headline"], [data-component="related-item-headline"], [data-component="title"], article ${DEFAULT_SELECTOR}`,
},
{
pattern: `deno.land, docs.github.com`,
selector: `main ${DEFAULT_SELECTOR}`,
},
{
pattern: `doc.rust-lang.org`,
selector: `#content ${DEFAULT_SELECTOR}`,
},
{
pattern: `www.indiehackers.com`,
selector: `h1, h3, .content ${DEFAULT_SELECTOR}, .feed-item__title-link`,
},
{
pattern: `platform.openai.com/docs`,
selector: `.docs-body ${DEFAULT_SELECTOR}`,
},
{
pattern: `en.wikipedia.org`,
selector: `h1, .mw-parser-output ${DEFAULT_SELECTOR}`,
},
{
pattern: `stackoverflow.com`,
selector: `h1, .s-prose p, .comment-body .comment-copy`,
},
{
pattern: `www.npmjs.com/package/, developer.chrome.com/docs, medium.com, developers.cloudflare.com, react.dev, create-react-app.dev, pytorch.org/`,
selector: `article ${DEFAULT_SELECTOR}`,
},
{
pattern: `news.ycombinator.com`,
selector: `.title, .commtext`,
},
{
pattern: `https://github.com/`,
selector: `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop='description'], .markdown-title, bdi`,
},
{
pattern: `twitter.com`,
selector: `[data-testid='tweetText']`,
},
{
pattern: `youtube.com`,
selector: `h1, #video-title, #content-text, #title, yt-attributed-string>span>span`,
},
];
const RULES_MAP = {
"www.google.com/search": `h3, .IsZvec, .VwiC3b`,
"news.google.com": `[role="link"], .DY5T1d, .ifw3f, ${DEFAULT_SELECTOR}`,
"www.foxnews.com": `h1, h2, .title, .sidebar [data-type="Title"], .article-content ${DEFAULT_SELECTOR}; [data-spotim-module="conversation"]>div >>> [data-spot-im-class="message-text"] p, [data-spot-im-class="message-text"]`,
"bearblog.dev, www.theverge.com, www.tampermonkey.net/documentation.php": `${DEFAULT_SELECTOR}`,
"themessenger.com": `.leading-tight, .leading-tighter, .my-2 p, .font-body p, article ${DEFAULT_SELECTOR}`,
"www.telegraph.co.uk, go.dev/doc/": `article ${DEFAULT_SELECTOR}`,
"www.theguardian.com": `.show-underline, .dcr-hup5wm div, .dcr-7vl6y8 div, .dcr-12evv1c, figcaption, article ${DEFAULT_SELECTOR}, [data-cy="mostviewed-footer"] h4`,
"www.semafor.com": `${DEFAULT_SELECTOR}, .styles_intro__IYj__, [class*="styles_description"]`,
"www.noemamag.com": `.splash__title, .single-card__title, .single-card__type, .single-card__topic, .highlighted-content__title, .single-card__author, article ${DEFAULT_SELECTOR}, .quote__text, .wp-caption-text div`,
"restofworld.org": `${DEFAULT_SELECTOR}, .recirc-story__headline, .recirc-story__dek`,
"www.axios.com": `.h7, ${DEFAULT_SELECTOR}`,
"www.newyorker.com": `.summary-item__hed, .summary-item__dek, .summary-collection-grid__dek, .dqtvfu, .rubric__link, .caption, article ${DEFAULT_SELECTOR}, .HEhan ${DEFAULT_SELECTOR}, .ContributorBioBio-fBolsO`,
"time.com": `h1, h3, .summary, .video-title, #article-body ${DEFAULT_SELECTOR}, .image-wrap-container .credit.body-caption, .media-heading`,
"www.dw.com": `.ts-teaser-title a, .news-title a, .title a, .teaser-description a, .hbudab h3, .hbudab p, figcaption ,article ${DEFAULT_SELECTOR}`,
"www.bbc.com": `h1, h2, .media__link, .media__summary, article ${DEFAULT_SELECTOR}, .ssrcss-y7krbn-Stack, .ssrcss-17zglt8-PromoHeadline, .ssrcss-18cjaf3-Headline, .gs-c-promo-heading__title, .gs-c-promo-summary, .media__content h3, .article__intro`,
"www.chinadaily.com.cn": `h1, .tMain [shape="rect"], .cMain [shape="rect"], .photo_art [shape="rect"], .mai_r [shape="rect"], .lisBox li, #Content ${DEFAULT_SELECTOR}`,
"www.facebook.com": `[role="main"] [dir="auto"]`,
"www.reddit.com": `[slot="title"], [slot="text-body"] ${DEFAULT_SELECTOR}, #-post-rtjson-content p`,
"www.quora.com": `.qu-wordBreak--break-word`,
"edition.cnn.com": `.container__title, .container__headline, .headline__text, .image__caption, [data-type="Title"], .article__content ${DEFAULT_SELECTOR}`,
"www.reuters.com": `#main-content [data-testid="Heading"], #main-content [data-testid="Body"], .article-body__content__17Yit ${DEFAULT_SELECTOR}`,
"www.bloomberg.com": `[data-component="headline"], [data-component="related-item-headline"], [data-component="title"], article ${DEFAULT_SELECTOR}`,
"deno.land, docs.github.com": `main ${DEFAULT_SELECTOR}`,
"doc.rust-lang.org": `.content ${DEFAULT_SELECTOR}`,
"www.indiehackers.com": `h1, h3, .content ${DEFAULT_SELECTOR}, .feed-item__title-link`,
"platform.openai.com/docs": `.docs-body ${DEFAULT_SELECTOR}`,
"en.wikipedia.org": `h1, .mw-parser-output ${DEFAULT_SELECTOR}`,
"stackoverflow.com": `h1, .s-prose p, .comment-body .comment-copy`,
"www.npmjs.com/package, developer.chrome.com/docs, medium.com, developers.cloudflare.com, react.dev, create-react-app.dev, pytorch.org": `article ${DEFAULT_SELECTOR}`,
"news.ycombinator.com": `.title, .commtext`,
"github.com": `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop="description"], .markdown-title, bdi, .ws-pre-wrap, .status-meta, span.status-meta, .col-10.color-fg-muted, .TimelineItem-body, .pinned-item-list-item-content .color-fg-muted, .markdown-body td, .markdown-body th`,
"twitter.com": `[data-testid="tweetText"]`,
"m.youtube.com": `.slim-video-information-title .yt-core-attributed-string, .media-item-headline .yt-core-attributed-string, .comment-text .yt-core-attributed-string, .typography-body-2b .yt-core-attributed-string, #ytp-caption-window-container .ytp-caption-segment`,
"www.youtube.com": `h1, #video-title, #content-text, #title, yt-attributed-string>span>span, #ytp-caption-window-container .ytp-caption-segment`,
"bard.google.com": `.query-content ${DEFAULT_SELECTOR}, message-content ${DEFAULT_SELECTOR}`,
"www.bing.com": `.b_algoSlug, .rwrl_padref; .cib-serp-main >>> .ac-textBlock ${DEFAULT_SELECTOR}, .text-message-content div`,
"www.phoronix.com": `article ${DEFAULT_SELECTOR}`,
"wx2.qq.com": `.js_message_plain`,
"app.slack.com/client/": `.p-rich_text_section, .c-message_attachment__text, .p-rich_text_list li`,
"discord.com/channels/": `div[id^=message-content]`,
"t.me/s/": `.js-message_text ${DEFAULT_SELECTOR}`,
"web.telegram.org/k/": `.message, .bot-commands-list-element-description, .reply-markup-button-text`,
"web.telegram.org/a/": `.message, .text-content, .bot-commands-list-element-description, .reply-markup-button-text`,
"chromereleases.googleblog.com": `.title, .publishdate, p, i, .header-desc, .header-title, .text`,
"www.instagram.com/": `h1, article span[dir=auto] > span[dir=auto], ._ab1y`,
"www.instagram.com/p/,www.instagram.com/reels/": `h1, div[class='x9f619 xjbqb8w x78zum5 x168nmei x13lgxp2 x5pf9jr xo71vjh x1uhb9sk x1plvlek xryxfnj x1c4vz4f x2lah0s xdt5ytf xqjyukv x1cy8zhl x1oa3qoh x1nhvcw1'] > span[class='x1lliihq x1plvlek xryxfnj x1n2onr6 x193iq5w xeuugli x1fj9vlw x13faqbe x1vvkbs x1s928wv xhkezso x1gmr53x x1cpjm7i x1fgarty x1943h6x x1i0vuye xvs91rp xo1l8bm x5n08af x10wh9bi x1wdrske x8viiok x18hxmgj'], span[class='x193iq5w xeuugli x1fj9vlw x13faqbe x1vvkbs xt0psk2 x1i0vuye xvs91rp xo1l8bm x5n08af x10wh9bi x1wdrske x8viiok x18hxmgj']`,
"mail.google.com": `${DEFAULT_SELECTOR}, h2[data-thread-perm-id], span[data-thread-id], div[data-message-id] div[class=''], .messageBody, #views`,
"web.whatsapp.com": `.copyable-text > span`,
"chat.openai.com": `div[data-message-author-role] > div ${DEFAULT_SELECTOR}`,
"forum.ru-board.com": `.tit, .dats, span.post ${DEFAULT_SELECTOR}`,
"education.github.com": `${DEFAULT_SELECTOR}, a, summary, span.Button-content`,
"blogs.windows.com": `${DEFAULT_SELECTOR}, .c-uhf-nav-link, figcaption`,
"developer.apple.com/documentation/": `#main ${DEFAULT_SELECTOR}, #main .abstract .content, #main .abstract.content, #main .link span`,
};
export const BUILTIN_RULES = RULES.sort((a, b) =>
a.pattern.localeCompare(b.pattern)
).map((item) => ({
...DEFAULT_RULE,
...item,
transOpen: "true",
}));
export const BUILTIN_RULES = Object.entries(RULES_MAP)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([pattern, selector]) => ({
...DEFAULT_RULE,
pattern,
selector,
}));

View File

@@ -1,56 +1,3 @@
import { browser } from "./libs/browser";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_TOGGLE_STYLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
} from "./config";
import { getSettingWithDefault, getRulesWithDefault } from "./libs/storage";
import { Translator } from "./libs/translator";
import { isIframe } from "./libs/iframe";
import { matchRule } from "./libs/rules";
import { webfix } from "./libs/webfix";
import { run } from "./common";
/**
* 入口函数
*/
const init = async () => {
const href = isIframe ? document.referrer : document.location.href;
const setting = await getSettingWithDefault();
const rules = await getRulesWithDefault();
const rule = await matchRule(rules, href, setting);
const translator = new Translator(rule, setting);
webfix(href, setting);
// 监听消息
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
switch (action) {
case MSG_TRANS_TOGGLE:
translator.toggle();
break;
case MSG_TRANS_TOGGLE_STYLE:
translator.toggleStyle();
break;
case MSG_TRANS_GETRULE:
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args);
break;
default:
return { error: `message action is unavailable: ${action}` };
}
return { data: translator.rule };
});
};
(async () => {
try {
await init();
} catch (err) {
console.error("[KISS-Translator]", err);
const $err = document.createElement("div");
$err.innerText = `KISS-Translator: ${err.message}`;
$err.style.cssText = "background:red; color:#fff;";
document.body.prepend($err);
}
})();
run();

11
src/hooks/Fab.js Normal file
View File

@@ -0,0 +1,11 @@
import { STOKEY_FAB } from "../config";
import { useStorage } from "./Storage";
/**
* fab hook
* @returns
*/
export function useFab() {
const { data, update } = useStorage(STOKEY_FAB);
return { fab: data, updateFab: update };
}

67
src/hooks/FavWords.js Normal file
View File

@@ -0,0 +1,67 @@
import { KV_WORDS_KEY } from "../config";
import { useCallback, useEffect, useState } from "react";
import { trySyncWords } from "../libs/sync";
import { getWordsWithDefault, setWords } from "../libs/storage";
import { useSyncMeta } from "./Sync";
export function useFavWords() {
const [loading, setLoading] = useState(false);
const [favWords, setFavWords] = useState({});
const { updateSyncMeta } = useSyncMeta();
const toggleFav = useCallback(
async (word) => {
const favs = { ...favWords };
if (favs[word]) {
delete favs[word];
} else {
favs[word] = { createdAt: Date.now() };
}
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
},
[updateSyncMeta, favWords]
);
const mergeWords = useCallback(
async (newWords) => {
const favs = { ...favWords };
newWords.forEach((word) => {
if (!favs[word]) {
favs[word] = { createdAt: Date.now() };
}
});
await setWords(favs);
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords(favs);
},
[updateSyncMeta, favWords]
);
const clearWords = useCallback(async () => {
await setWords({});
await updateSyncMeta(KV_WORDS_KEY);
await trySyncWords();
setFavWords({});
}, [updateSyncMeta]);
useEffect(() => {
(async () => {
try {
setLoading(true);
await trySyncWords();
const favWords = await getWordsWithDefault();
setFavWords(favWords);
} catch (err) {
console.log("[query fav]", err);
} finally {
setLoading(false);
}
})();
}, []);
return { loading, favWords, toggleFav, mergeWords, clearWords };
}

View File

@@ -1,8 +1,9 @@
import { STOKEY_RULES, DEFAULT_RULES } from "../config";
import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncRules } from "../libs/sync";
import { checkRules } from "../libs/rules";
import { useCallback } from "react";
import { useSyncMeta } from "./Sync";
/**
* 规则 hook
@@ -10,13 +11,15 @@ import { useCallback } from "react";
*/
export function useRules() {
const { data: list, save } = useStorage(STOKEY_RULES, DEFAULT_RULES);
const { updateSyncMeta } = useSyncMeta();
const updateRules = useCallback(
async (rules) => {
await save(rules);
trySyncRules(false, true);
await updateSyncMeta(KV_RULES_KEY);
trySyncRules();
},
[save]
[save, updateSyncMeta]
);
const add = useCallback(

View File

@@ -1,25 +1,24 @@
import { STOKEY_SETTING, DEFAULT_SETTING } from "../config";
import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncSetting } from "../libs/sync";
import { createContext, useCallback, useContext, useMemo } from "react";
import { debounce } from "../libs/utils";
import { useSyncMeta } from "./Sync";
const SettingContext = createContext({
setting: {},
setting: null,
updateSetting: async () => {},
reloadSetting: async () => {},
});
export function SettingProvider({ children }) {
const { data, update, reload, loading } = useStorage(
STOKEY_SETTING,
DEFAULT_SETTING
);
const { data, update, reload } = useStorage(STOKEY_SETTING, DEFAULT_SETTING);
const { updateSyncMeta } = useSyncMeta();
const syncSetting = useMemo(
() =>
debounce(() => {
trySyncSetting(false, true);
trySyncSetting();
}, [2000]),
[]
);
@@ -27,12 +26,13 @@ export function SettingProvider({ children }) {
const updateSetting = useCallback(
async (obj) => {
await update(obj);
await updateSyncMeta(KV_SETTING_KEY);
syncSetting();
},
[update, syncSetting]
[update, syncSetting, updateSyncMeta]
);
if (loading) {
if (!data) {
return;
}

View File

@@ -1,9 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { storage } from "../libs/storage";
export function useStorage(key, defaultVal = null) {
const [loading, setLoading] = useState(true);
const [data, setData] = useState(defaultVal);
/**
*
* @param {*} key
* @param {*} defaultVal 需为调用hook外的常量
* @returns
*/
export function useStorage(key, defaultVal) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const save = useCallback(
async (val) => {
@@ -15,7 +21,7 @@ export function useStorage(key, defaultVal = null) {
const update = useCallback(
async (obj) => {
setData((pre) => ({ ...pre, ...obj }));
setData((pre = {}) => ({ ...pre, ...obj }));
await storage.putObj(key, obj);
},
[key]
@@ -27,27 +33,37 @@ export function useStorage(key, defaultVal = null) {
}, [key]);
const reload = useCallback(async () => {
const val = await storage.getObj(key);
if (val) {
setData(val);
} else if (defaultVal) {
setData(defaultVal);
await storage.setObj(key, defaultVal);
try {
setLoading(true);
const val = await storage.getObj(key);
if (val) {
setData(val);
}
} catch (err) {
console.log("[storage reload]", err.message);
} finally {
setLoading(false);
}
}, [key, defaultVal]);
}, [key]);
useEffect(() => {
(async () => {
try {
setLoading(true);
await reload();
const val = await storage.getObj(key);
if (val) {
setData(val);
} else if (defaultVal) {
setData(defaultVal);
await storage.setObj(key, defaultVal);
}
} catch (err) {
//
console.log("[storage load]", err.message);
} finally {
setLoading(false);
}
})();
}, [reload]);
}, [key, defaultVal]);
return { data, save, update, remove, reload, loading };
}

View File

@@ -11,6 +11,23 @@ export function useSync() {
return { sync: data, updateSync: update, reloadSync: reload };
}
/**
* update syncmeta hook
* @returns
*/
export function useSyncMeta() {
const { sync, updateSync } = useSync();
const updateSyncMeta = useCallback(
async (key) => {
const syncMeta = sync?.syncMeta || {};
syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() };
await updateSync({ syncMeta });
},
[sync?.syncMeta, updateSync]
);
return { updateSyncMeta };
}
/**
* caches sync hook
* @param {*} url
@@ -21,7 +38,7 @@ export function useSyncCaches() {
const updateDataCache = useCallback(
async (url) => {
const dataCaches = sync.dataCaches || {};
const dataCaches = sync?.dataCaches || {};
dataCaches[url] = Date.now();
await updateSync({ dataCaches });
},
@@ -30,7 +47,7 @@ export function useSyncCaches() {
const deleteDataCache = useCallback(
async (url) => {
const dataCaches = sync.dataCaches || {};
const dataCaches = sync?.dataCaches || {};
delete dataCaches[url];
await updateSync({ dataCaches });
},
@@ -38,7 +55,7 @@ export function useSyncCaches() {
);
return {
dataCaches: sync.dataCaches || {},
dataCaches: sync?.dataCaches || {},
updateDataCache,
deleteDataCache,
reloadSync,

View File

@@ -12,10 +12,24 @@ import { THEME_DARK, THEME_LIGHT } from "../config";
export default function Theme({ children, options }) {
const { darkMode } = useDarkMode();
const theme = useMemo(() => {
let htmlFontSize = 16;
try {
const s = window.getComputedStyle(document.body.parentNode).fontSize;
const fontSize = parseInt(s.replace("px", ""));
if (fontSize > 0 && fontSize < 1000) {
htmlFontSize = fontSize;
}
} catch (err) {
//
}
return createTheme({
palette: {
mode: darkMode ? THEME_DARK : THEME_LIGHT,
},
typography: {
htmlFontSize,
},
...options,
});
}, [darkMode, options]);

18
src/hooks/Tranbox.js Normal file
View File

@@ -0,0 +1,18 @@
import { useCallback } from "react";
import { DEFAULT_TRANBOX_SETTING } from "../config";
import { useSetting } from "./Setting";
export function useTranbox() {
const { setting, updateSetting } = useSetting();
const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING;
const updateTranbox = useCallback(
async (obj) => {
Object.assign(tranboxSetting, obj);
await updateSetting({ tranboxSetting });
},
[tranboxSetting, updateSetting]
);
return { tranboxSetting, updateTranbox };
}

View File

@@ -23,8 +23,12 @@ export function useTranslate(q, rule, setting) {
try {
setLoading(true);
const deLang = await tryDetectLang(q);
if (deLang && toLang.includes(deLang)) {
const deLang = await tryDetectLang(q, setting.detectRemote);
const disableLangs = setting.disableLangs || [];
if (
deLang &&
(toLang.includes(deLang) || disableLangs.includes(deLang))
) {
setSamelang(true);
} else {
const [trText, isSame] = await apiTranslate({
@@ -32,7 +36,8 @@ export function useTranslate(q, rule, setting) {
text: q,
fromLang,
toLang,
apiSetting: (setting.transApis || DEFAULT_TRANS_APIS)[translator],
apiSetting:
setting.transApis?.[translator] || DEFAULT_TRANS_APIS[translator],
});
setText(trText);
setSamelang(isSame);

58
src/hooks/WebfixRules.js Normal file
View File

@@ -0,0 +1,58 @@
import { STOKEY_WFRULES, KV_WFRULES_KEY } from "../config";
import { useStorage } from "./Storage";
import { trySyncWebfixRules } from "../libs/sync";
import { useCallback } from "react";
import { useSyncMeta } from "./Sync";
const DEFAULT_WFRULES = [];
/**
* 修复规则 hook
* @returns
*/
export function useWebfixRules() {
const { data: list, save } = useStorage(STOKEY_WFRULES, DEFAULT_WFRULES);
const { updateSyncMeta } = useSyncMeta();
const updateRules = useCallback(
async (rules) => {
await save(rules);
await updateSyncMeta(KV_WFRULES_KEY);
trySyncWebfixRules();
},
[save, updateSyncMeta]
);
const add = useCallback(
async (rule) => {
const rules = [...list];
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
return;
}
rules.unshift(rule);
await updateRules(rules);
},
[list, updateRules]
);
const del = useCallback(
async (pattern) => {
let rules = [...list];
rules = rules.filter((item) => item.pattern !== pattern);
await updateRules(rules);
},
[list, updateRules]
);
const put = useCallback(
async (pattern, obj) => {
const rules = [...list];
const rule = rules.find((r) => r.pattern === pattern);
rule && Object.assign(rule, obj);
await updateRules(rules);
},
[list, updateRules]
);
return { list, add, del, put };
}

View File

@@ -32,25 +32,23 @@ function App() {
href={process.env.REACT_APP_HOMEPAGE}
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
</Divider>
<Stack spacing={2} direction="row" useFlexGap flexWrap="wrap">
<Stack spacing={2}>
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>
Install Userscript 1
</Link>
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL2}>
Install Userscript 2
Install/Update Userscript for Tampermonkey/Violentmonkey
</Link>
{/* <Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL2}>
Install/Update Userscript for Tampermonkey/Violentmonkey 2
</Link> */}
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>
Install Userscript Safari 1
Install/Update Userscript for iOS Safari
</Link>
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2}>
Install Userscript Safari 2
</Link>
<Link href={process.env.REACT_APP_OPTIONSPAGE}>
Open Options Page 1
</Link>
<Link href={process.env.REACT_APP_OPTIONSPAGE2}>
{/* <Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2}>
Install/Update Userscript for iOS Safari 2
</Link> */}
<Link href={process.env.REACT_APP_OPTIONSPAGE}>Open Options Page</Link>
{/* <Link href={process.env.REACT_APP_OPTIONSPAGE2}>
Open Options Page 2
</Link>
</Link> */}
</Stack>
{loading ? (

13
src/libs/blacklist.js Normal file
View File

@@ -0,0 +1,13 @@
import { isMatch } from "./utils";
import { DEFAULT_BLACKLIST } from "../config";
/**
* 检查是否在黑名单中
* @param {*} href
* @param {*} param1
* @returns
*/
export const isInBlacklist = (
href,
{ blacklist = DEFAULT_BLACKLIST.join(",\n") }
) => blacklist.split(",").some((url) => isMatch(href, url.trim()));

View File

@@ -13,3 +13,5 @@ function _browser() {
}
export const browser = _browser();
export const isBg = () => globalThis?.ContextType === "BACKGROUND";

View File

@@ -6,13 +6,11 @@ import {
MSG_FETCH_LIMIT,
MSG_FETCH_CLEAR,
CACHE_NAME,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_OPENAI,
DEFAULT_FETCH_INTERVAL,
DEFAULT_FETCH_LIMIT,
} from "../config";
import { msAuth } from "./auth";
import { isBg } from "./browser";
import { newCacheReq, newTransReq } from "./req";
/**
* 油猴脚本的请求封装
@@ -27,55 +25,38 @@ export const fetchGM = async (input, { method = "GET", headers, body } = {}) =>
url: input,
headers,
data: body,
onload: (response) => {
if (response.status === 200) {
const headers = new Headers();
response.responseHeaders.split("\n").forEach((line) => {
const [name, value] = line.split(":").map((item) => item.trim());
if (name && value) {
headers.append(name, value);
}
});
resolve(new Response(response.response, { headers }));
} else {
reject(new Error(`[${response.status}] ${response.responseText}`));
}
// withCredentials: true,
onload: ({ response, responseHeaders, status, statusText, ...opts }) => {
const headers = {};
responseHeaders.split("\n").forEach((line) => {
const [name, value] = line.split(":").map((item) => item.trim());
if (name && value) {
headers[name] = value;
}
});
resolve({
body: response,
headers,
status,
statusText,
});
},
onerror: reject,
});
});
/**
* 构造缓存 request
* @param {*} request
* @returns
*/
const newCacheReq = async (request) => {
if (request.method !== "GET") {
const body = await request.text();
const cacheUrl = new URL(request.url);
cacheUrl.pathname += body;
request = new Request(cacheUrl.toString(), { method: "GET" });
}
return request;
};
/**
* 发起请求
* @param {*} param0
* @returns
*/
const fetchApi = async ({ input, init = {}, translator, token }) => {
if (token) {
if (translator === OPT_TRANS_DEEPL) {
init.headers["Authorization"] = `DeepL-Auth-Key ${token}`; // DeepL
} else if (translator === OPT_TRANS_OPENAI) {
init.headers["Authorization"] = `Bearer ${token}`; // OpenAI
init.headers["api-key"] = token; // Azure OpenAI
} else {
init.headers["Authorization"] = `Bearer ${token}`; // Microsoft & others
}
export const fetchApi = async ({ input, init, transOpts, apiSetting }) => {
if (transOpts?.translator) {
[input, init] = await newTransReq(transOpts, apiSetting);
}
if (!input) {
throw new Error("url is empty");
}
if (isGm) {
@@ -85,19 +66,26 @@ const fetchApi = async ({ input, init = {}, translator, token }) => {
} else {
info = GM.info;
}
// Tampermonkey --> .connects
// Violentmonkey --> .connect
const connects = info?.script?.connects || info?.script?.connect || [];
const url = new URL(input);
const isSafe = connects.find((item) => url.hostname.endsWith(item));
if (isSafe) {
if (window.KISS_GM) {
return window.KISS_GM.fetch(input, init);
} else {
return fetchGM(input, init);
}
const { body, headers, status, statusText } = window.KISS_GM
? await window.KISS_GM.fetch(input, init)
: await fetchGM(input, init);
return new Response(body, {
headers: new Headers(headers),
status,
statusText,
});
}
}
return fetch(input, init);
};
@@ -106,13 +94,7 @@ const fetchApi = async ({ input, init = {}, translator, token }) => {
*/
export const fetchPool = taskPool(
fetchApi,
async ({ translator }) => {
if (translator === OPT_TRANS_MICROSOFT) {
const [token] = await msAuth();
return { token };
}
return {};
},
null,
DEFAULT_FETCH_INTERVAL,
DEFAULT_FETCH_LIMIT
);
@@ -125,9 +107,9 @@ export const fetchPool = taskPool(
*/
export const fetchData = async (
input,
{ useCache, usePool, translator, token, ...init } = {}
{ useCache, usePool, transOpts, apiSetting, ...init } = {}
) => {
const cacheReq = await newCacheReq(new Request(input, init));
const cacheReq = await newCacheReq(input, init);
let res;
// 查询缓存
@@ -143,13 +125,19 @@ export const fetchData = async (
if (!res) {
// 发送请求
if (usePool) {
res = await fetchPool.push({ input, init, translator, token });
res = await fetchPool.push({ input, init, transOpts, apiSetting });
} else {
res = await fetchApi({ input, init, translator, token });
res = await fetchApi({ input, init, transOpts, apiSetting });
}
if (!res?.ok) {
throw new Error(`response: ${res.statusText}`);
const cause = {
status: res.status,
};
if (res.headers.get("Content-Type")?.includes("json")) {
cause.body = await res.json();
}
throw new Error(`response: [${res.status}] ${res.statusText}`, { cause });
}
// 插入缓存
@@ -176,16 +164,16 @@ export const fetchData = async (
* @param {*} opts
* @returns
*/
export const fetchPolyfill = async (input, { isBg = false, ...opts } = {}) => {
if (!input.trim()) {
export const fetchPolyfill = async (input, opts) => {
if (!input?.trim()) {
throw new Error("URL is empty");
}
// 插件
if (isExt && !isBg) {
if (isExt && !isBg()) {
const res = await sendBgMsg(MSG_FETCH, { input, opts });
if (res.error) {
throw new Error(res.error);
throw new Error(res.error, { cause: res.cause });
}
return res.data;
}

View File

@@ -5,3 +5,7 @@ export const sendIframeMsg = (action, args) => {
iframe.contentWindow.postMessage({ action, args }, "*");
});
};
export const sendParentMsg = (action, args) => {
window.parent.postMessage({ action, args }, "*");
};

View File

@@ -1,5 +1,6 @@
import { CACHE_NAME } from "../config";
import { browser } from "./browser";
import { apiBaiduLangdetect } from "../apis";
/**
* 清除缓存数据
@@ -13,15 +14,29 @@ export const tryClearCaches = async () => {
};
/**
* 本地语言识别
* 语言识别
* @param {*} q
* @returns
*/
export const tryDetectLang = async (q) => {
try {
const res = await browser?.i18n?.detectLanguage(q);
return res?.languages?.[0]?.language;
} catch (err) {
console.log("[detect lang]", err.message);
export const tryDetectLang = async (q, useRemote = false) => {
let lang = "";
if (useRemote) {
try {
lang = await apiBaiduLangdetect(q);
} catch (err) {
console.log("[detect lang remote]", err.message);
}
}
if (!lang) {
try {
const res = await browser?.i18n?.detectLanguage(q);
lang = res?.languages?.[0]?.language;
} catch (err) {
console.log("[detect lang local]", err.message);
}
}
return lang;
};

205
src/libs/inputTranslate.js Normal file
View File

@@ -0,0 +1,205 @@
import {
DEFAULT_INPUT_RULE,
DEFAULT_TRANS_APIS,
DEFAULT_INPUT_SHORTCUT,
OPT_LANGS_LIST,
} from "../config";
import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils";
import { stepShortcutRegister } from "./shortcut";
import { apiTranslate } from "../apis";
import { tryDetectLang } from ".";
import { loadingSvg } from "./svg";
function isInputNode(node) {
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
}
function isEditAbleNode(node) {
return node.hasAttribute("contenteditable");
}
function selectContent(node) {
node.focus();
const range = document.createRange();
range.selectNodeContents(node);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function pasteContentEvent(node, text) {
node.focus();
const data = new DataTransfer();
data.setData("text/plain", text);
const event = new ClipboardEvent("paste", { clipboardData: data });
document.dispatchEvent(event);
data.clearData();
}
function pasteContentCommand(node, text) {
node.focus();
document.execCommand("insertText", false, text);
}
function collapseToEnd(node) {
node.focus();
const selection = window.getSelection();
selection.collapseToEnd();
}
function getNodeText(node) {
if (isInputNode(node)) {
return node.value;
}
return node.innerText || node.textContent || "";
}
function addLoading(node, loadingId) {
const div = document.createElement("div");
div.id = loadingId;
div.innerHTML = loadingSvg;
div.style.cssText = `
width: ${node.offsetWidth}px;
height: ${node.offsetHeight}px;
line-height: ${node.offsetHeight}px;
position: absolute;
text-align: center;
left: ${node.offsetLeft}px;
top: ${node.offsetTop}px;
z-index: 2147483647;
`;
node.offsetParent?.appendChild(div);
}
function removeLoading(node, loadingId) {
const div = node.offsetParent.querySelector(`#${loadingId}`);
if (div) {
div.remove();
}
}
/**
* 输入框翻译
*/
export default function inputTranslate ({
inputRule: {
transOpen,
triggerShortcut,
translator,
fromLang,
toLang,
triggerCount,
triggerTime,
transSign,
} = DEFAULT_INPUT_RULE,
transApis,
detectRemote,
}) {
if (!transOpen) {
return;
}
const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator];
if (triggerShortcut.length === 0) {
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
triggerCount = 1;
}
stepShortcutRegister(
triggerShortcut,
async () => {
let node = document.activeElement;
if (!node) {
return;
}
while (node.shadowRoot) {
node = node.shadowRoot.activeElement;
}
if (!isInputNode(node) && !isEditAbleNode(node)) {
return;
}
let initText = getNodeText(node);
if (triggerShortcut.length === 1 && triggerShortcut[0].length === 1) {
// todo: remove multiple char
initText = removeEndchar(initText, triggerShortcut[0], triggerCount);
}
if (!initText.trim()) {
return;
}
let text = initText;
if (transSign) {
const res = matchInputStr(text, transSign);
if (res) {
let lang = res[1];
if (lang === "zh" || lang === "cn") {
lang = "zh-CN";
} else if (lang === "tw" || lang === "hk") {
lang = "zh-TW";
}
if (lang && OPT_LANGS_LIST.includes(lang)) {
toLang = lang;
}
text = res[2];
}
}
// console.log("input -->", text);
const loadingId = "kiss-" + genEventName();
try {
addLoading(node, loadingId);
const deLang = await tryDetectLang(text, detectRemote);
if (deLang && toLang.includes(deLang)) {
return;
}
const [trText, isSame] = await apiTranslate({
translator,
text,
fromLang,
toLang,
apiSetting,
});
if (!trText || isSame) {
return;
}
if (isInputNode(node)) {
node.value = trText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
return;
}
selectContent(node);
await sleep(200);
pasteContentEvent(node, trText);
await sleep(200);
// todo: use includes?
if (getNodeText(node).startsWith(initText)) {
pasteContentCommand(node, trText);
await sleep(100);
} else {
collapseToEnd(node);
}
} catch (err) {
console.log("[translate input]", err.message);
} finally {
removeLoading(node, loadingId);
}
},
triggerCount,
triggerTime
);
}

View File

@@ -6,7 +6,13 @@
* @param {*} _limit
* @returns
*/
export const taskPool = (fn, preFn, _interval = 100, _limit = 100) => {
export const taskPool = (
fn,
preFn,
_interval = 100,
_limit = 100,
_retryInteral = 1000
) => {
const pool = [];
const maxRetry = 2; // 最大重试次数
let maxCount = _limit; // 最大数量
@@ -14,23 +20,6 @@ export const taskPool = (fn, preFn, _interval = 100, _limit = 100) => {
let interval = _interval; // 间隔时间
let timer = null;
const handleTask = async (item, preArgs) => {
curCount++;
const { args, resolve, reject, retry } = item;
try {
const res = await fn({ ...args, ...preArgs });
resolve(res);
} catch (err) {
if (retry < maxRetry) {
pool.push({ args, resolve, reject, retry: retry + 1 });
} else {
reject(err);
}
} finally {
curCount--;
}
};
const run = async () => {
// console.log("timer", timer);
timer && clearTimeout(timer);
@@ -39,12 +28,24 @@ export const taskPool = (fn, preFn, _interval = 100, _limit = 100) => {
if (curCount < maxCount) {
const item = pool.shift();
if (item) {
curCount++;
const { args, resolve, reject, retry } = item;
try {
const preArgs = await preFn(item.args);
handleTask(item, preArgs);
const preArgs = preFn ? await preFn(item.args) : {};
const res = await fn({ ...args, ...preArgs });
resolve(res);
} catch (err) {
console.log("[preFn]", err);
pool.push(item);
console.log("[task]", retry, err);
if (retry < maxRetry) {
const retryTimer = setTimeout(() => {
clearTimeout(retryTimer);
pool.push({ args, resolve, reject, retry: retry + 1 });
}, _retryInteral);
} else {
reject(err);
}
} finally {
curCount--;
}
}
}

286
src/libs/req.js Normal file
View File

@@ -0,0 +1,286 @@
import queryString from "query-string";
import {
OPT_TRANS_GOOGLE,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPL,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_DEEPLX,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_CLOUDFLAREAI,
OPT_TRANS_CUSTOMIZE,
URL_MICROSOFT_TRAN,
URL_TENCENT_TRANSMART,
PROMPT_PLACE_FROM,
PROMPT_PLACE_TO,
PROMPT_PLACE_TEXT,
} from "../config";
import { msAuth } from "./auth";
import { genDeeplFree } from "../apis/deepl";
import { genBaidu } from "../apis/baidu";
/**
* 构造缓存 request
* @param {*} request
* @returns
*/
export const newCacheReq = async (input, init) => {
let request = new Request(input, init);
if (request.method !== "GET") {
const body = await request.text();
const cacheUrl = new URL(request.url);
cacheUrl.pathname += body;
request = new Request(cacheUrl.toString(), { method: "GET" });
}
return request;
};
const genGoogle = ({ text, from, to, url, key }) => {
const params = {
client: "gtx",
dt: "t",
dj: 1,
ie: "UTF-8",
sl: from,
tl: to,
q: text,
};
const input = `${url}?${queryString.stringify(params)}`;
const init = {
headers: {
"Content-type": "application/json",
},
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [input, init];
};
const genMicrosoft = async ({ text, from, to }) => {
const [token] = await msAuth();
const params = {
from,
to,
"api-version": "3.0",
};
const input = `${URL_MICROSOFT_TRAN}?${queryString.stringify(params)}`;
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${token}`,
},
method: "POST",
body: JSON.stringify([{ Text: text }]),
};
return [input, init];
};
const genDeepl = ({ text, from, to, url, key }) => {
const data = {
text: [text],
target_lang: to,
source_lang: from,
// split_sentences: "0",
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `DeepL-Auth-Key ${key}`,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genDeeplX = ({ text, from, to, url, key }) => {
const data = {
text,
target_lang: to,
source_lang: from,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [url, init];
};
const genTencent = ({ text, from, to }) => {
const data = {
header: {
fn: "auto_translation_block",
},
source: {
text_block: text,
lang: from,
},
target: {
lang: to,
},
};
const init = {
headers: {
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
return [URL_TENCENT_TRANSMART, init];
};
const genOpenAI = ({ text, from, to, url, key, prompt, model }) => {
prompt = prompt
.replaceAll(PROMPT_PLACE_FROM, from)
.replaceAll(PROMPT_PLACE_TO, to);
const data = {
model,
messages: [
{
role: "system",
content: prompt,
},
{
role: "user",
content: text,
},
],
temperature: 0,
max_tokens: 256,
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${key}`, // OpenAI
"api-key": key, // Azure OpenAI
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genGemini = ({ text, from, to, url, key, prompt, model }) => {
prompt = prompt
.replaceAll(PROMPT_PLACE_FROM, from)
.replaceAll(PROMPT_PLACE_TO, to)
.replaceAll(PROMPT_PLACE_TEXT, text);
const data = {
contents: [
{
// role: "user",
parts: [
{
text: prompt,
},
],
},
],
};
const input = `${url}/${model}:generateContent?key=${key}`;
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
return [input, init];
};
const genCloudflareAI = ({ text, from, to, url, key }) => {
const data = {
text,
source_lang: from,
target_lang: to,
};
const init = {
headers: {
"Content-type": "application/json",
Authorization: `Bearer ${key}`,
},
method: "POST",
body: JSON.stringify(data),
};
return [url, init];
};
const genCustom = ({ text, from, to, url, key }) => {
const data = {
text,
from,
to,
};
const init = {
headers: {
"Content-type": "application/json",
},
method: "POST",
body: JSON.stringify(data),
};
if (key) {
init.headers.Authorization = `Bearer ${key}`;
}
return [url, init];
};
/**
* 构造翻译接口 request
* @param {*}
* @returns
*/
export const newTransReq = ({ translator, text, from, to }, apiSetting) => {
const args = { text, from, to, ...apiSetting };
switch (translator) {
case OPT_TRANS_GOOGLE:
return genGoogle(args);
case OPT_TRANS_MICROSOFT:
return genMicrosoft(args);
case OPT_TRANS_DEEPL:
return genDeepl(args);
case OPT_TRANS_DEEPLFREE:
return genDeeplFree(args);
case OPT_TRANS_DEEPLX:
return genDeeplX(args);
case OPT_TRANS_BAIDU:
return genBaidu(args);
case OPT_TRANS_TENCENT:
return genTencent(args);
case OPT_TRANS_OPENAI:
return genOpenAI(args);
case OPT_TRANS_GEMINI:
return genGemini(args);
case OPT_TRANS_CLOUDFLAREAI:
return genCloudflareAI(args);
case OPT_TRANS_CUSTOMIZE:
return genCustom(args);
default:
throw new Error(`[trans] translator: ${translator} not support`);
}
};

View File

@@ -21,7 +21,6 @@ import { trySyncRules } from "./sync";
* @returns
*/
export const matchRule = async (
rules,
href,
{
injectRules = true,
@@ -29,7 +28,7 @@ export const matchRule = async (
owSubrule = DEFAULT_OW_RULE,
}
) => {
rules = [...rules];
const rules = await getRulesWithDefault();
if (injectRules) {
try {
const selectedSub = subrulesList.find((item) => item.selected);
@@ -149,5 +148,5 @@ export const saveRule = async (newRule) => {
rules.unshift(newRule);
}
await setRules(rules);
trySyncRules(false, true);
trySyncRules();
};

View File

@@ -22,14 +22,14 @@ export const shortcutListener = (fn, target = document, timeout = 3000) => {
}, timeout);
if (e.code) {
allkeys.add(e.key);
curkeys.add(e.key);
allkeys.add(e.code);
curkeys.add(e.code);
fn([...curkeys], [...allkeys]);
}
};
const handleKeyup = (e) => {
curkeys.delete(e.key);
curkeys.delete(e.code);
if (curkeys.size === 0) {
fn([...curkeys], [...allkeys]);
allkeys.clear();

View File

@@ -1,9 +1,12 @@
import {
STOKEY_SETTING,
STOKEY_RULES,
STOKEY_WFRULES,
STOKEY_WORDS,
STOKEY_FAB,
STOKEY_SYNC,
STOKEY_MSAUTH,
STOKEY_BDAUTH,
STOKEY_RULESCACHE_PREFIX,
STOKEY_WEBFIXCACHE_PREFIX,
DEFAULT_SETTING,
@@ -96,6 +99,21 @@ export const getRulesWithDefault = async () =>
(await getRules()) || DEFAULT_RULES;
export const setRules = (val) => setObj(STOKEY_RULES, val);
/**
* 修复规则列表
*/
export const getWebfixRules = () => getObj(STOKEY_WFRULES);
export const getWebfixRulesWithDefault = async () =>
(await getWebfixRules()) || [];
export const setWebfixRules = (val) => setObj(STOKEY_WFRULES, val);
/**
* 词汇列表
*/
export const getWords = () => getObj(STOKEY_WORDS);
export const getWordsWithDefault = async () => (await getWords()) || {};
export const setWords = (val) => setObj(STOKEY_WORDS, val);
/**
* 订阅规则
*/
@@ -119,6 +137,7 @@ export const setWebfix = (url, val) =>
export const getFab = () => getObj(STOKEY_FAB);
export const getFabWithDefault = async () => (await getFab()) || {};
export const setFab = (obj) => setObj(STOKEY_FAB, obj);
export const updateFab = (obj) => putObj(STOKEY_FAB, obj);
/**
* 数据同步
@@ -133,6 +152,12 @@ export const updateSync = (obj) => putObj(STOKEY_SYNC, obj);
export const getMsauth = () => getObj(STOKEY_MSAUTH);
export const setMsauth = (val) => setObj(STOKEY_MSAUTH, val);
/**
* baidu auth
*/
export const getBdauth = () => getObj(STOKEY_BDAUTH);
export const setBdauth = (val) => setObj(STOKEY_BDAUTH, val);
/**
* 存入默认数据
*/

View File

@@ -25,8 +25,8 @@ const updateSyncDataCache = async (url) => {
* @param {*} url
* @returns
*/
export const syncSubRules = async (url, isBg = false) => {
const res = await apiFetch(url, isBg);
export const syncSubRules = async (url) => {
const res = await apiFetch(url);
const rules = checkRules(res).filter(
({ pattern }) => !isAllchar(pattern, GLOBAL_KEY)
);
@@ -41,10 +41,10 @@ export const syncSubRules = async (url, isBg = false) => {
* @param {*} url
* @returns
*/
export const syncAllSubRules = async (subrulesList, isBg = false) => {
export const syncAllSubRules = async (subrulesList) => {
for (let subrules of subrulesList) {
try {
await syncSubRules(subrules.url, isBg);
await syncSubRules(subrules.url);
await updateSyncDataCache(subrules.url);
} catch (err) {
console.log(`[sync subrule error]: ${subrules.url}`, err);
@@ -57,14 +57,14 @@ export const syncAllSubRules = async (subrulesList, isBg = false) => {
* @param {*} url
* @returns
*/
export const trySyncAllSubRules = async ({ subrulesList }, isBg = false) => {
export const trySyncAllSubRules = async ({ subrulesList }) => {
try {
const { subRulesSyncAt } = await getSyncWithDefault();
const now = Date.now();
const interval = 24 * 60 * 60 * 1000; // 间隔一天
if (now - subRulesSyncAt > interval) {
// 同步订阅规则
await syncAllSubRules(subrulesList, isBg);
await syncAllSubRules(subrulesList);
await updateSync({ subRulesSyncAt: now });
// 同步修复规则

View File

@@ -1,65 +1,122 @@
import {
APP_LCNAME,
KV_SETTING_KEY,
KV_RULES_KEY,
KV_WFRULES_KEY,
KV_WORDS_KEY,
KV_RULES_SHARE_KEY,
KV_SALT_SHARE,
OPT_SYNCTYPE_WEBDAV,
} from "../config";
import {
getSyncWithDefault,
updateSync,
getSettingWithDefault,
getRulesWithDefault,
getWordsWithDefault,
getWebfixRulesWithDefault,
setSetting,
setRules,
setWebfixRules,
setWords,
} from "./storage";
import { apiSyncData } from "../apis";
import { sha256 } from "./utils";
import { sha256, removeEndchar } from "./utils";
import { createClient, getPatcher } from "webdav";
import { fetchApi } from "./fetch";
getPatcher().patch("request", (opts) => {
return fetchApi({
input: opts.url,
init: { method: opts.method, headers: opts.headers, body: opts.data },
});
});
const syncByWebdav = async (data, { syncUrl, syncUser, syncKey }) => {
const client = createClient(syncUrl, {
username: syncUser,
password: syncKey,
});
const pathname = `/${APP_LCNAME}`;
const filename = `/${APP_LCNAME}/${data.key}`;
if ((await client.exists(pathname)) === false) {
await client.createDirectory(pathname);
}
const isExist = await client.exists(filename);
if (isExist) {
const cont = await client.getFileContents(filename, { format: "text" });
const webData = JSON.parse(cont);
if (webData.updateAt >= data.updateAt) {
return webData;
}
}
await client.putFileContents(filename, JSON.stringify(data, null, 2));
return data;
};
const syncByWorker = async (data, { syncUrl, syncKey }) => {
syncUrl = removeEndchar(syncUrl, "/");
return await apiSyncData(`${syncUrl}/sync`, syncKey, data);
};
const syncData = async (key, valueFn) => {
const {
syncType,
syncUrl,
syncUser,
syncKey,
syncMeta = {},
} = await getSyncWithDefault();
if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) {
return;
}
let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {};
syncAt === 0 && (updateAt = 0);
const value = await valueFn();
const data = {
key,
value: JSON.stringify(value),
updateAt,
};
const args = {
syncUrl,
syncUser,
syncKey,
};
const res =
syncType === OPT_SYNCTYPE_WEBDAV
? await syncByWebdav(data, args)
: await syncByWorker(data, args);
syncMeta[key] = {
updateAt: res.updateAt,
syncAt: Date.now(),
};
await updateSync({ syncMeta });
return { value: JSON.parse(res.value), isNew: res.updateAt > updateAt };
};
/**
* 同步设置
* @returns
*/
const syncSetting = async (isBg = false, isForce = false) => {
let {
syncUrl,
syncKey,
settingUpdateAt = 0,
settingSyncAt = 0,
} = await getSyncWithDefault();
if (!syncUrl || !syncKey) {
return;
}
if (isForce) {
settingUpdateAt = Date.now();
}
const setting = await getSettingWithDefault();
const res = await apiSyncData(
syncUrl,
syncKey,
{
key: KV_SETTING_KEY,
value: setting,
updateAt: settingSyncAt === 0 ? 0 : settingUpdateAt,
},
isBg
);
if (res.updateAt > settingUpdateAt) {
const syncSetting = async () => {
const res = await syncData(KV_SETTING_KEY, getSettingWithDefault);
if (res?.isNew) {
await setSetting(res.value);
}
await updateSync({
settingUpdateAt: res.updateAt,
settingSyncAt: Date.now(),
});
return res.value;
};
export const trySyncSetting = async (isBg = false, isForce = false) => {
export const trySyncSetting = async () => {
try {
return await syncSetting(isBg, isForce);
await syncSetting();
} catch (err) {
console.log("[sync setting]", err);
}
@@ -69,65 +126,77 @@ export const trySyncSetting = async (isBg = false, isForce = false) => {
* 同步规则
* @returns
*/
const syncRules = async (isBg = false, isForce = false) => {
let {
syncUrl,
syncKey,
rulesUpdateAt = 0,
rulesSyncAt = 0,
} = await getSyncWithDefault();
if (!syncUrl || !syncKey) {
return;
}
if (isForce) {
rulesUpdateAt = Date.now();
}
const rules = await getRulesWithDefault();
const res = await apiSyncData(
syncUrl,
syncKey,
{
key: KV_RULES_KEY,
value: rules,
updateAt: rulesSyncAt === 0 ? 0 : rulesUpdateAt,
},
isBg
);
if (res.updateAt > rulesUpdateAt) {
const syncRules = async () => {
const res = await syncData(KV_RULES_KEY, getRulesWithDefault);
if (res?.isNew) {
await setRules(res.value);
}
await updateSync({
rulesUpdateAt: res.updateAt,
rulesSyncAt: Date.now(),
});
return res.value;
};
export const trySyncRules = async (isBg = false, isForce = false) => {
export const trySyncRules = async () => {
try {
return await syncRules(isBg, isForce);
await syncRules();
} catch (err) {
console.log("[sync user rules]", err);
}
};
/**
* 同步修复规则
* @returns
*/
const syncWebfixRules = async () => {
const res = await syncData(KV_WFRULES_KEY, getWebfixRulesWithDefault);
if (res?.isNew) {
await setWebfixRules(res.value);
}
};
export const trySyncWebfixRules = async () => {
try {
await syncWebfixRules();
} catch (err) {
console.log("[sync user webfix rules]", err);
}
};
/**
* 同步词汇
* @returns
*/
const syncWords = async () => {
const res = await syncData(KV_WORDS_KEY, getWordsWithDefault);
if (res?.isNew) {
await setWords(res.value);
}
};
export const trySyncWords = async () => {
try {
await syncWords();
} catch (err) {
console.log("[sync fav words]", err);
}
};
/**
* 同步分享规则
* @param {*} param0
* @returns
*/
export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
await apiSyncData(syncUrl, syncKey, {
const data = {
key: KV_RULES_SHARE_KEY,
value: rules,
value: JSON.stringify(rules, null, 2),
updateAt: Date.now(),
});
};
const args = {
syncUrl,
syncKey,
};
await syncByWorker(data, args);
const psk = await sha256(syncKey, KV_SALT_SHARE);
const shareUrl = `${syncUrl}?psk=${psk}`;
const shareUrl = `${syncUrl}/rules?psk=${psk}`;
return shareUrl;
};
@@ -135,10 +204,16 @@ export const syncShareRules = async ({ rules, syncUrl, syncKey }) => {
* 同步个人设置和规则
* @returns
*/
export const syncSettingAndRules = async (isBg = false) => {
return [await syncSetting(isBg), await syncRules(isBg)];
export const syncSettingAndRules = async () => {
await syncSetting();
await syncRules();
await syncWebfixRules();
await syncWords();
};
export const trySyncSettingAndRules = async (isBg = false) => {
return [await trySyncSetting(isBg), await trySyncRules(isBg)];
export const trySyncSettingAndRules = async () => {
await trySyncSetting();
await trySyncRules();
await trySyncWebfixRules();
await trySyncWords();
};

12
src/libs/touch.js Normal file
View File

@@ -0,0 +1,12 @@
export function touchTapListener(fn, touchsLength) {
const handleTouchend = (e) => {
if (e.touches.length === touchsLength) {
fn();
}
};
document.addEventListener("touchstart", handleTouchend);
return () => {
document.removeEventListener("touchstart", handleTouchend);
};
}

View File

@@ -9,101 +9,16 @@ import {
SHADOW_KEY,
OPT_MOUSEKEY_DISABLE,
OPT_MOUSEKEY_MOUSEOVER,
DEFAULT_INPUT_RULE,
DEFAULT_TRANS_APIS,
DEFAULT_INPUT_SHORTCUT,
OPT_LANGS_LIST,
} from "../config";
import Content from "../views/Content";
import { updateFetchPool, clearFetchPool } from "./fetch";
import {
debounce,
genEventName,
removeEndchar,
matchInputStr,
sleep,
} from "./utils";
import { stepShortcutRegister } from "./shortcut";
import { apiTranslate } from "../apis";
import { tryDetectLang } from ".";
import { loadingSvg } from "./svg";
function isInputNode(node) {
return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA";
}
function isEditAbleNode(node) {
return node.hasAttribute("contenteditable");
}
function selectContent(node) {
node.focus();
const range = document.createRange();
range.selectNodeContents(node);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function pasteContentEvent(node, text) {
node.focus();
const data = new DataTransfer();
data.setData("text/plain", text);
const event = new ClipboardEvent("paste", { clipboardData: data });
document.dispatchEvent(event);
data.clearData();
}
function pasteContentCommand(node, text) {
node.focus();
document.execCommand("insertText", false, text);
}
function collapseToEnd(node) {
node.focus();
const selection = window.getSelection();
selection.collapseToEnd();
}
function getNodeText(node) {
if (isInputNode(node)) {
return node.value;
}
return node.innerText || node.textContent || "";
}
function addLoading(node, loadingId) {
const div = document.createElement("div");
div.id = loadingId;
div.innerHTML = loadingSvg;
div.style.cssText = `
width: ${node.offsetWidth}px;
height: ${node.offsetHeight}px;
line-height: ${node.offsetHeight}px;
position: absolute;
text-align: center;
left: ${node.offsetLeft}px;
top: ${node.offsetTop}px;
z-index: 2147483647;
`;
node.offsetParent?.appendChild(div);
}
function removeLoading(loadingId) {
const div = document.getElementById(loadingId);
if (div) {
div.remove();
}
}
import { debounce, genEventName } from "./utils";
/**
* 翻译类
*/
export class Translator {
_rule = {};
_inputRule = {};
_setting = {};
_rootNodes = new Set();
_tranNodes = new Map();
@@ -124,6 +39,7 @@ export class Translator {
"iframe",
];
_eventName = genEventName();
_mouseoverNode = null;
// 显示
_interseObserver = new IntersectionObserver(
@@ -186,11 +102,6 @@ export class Translator {
if (rule.transOpen === "true") {
this._register();
}
this._inputRule = setting.inputRule || DEFAULT_INPUT_RULE;
if (this._inputRule.transOpen) {
this._registerInput();
}
}
get setting() {
@@ -259,6 +170,22 @@ export class Translator {
);
};
_queryShadowNodes = (selector, rootNode) => {
this._rootNodes.add(rootNode);
this._queryFilter(selector, rootNode).forEach((item) => {
if (!this._tranNodes.has(item)) {
this._tranNodes.set(item, "");
}
});
Array.from(rootNode.querySelectorAll("*"))
.map((item) => item.shadowRoot)
.filter(Boolean)
.forEach((item) => {
this._queryShadowNodes(selector, item);
});
};
_queryNodes = (rootNode = document) => {
// const childRoots = Array.from(rootNode.querySelectorAll("*"))
// .map((item) => item.shadowRoot)
@@ -281,14 +208,15 @@ export class Translator {
const outNodes = this._querySelectorAll(outSelector, rootNode);
outNodes.forEach((outNode) => {
if (outNode.shadowRoot) {
this._rootNodes.add(outNode.shadowRoot);
this._queryFilter(inSelector, outNode.shadowRoot).forEach(
(item) => {
if (!this._tranNodes.has(item)) {
this._tranNodes.set(item, "");
}
}
);
// this._rootNodes.add(outNode.shadowRoot);
// this._queryFilter(inSelector, outNode.shadowRoot).forEach(
// (item) => {
// if (!this._tranNodes.has(item)) {
// this._tranNodes.set(item, "");
// }
// }
// );
this._queryShadowNodes(inSelector, outNode.shadowRoot);
}
});
}
@@ -319,135 +247,65 @@ export class Translator {
});
});
this._tranNodes.forEach((_, node) => {
if (
!this._setting.mouseKey ||
this._setting.mouseKey === OPT_MOUSEKEY_DISABLE
) {
// 监听节点显示
if (
!this._setting.mouseKey ||
this._setting.mouseKey === OPT_MOUSEKEY_DISABLE
) {
// 监听节点显示
this._tranNodes.forEach((_, node) => {
this._interseObserver.observe(node);
} else {
// 监听鼠标悬停
node.addEventListener("mouseover", this._handleMouseover);
}
});
};
_registerInput = () => {
const {
triggerShortcut: initTriggerShortcut,
translator,
fromLang,
toLang: initToLang,
triggerCount: initTriggerCount,
triggerTime,
transSign,
} = this._inputRule;
const apiSetting = (this._setting.transApis || DEFAULT_TRANS_APIS)[
translator
];
let triggerShortcut = initTriggerShortcut;
let triggerCount = initTriggerCount;
if (triggerShortcut.length === 0) {
triggerShortcut = DEFAULT_INPUT_SHORTCUT;
triggerCount = 1;
});
} else {
// 监听鼠标悬停
window.addEventListener("keydown", this._handleKeydown);
this._tranNodes.forEach((_, node) => {
node.addEventListener("mouseenter", this._handleMouseover);
node.addEventListener("mouseleave", this._handleMouseout);
});
}
stepShortcutRegister(
triggerShortcut,
async () => {
const node = document.activeElement;
if (!node || !(isInputNode(node) || isEditAbleNode(node))) {
return;
}
let initText = getNodeText(node);
if (triggerShortcut.length === 1 && triggerShortcut[0].length === 1) {
// todo: remove multiple char
initText = removeEndchar(initText, triggerShortcut[0], triggerCount);
}
if (!initText.trim()) {
return;
}
let text = initText;
let toLang = initToLang;
if (transSign) {
const res = matchInputStr(text, transSign);
if (res) {
let lang = res[1];
if (lang === "zh" || lang === "cn") {
lang = "zh-CN";
} else if (lang === "tw" || lang === "hk") {
lang = "zh-TW";
}
if (lang && OPT_LANGS_LIST.includes(lang)) {
toLang = lang;
}
text = res[2];
}
}
// console.log("input -->", text);
const loadingId = "kiss-" + genEventName();
try {
addLoading(node, loadingId);
const deLang = await tryDetectLang(text);
if (deLang && toLang.includes(deLang)) {
return;
}
const [trText, isSame] = await apiTranslate({
translator,
text,
fromLang,
toLang,
apiSetting,
});
if (!trText || isSame) {
return;
}
if (isInputNode(node)) {
node.value = trText;
node.dispatchEvent(
new Event("input", { bubbles: true, cancelable: true })
);
return;
}
selectContent(node);
await sleep(200);
pasteContentEvent(node, trText);
await sleep(200);
// todo: use includes?
if (getNodeText(node).startsWith(initText)) {
pasteContentCommand(node, trText);
await sleep(100);
} else {
collapseToEnd(node);
}
} catch (err) {
console.log("[translate input]", err.message);
} finally {
removeLoading(loadingId);
}
},
triggerCount,
triggerTime
);
};
_handleMouseover = (e) => {
// console.log("mouseenter", e);
if (!this._tranNodes.has(e.target)) {
return;
}
const key = this._setting.mouseKey.slice(3);
if (this._setting.mouseKey === OPT_MOUSEKEY_MOUSEOVER || e[key]) {
e.target.removeEventListener("mouseover", this._handleMouseover);
e.target.removeEventListener("mouseenter", this._handleMouseover);
e.target.removeEventListener("mouseleave", this._handleMouseout);
this._render(e.target);
} else {
this._mouseoverNode = e.target;
}
};
_handleMouseout = (e) => {
// console.log("mouseleave", e);
if (!this._tranNodes.has(e.target)) {
return;
}
this._mouseoverNode = null;
};
_handleKeydown = (e) => {
// console.log("keydown", e);
const key = this._setting.mouseKey.slice(3);
if (e[key] && this._mouseoverNode) {
this._mouseoverNode.removeEventListener(
"mouseenter",
this._handleMouseover
);
this._mouseoverNode.removeEventListener(
"mouseleave",
this._handleMouseout
);
const node = this._mouseoverNode;
this._render(node);
this._mouseoverNode = null;
}
};
@@ -458,21 +316,27 @@ export class Translator {
// 解除节点显示监听
// this._interseObserver.disconnect();
this._tranNodes.forEach((_, node) => {
if (
!this._setting.mouseKey ||
this._setting.mouseKey === OPT_MOUSEKEY_DISABLE
) {
// 解除节点显示监听
if (
!this._setting.mouseKey ||
this._setting.mouseKey === OPT_MOUSEKEY_DISABLE
) {
// 解除节点显示监听
this._tranNodes.forEach((_, node) => {
this._interseObserver.unobserve(node);
} else {
// 移除鼠标悬停监听
node.removeEventListener("mouseover", this._handleMouseover);
}
// 移除已插入元素
node.querySelector(APP_LCNAME)?.remove();
});
// 移除已插入元素
node.querySelector(APP_LCNAME)?.remove();
});
} else {
// 移除鼠标悬停监听
window.removeEventListener("keydown", this._handleKeydown);
this._tranNodes.forEach((_, node) => {
// node.style.pointerEvents = "none";
node.removeEventListener("mouseenter", this._handleMouseover);
node.removeEventListener("mouseleave", this._handleMouseout);
// 移除已插入元素
node.querySelector(APP_LCNAME)?.remove();
});
}
// 清空节点集合
this._rootNodes.clear();

View File

@@ -223,3 +223,13 @@ export const matchInputStr = (str, sign) => {
}
return str.match(reg);
};
/**
* 判断是否英文单词
* @param {*} str
* @returns
*/
export const isValidWord = (str) => {
const regex = /^[a-zA-Z-]+$/;
return regex.test(str);
};

View File

@@ -1,12 +1,22 @@
import { isMatch } from "./utils";
import { getWebfix, setWebfix } from "./storage";
import { getWebfix, setWebfix, getWebfixRulesWithDefault } from "./storage";
import { apiFetch } from "../apis";
/**
* 修复程序类型
*/
const FIXER_BR = "br";
const FIXER_BN = "bn";
const FIXER_BR_DIV = "brToDiv";
const FIXER_BN_DIV = "bnToDiv";
const FIXER_FONTSIZE = "fontSize";
export const FIXER_ALL = [
FIXER_BR,
FIXER_BN,
FIXER_BR_DIV,
FIXER_BN_DIV,
// FIXER_FONTSIZE,
];
/**
* 需要修复的站点列表
@@ -34,6 +44,12 @@ const DEFAULT_SITES = [
rootSelector: "",
fixer: FIXER_FONTSIZE,
},
{
pattern: "chat.openai.com",
selector: "div[data-testid^=conversation-turn] .items-start > div",
rootSelector: "",
fixer: FIXER_BN,
},
];
/**
@@ -47,7 +63,7 @@ const fixedSign = "kissfixed";
* @param {*} node
* @returns
*/
function brFixer(node) {
function brFixer(node, tag = "p") {
if (node.hasAttribute(fixedSign)) {
return;
}
@@ -74,13 +90,13 @@ function brFixer(node) {
var html = "";
node.childNodes.forEach(function (child, index) {
if (index === 0) {
html += "<p>";
html += `<${tag} class="kiss-p">`;
}
if (gapTags.indexOf(child.nodeName) !== -1) {
html += "</p><p>";
html += `</${tag}><${tag} class="kiss-p">`;
} else if (newlineTags.indexOf(child.nodeName) !== -1) {
html += "</p>" + child.outerHTML + "<p>";
html += `</${tag}>${child.outerHTML}<${tag} class="kiss-p">`;
} else if (child.outerHTML) {
html += child.outerHTML;
} else if (child.nodeValue) {
@@ -88,12 +104,36 @@ function brFixer(node) {
}
if (index === node.childNodes.length - 1) {
html += "</p>";
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");
}
/**
* 修复字体大小问题,如 baidu.com
* @param {*} node
@@ -107,6 +147,9 @@ function fontSizeFixer(node) {
*/
const fixerMap = {
[FIXER_BR]: brFixer,
[FIXER_BN]: bnFixer,
[FIXER_BR_DIV]: brDivFixer,
[FIXER_BN_DIV]: bnDivFixer,
[FIXER_FONTSIZE]: fontSizeFixer,
};
@@ -120,7 +163,11 @@ function run(selector, fixer, rootSelector) {
var mutaObserver = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (addNode) {
addNode.querySelectorAll(selector).forEach(fixer);
if (addNode && addNode.querySelectorAll) {
addNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
}
});
});
});
@@ -131,9 +178,12 @@ function run(selector, fixer, rootSelector) {
}
rootNodes.forEach(function (rootNode) {
rootNode.querySelectorAll(selector).forEach(fixer);
rootNode.querySelectorAll(selector).forEach(function (node) {
fixer(node);
});
mutaObserver.observe(rootNode, {
childList: true,
subtree: true,
});
});
}
@@ -170,13 +220,16 @@ export const loadOrFetchWebfix = async (url) => {
/**
* 匹配站点
*/
export async function webfix(href, { injectWebfix }) {
export async function runWebfix({ injectWebfix }) {
try {
if (!injectWebfix) {
return;
}
const sites = await loadOrFetchWebfix(process.env.REACT_APP_WEBFIXURL);
const href = document.location.href;
const userSites = await getWebfixRulesWithDefault();
const subSites = await loadOrFetchWebfix(process.env.REACT_APP_WEBFIXURL);
const sites = [...userSites, ...subSites];
for (var i = 0; i < sites.length; i++) {
var site = sites[i];
if (isMatch(href, site.pattern)) {

View File

@@ -1,110 +1,3 @@
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 {
getSettingWithDefault,
getRulesWithDefault,
getFabWithDefault,
} from "./libs/storage";
import { Translator } from "./libs/translator";
import { trySyncAllSubRules } from "./libs/subRules";
import { MSG_TRANS_TOGGLE, MSG_TRANS_PUTRULE } from "./config";
import { isIframe } from "./libs/iframe";
import { handlePing, injectScript } from "./libs/gm";
import { matchRule } from "./libs/rules";
import { genEventName } from "./libs/utils";
import { webfix } from "./libs/webfix";
import { run } from "./common";
/**
* 入口函数
*/
const init = async () => {
// 设置页面
if (
document.location.href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) ||
document.location.href.includes(process.env.REACT_APP_OPTIONSPAGE) ||
document.location.href.includes(process.env.REACT_APP_OPTIONSPAGE2)
) {
if (GM?.info?.script?.grant?.includes("unsafeWindow")) {
unsafeWindow.GM = GM;
unsafeWindow.APP_INFO = {
name: process.env.REACT_APP_NAME,
version: process.env.REACT_APP_VERSION,
};
} else {
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);
}
return;
}
// 翻译页面
const href = isIframe ? document.referrer : document.location.href;
const setting = await getSettingWithDefault();
const rules = await getRulesWithDefault();
const rule = await matchRule(rules, href, setting);
const translator = new Translator(rule, setting);
webfix(href, setting);
if (isIframe) {
// iframe
window.addEventListener("message", (e) => {
const action = e?.data?.action;
switch (action) {
case MSG_TRANS_TOGGLE:
translator.toggle();
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(e.data.args || {});
break;
default:
}
});
return;
}
// 浮球按钮
const fab = await getFabWithDefault();
const $action = document.createElement("div");
$action.setAttribute("id", "kiss-translator");
document.body.parentElement.appendChild($action);
const shadowContainer = $action.attachShadow({ mode: "closed" });
const emotionRoot = document.createElement("style");
const shadowRootElement = document.createElement("div");
shadowContainer.appendChild(emotionRoot);
shadowContainer.appendChild(shadowRootElement);
const cache = createCache({
key: "css",
prepend: true,
container: emotionRoot,
});
ReactDOM.createRoot(shadowRootElement).render(
<React.StrictMode>
<CacheProvider value={cache}>
<Action translator={translator} fab={fab} />
</CacheProvider>
</React.StrictMode>
);
// 同步订阅规则
trySyncAllSubRules(setting);
};
(async () => {
try {
await init();
} catch (err) {
console.error("[KISS-Translator]", err);
const $err = document.createElement("div");
$err.innerText = `KISS-Translator: ${err.message}`;
$err.style.cssText = "background:red; color:#fff;";
document.body.prepend($err);
}
})();
run(true);

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { limitNumber } from "../../libs/utils";
import { isMobile } from "../../libs/mobile";
import { setFab } from "../../libs/storage";
import { updateFab } from "../../libs/storage";
import { debounce } from "../../libs/utils";
import Paper from "@mui/material/Paper";
@@ -61,7 +61,7 @@ export default function Draggable({
const [hover, setHover] = useState(false);
const [origin, setOrigin] = useState(null);
const [position, setPosition] = useState({ x: left, y: top });
const setFabPosition = useMemo(() => debounce(setFab, 500), []);
const setFabPosition = useMemo(() => debounce(updateFab, 500), []);
const handlePointerDown = (e) => {
!isMobile && e.target.setPointerCapture(e.pointerId);

View File

@@ -16,8 +16,11 @@ import {
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";
export default function Action({ translator, fab }) {
const fabWidth = 40;
@@ -52,15 +55,21 @@ export default function Action({ translator, fab }) {
}, []);
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], () => {
@@ -86,38 +95,42 @@ export default function Action({ translator, fab }) {
// 注册菜单
try {
const menuCommandIds = [];
menuCommandIds.push(
GM.registerMenuCommand(
"Toggle Translate (Alt+q)",
(event) => {
translator.toggle();
setShowPopup(false);
},
"Q"
),
GM.registerMenuCommand(
"Toggle Style (Alt+c)",
(event) => {
translator.toggleStyle();
setShowPopup(false);
},
"C"
),
GM.registerMenuCommand(
"Open Menu (Alt+k)",
(event) => {
setShowPopup((pre) => !pre);
},
"K"
),
GM.registerMenuCommand(
"Open Setting (Alt+o)",
(event) => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
},
"O"
)
);
const { contextMenus = true } = translator.setting;
contextMenus &&
menuCommandIds.push(
GM.registerMenuCommand(
"Toggle Translate (Alt+q)",
(event) => {
translator.toggle();
sendIframeMsg(MSG_TRANS_TOGGLE);
setShowPopup(false);
},
"Q"
),
GM.registerMenuCommand(
"Toggle Style (Alt+c)",
(event) => {
translator.toggleStyle();
sendIframeMsg(MSG_TRANS_TOGGLE_STYLE);
setShowPopup(false);
},
"C"
),
GM.registerMenuCommand(
"Open Menu (Alt+k)",
(event) => {
setShowPopup((pre) => !pre);
},
"K"
),
GM.registerMenuCommand(
"Open Setting (Alt+o)",
(event) => {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
},
"O"
)
);
return () => {
menuCommandIds.forEach((id) => {
@@ -191,7 +204,7 @@ export default function Action({ translator, fab }) {
key="fab"
snapEdge
{...fabProps}
show={translator.setting.hideFab ? false : !showPopup}
show={fab.isHide ? false : !showPopup}
onStart={handleStart}
onMove={handleMove}
handler={
@@ -204,7 +217,12 @@ export default function Action({ translator, fab }) {
}
}}
>
<TranslateIcon />
<TranslateIcon
sx={{
width: 24,
height: 24,
}}
/>
</Fab>
}
/>

View File

@@ -7,6 +7,7 @@ import {
OPT_STYLE_WAVYLINE,
OPT_STYLE_FUZZY,
OPT_STYLE_HIGHLIGHT,
OPT_STYLE_BLOCKQUOTE,
OPT_STYLE_DIY,
DEFAULT_COLOR,
MSG_TRANS_CURRULE,
@@ -34,6 +35,18 @@ const LineSpan = styled("span")`
}
`;
const BlockquoteSpan = styled("span")`
opacity: 0.6;
-webkit-opacity: 0.6;
display: block;
padding: 0 0.75em;
border-left: 0.25em solid ${(props) => props.$lineColor};
&:hover {
opacity: 1;
-webkit-opacity: 1;
}
`;
const FuzzySpan = styled("span")`
filter: blur(0.2em);
-webkit-filter: blur(0.2em);
@@ -86,6 +99,12 @@ function StyledSpan({ textStyle, textDiyStyle, bgColor, children }) {
{children}
</HighlightSpan>
);
case OPT_STYLE_BLOCKQUOTE: // 引用
return (
<BlockquoteSpan $lineColor={bgColor || DEFAULT_COLOR}>
{children}
</BlockquoteSpan>
);
case OPT_STYLE_DIY: // 自定义
return <DiySpan $diyStyle={textDiyStyle}>{children}</DiySpan>;
default:
@@ -120,7 +139,7 @@ export default function Content({ q, translator }) {
if (loading) {
return (
<>
{q.length > newlineLength ? <br /> : " "}
{q.length >= newlineLength ? <br /> : " "}
<LoadingIcon />
</>
);
@@ -129,7 +148,7 @@ export default function Content({ q, translator }) {
if (text && !sameLang) {
return (
<>
{q.length > newlineLength ? <br /> : " "}
{q.length >= newlineLength ? <br /> : " "}
<StyledSpan
textStyle={textStyle}
textDiyStyle={textDiyStyle}

View File

@@ -5,7 +5,11 @@ import CircularProgress from "@mui/material/CircularProgress";
import {
OPT_TRANS_ALL,
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
OPT_TRANS_OPENAI,
OPT_TRANS_GEMINI,
OPT_TRANS_CUSTOMIZE,
URL_KISS_PROXY,
} from "../../config";
@@ -35,14 +39,28 @@ function TestButton({ translator, api }) {
text: "hello world",
fromLang: "en",
toLang: "zh-CN",
apiSetting: { ...api, useCache: false },
apiSetting: api,
useCache: false,
});
if (!text) {
throw new Error("empty reault");
}
alert.success(i18n("test_success"));
} catch (err) {
alert.error(`${i18n("test_failed")}: ${err.message}`);
// alert.error(`${i18n("test_failed")}: ${err.message}`);
alert.error(
<>
<div>{`${i18n("test_failed")}: ${err.message}`}</div>
<pre
style={{
maxWidth: 400,
overflow: "auto",
}}
>
{JSON.stringify(err.cause || {}, null, 2)}
</pre>
</>
);
} finally {
setLoading(false);
}
@@ -71,9 +89,16 @@ function ApiFields({ translator }) {
});
};
const buildinTranslators = [
OPT_TRANS_MICROSOFT,
OPT_TRANS_DEEPLFREE,
OPT_TRANS_BAIDU,
OPT_TRANS_TENCENT,
];
return (
<Stack spacing={3}>
{translator !== OPT_TRANS_MICROSOFT && (
{!buildinTranslators.includes(translator) && (
<>
<TextField
size="small"
@@ -91,7 +116,7 @@ function ApiFields({ translator }) {
/>
</>
)}
{translator === OPT_TRANS_OPENAI && (
{(translator === OPT_TRANS_OPENAI || translator === OPT_TRANS_GEMINI) && (
<>
<TextField
size="small"
@@ -113,7 +138,7 @@ function ApiFields({ translator }) {
<Stack direction="row" spacing={2}>
<TestButton translator={translator} api={api} />
{translator !== OPT_TRANS_MICROSOFT && (
{!buildinTranslators.includes(translator) && (
<Button
size="small"
variant="outlined"

View File

@@ -0,0 +1,27 @@
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import Button from "@mui/material/Button";
export default function DownloadButton({ data, text, fileName }) {
const handleClick = (e) => {
e.preventDefault();
if (data) {
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", fileName || `${Date.now()}.json`);
document.body.appendChild(link);
link.click();
link.remove();
}
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileDownloadIcon />}
>
{text}
</Button>
);
}

View File

@@ -0,0 +1,150 @@
import Stack from "@mui/material/Stack";
import { OPT_TRANS_BAIDU } from "../../config";
import { useEffect, useState } from "react";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
import AccordionDetails from "@mui/material/AccordionDetails";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import CircularProgress from "@mui/material/CircularProgress";
import { useI18n } from "../../hooks/I18n";
import Alert from "@mui/material/Alert";
import { apiTranslate } from "../../apis";
import Box from "@mui/material/Box";
import { useFavWords } from "../../hooks/FavWords";
import DictCont from "../Selection/DictCont";
import DownloadButton from "./DownloadButton";
import UploadButton from "./UploadButton";
import Button from "@mui/material/Button";
import ClearAllIcon from "@mui/icons-material/ClearAll";
import { isValidWord } from "../../libs/utils";
function DictField({ word }) {
const [dictResult, setDictResult] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
(async () => {
try {
setLoading(true);
setError("");
const dictRes = await apiTranslate({
text: word,
translator: OPT_TRANS_BAIDU,
fromLang: "en",
toLang: "zh-CN",
});
setDictResult(dictRes[2].dict_result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, [word]);
if (loading) {
return <CircularProgress size={24} />;
}
if (error) {
return <Alert severity="error">{error}</Alert>;
}
return <DictCont dictResult={dictResult} />;
}
function FavAccordion({ word, index }) {
const [expanded, setExpanded] = useState(false);
const handleChange = (e) => {
setExpanded((pre) => !pre);
};
return (
<Accordion expanded={expanded} onChange={handleChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
{/* <Typography>{`[${new Date(
createdAt
).toLocaleString()}] ${word}`}</Typography> */}
<Typography>{`${index + 1}. ${word}`}</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && <DictField word={word} />}
</AccordionDetails>
</Accordion>
);
}
export default function FavWords() {
const i18n = useI18n();
const { loading, favWords, mergeWords, clearWords } = useFavWords();
const favList = Object.entries(favWords).sort((a, b) =>
a[0].localeCompare(b[0])
);
const downloadList = favList.map(([word]) => word);
const handleImport = async (data) => {
try {
const newWords = data
.split("\n")
.map((line) => line.split(",")[0].trim())
.filter(isValidWord);
await mergeWords(newWords);
} catch (err) {
console.log("[import rules]", err);
}
};
return (
<Box>
<Stack spacing={3}>
<Stack
direction="row"
alignItems="center"
spacing={2}
useFlexGap
flexWrap="wrap"
>
<UploadButton
text={i18n("import")}
handleImport={handleImport}
fileType="text"
fileExts={[".txt", ".csv"]}
/>
<DownloadButton
data={downloadList.join("\n")}
text={i18n("export")}
fileName={`kiss-words_${Date.now()}.txt`}
/>
<Button
size="small"
variant="outlined"
onClick={() => {
clearWords();
}}
startIcon={<ClearAllIcon />}
>
{i18n("clear_all")}
</Button>
</Stack>
<Box>
{loading ? (
<CircularProgress size={24} />
) : (
favList.map(([word, { createdAt }], index) => (
<FavAccordion
key={word}
index={index}
word={word}
createdAt={createdAt}
/>
))
)}
</Box>
</Stack>
</Box>
);
}

View File

@@ -1,4 +1,3 @@
import PropTypes from "prop-types";
import AppBar from "@mui/material/AppBar";
import IconButton from "@mui/material/IconButton";
import MenuIcon from "@mui/icons-material/Menu";
@@ -7,6 +6,7 @@ import Box from "@mui/material/Box";
import Link from "@mui/material/Link";
import { useI18n } from "../../hooks/I18n";
import DarkModeButton from "./DarkModeButton";
import Typography from "@mui/material/Typography";
function Header(props) {
const i18n = useI18n();
@@ -31,22 +31,18 @@ function Header(props) {
<MenuIcon />
</IconButton>
</Box>
<Box sx={{ flexGrow: 1 }}>
<Typography component="div" sx={{ flexGrow: 1, fontWeight: "bold" }}>
<Link
underline="none"
color="inherit"
href={process.env.REACT_APP_HOMEPAGE}
target="_blank"
>{`${i18n("app_name")} v${process.env.REACT_APP_VERSION}`}</Link>
</Box>
</Typography>
<DarkModeButton />
</Toolbar>
</AppBar>
);
}
Header.propTypes = {
onDrawerToggle: PropTypes.func.isRequired,
};
export default Header;

View File

@@ -67,7 +67,7 @@ export default function InputSetting() {
}}
/>
}
label={i18n("input_box_translation")}
label={i18n("use_input_box_translation")}
/>
<TextField

View File

@@ -13,6 +13,8 @@ import SyncIcon from "@mui/icons-material/Sync";
import ApiIcon from "@mui/icons-material/Api";
import SendTimeExtensionIcon from "@mui/icons-material/SendTimeExtension";
import InputIcon from "@mui/icons-material/Input";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import EventNoteIcon from "@mui/icons-material/EventNote";
function LinkItem({ label, url, icon }) {
const match = useMatch(url);
@@ -40,11 +42,17 @@ export default function Navigator(props) {
icon: <DesignServicesIcon />,
},
{
id: "input_setting",
label: i18n("input_setting"),
id: "input_translate",
label: i18n("input_translate"),
url: "/input",
icon: <InputIcon />,
},
{
id: "selection_translate",
label: i18n("selection_translate"),
url: "/tranbox",
icon: <SelectAllIcon />,
},
{
id: "apis_setting",
label: i18n("apis_setting"),
@@ -63,6 +71,12 @@ export default function Navigator(props) {
url: "/webfix",
icon: <SendTimeExtensionIcon />,
},
{
id: "words",
label: i18n("favorite_words"),
url: "/words",
icon: <EventNoteIcon />,
},
{ id: "about", label: i18n("about"), url: "/about", icon: <InfoIcon /> },
];
return (

View File

@@ -14,8 +14,9 @@ import {
OPT_STYLE_DIY,
OPT_STYLE_USE_COLOR,
URL_KISS_RULES_NEW_ISSUE,
OPT_SYNCTYPE_WORKER,
} from "../../config";
import { useState, useRef, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo } from "react";
import { useI18n } from "../../hooks/I18n";
import Typography from "@mui/material/Typography";
import Accordion from "@mui/material/Accordion";
@@ -25,8 +26,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { useRules } from "../../hooks/Rules";
import MenuItem from "@mui/material/MenuItem";
import Grid from "@mui/material/Grid";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useSetting } from "../../hooks/Setting";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
@@ -49,6 +48,8 @@ import OwSubRule from "./OwSubRule";
import ClearAllIcon from "@mui/icons-material/ClearAll";
import HelpButton from "./HelpButton";
import { useSyncCaches } from "../../hooks/Sync";
import DownloadButton from "./DownloadButton";
import UploadButton from "./UploadButton";
function RuleFields({ rule, rules, setShow, setKeyword }) {
const initFormValues = rule || {
@@ -374,8 +375,9 @@ function RuleAccordion({ rule, rules }) {
<Accordion expanded={expanded} onChange={handleChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography
style={{
sx={{
opacity: rules ? 1 : 0.5,
overflowWrap: "anywhere",
}}
>
{rule.pattern === GLOBAL_KEY
@@ -390,63 +392,13 @@ function RuleAccordion({ rule, rules }) {
);
}
function DownloadButton({ data, text, fileName }) {
const handleClick = (e) => {
e.preventDefault();
if (data) {
const url = window.URL.createObjectURL(new Blob([data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", fileName || `${Date.now()}.json`);
document.body.appendChild(link);
link.click();
link.remove();
}
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileDownloadIcon />}
>
{text}
</Button>
);
}
function UploadButton({ onChange, text }) {
const inputRef = useRef(null);
const handleClick = () => {
inputRef.current && inputRef.current.click();
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileUploadIcon />}
>
{text}
<input
type="file"
accept=".json"
ref={inputRef}
onChange={onChange}
hidden
/>
</Button>
);
}
function ShareButton({ rules, injectRules, selectedUrl }) {
const alert = useAlert();
const i18n = useI18n();
const handleClick = async () => {
try {
const { syncUrl, syncKey } = await getSyncWithDefault();
if (!syncUrl || !syncKey) {
const { syncType, syncUrl, syncKey } = await getSyncWithDefault();
if (syncType !== OPT_SYNCTYPE_WORKER || !syncUrl || !syncKey) {
alert.warning(i18n("error_sync_setting"));
return;
}
@@ -492,26 +444,12 @@ function UserRules({ subRules }) {
const injectRules = !!setting?.injectRules;
const { selectedUrl, selectedRules } = subRules;
const handleImport = (e) => {
const file = e.target.files[0];
if (!file) {
return;
const handleImport = async (data) => {
try {
await rules.merge(JSON.parse(data));
} catch (err) {
console.log("[import rules]", err);
}
if (!file.type.includes("json")) {
alert(i18n("error_wrong_file_type"));
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
try {
await rules.merge(JSON.parse(e.target.result));
} catch (err) {
console.log("[import rules]", err);
}
};
reader.readAsText(file);
};
const handleInject = () => {
@@ -526,6 +464,10 @@ function UserRules({ subRules }) {
}
}, [showAdd]);
if (!rules.list) {
return;
}
return (
<Stack spacing={3}>
<Stack
@@ -547,10 +489,11 @@ function UserRules({ subRules }) {
{i18n("add")}
</Button>
<UploadButton text={i18n("import")} onChange={handleImport} />
<UploadButton text={i18n("import")} handleImport={handleImport} />
<DownloadButton
data={JSON.stringify([...rules.list].reverse(), null, "\t")}
data={JSON.stringify([...rules.list].reverse(), null, 2)}
text={i18n("export")}
fileName={`kiss-rules_${Date.now()}.json`}
/>
<ShareButton
@@ -658,7 +601,14 @@ function SubRulesItem({
return (
<Stack direction="row" alignItems="center" spacing={2}>
<FormControlLabel value={url} control={<Radio />} label={url} />
<FormControlLabel
value={url}
control={<Radio />}
sx={{
overflowWrap: "anywhere",
}}
label={url}
/>
{syncAt && (
<span style={{ marginLeft: "0.5em", opacity: 0.5 }}>

View File

@@ -23,9 +23,14 @@ import {
OPT_SHORTCUT_STYLE,
OPT_SHORTCUT_POPUP,
OPT_SHORTCUT_SETTING,
OPT_LANGS_TO,
DEFAULT_BLACKLIST,
MSG_CONTEXT_MENUS,
} from "../../config";
import { useShortcut } from "../../hooks/Shortcut";
import ShortcutInput from "./ShortcutInput";
import { useFab } from "../../hooks/Fab";
import { sendBgMsg } from "../../libs/msg";
function ShortcutItem({ action, label }) {
const { shortcut, setShortcut } = useShortcut(action);
@@ -38,6 +43,7 @@ export default function Settings() {
const i18n = useI18n();
const { setting, updateSetting } = useSetting();
const alert = useAlert();
const { fab, updateFab } = useFab();
const handleChange = (e) => {
e.preventDefault();
@@ -58,6 +64,12 @@ export default function Settings() {
case "newlineLength":
value = limitNumber(value, 1, 1000);
break;
case "touchTranslate":
value = limitNumber(value, 0, 4);
break;
case "contextMenus":
isExt && sendBgMsg(MSG_CONTEXT_MENUS, { contextMenus: value });
break;
default:
}
updateSetting({
@@ -83,8 +95,13 @@ export default function Settings() {
clearCache,
newlineLength = TRANS_NEWLINE_LENGTH,
mouseKey = OPT_MOUSEKEY_DISABLE,
hideFab = false,
detectRemote = false,
contextMenus = true,
touchTranslate = 2,
blacklist = DEFAULT_BLACKLIST.join(",\n"),
disableLangs = [],
} = setting;
const { isHide = false } = fab || {};
return (
<Box>
@@ -166,39 +183,104 @@ export default function Settings() {
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{i18n("touch_translate_shortcut")}</InputLabel>
<Select
name="touchTranslate"
value={touchTranslate}
label={i18n("touch_translate_shortcut")}
onChange={handleChange}
>
{[0, 2, 3, 4].map((item) => (
<MenuItem key={item} value={item}>
{i18n(`touch_tap_${item}`)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{i18n("hide_fab_button")}</InputLabel>
<Select
name="isHide"
value={isHide}
label={i18n("hide_fab_button")}
onChange={(e) => {
updateFab({ isHide: e.target.value });
}}
>
<MenuItem value={false}>{i18n("show")}</MenuItem>
<MenuItem value={true}>{i18n("hide")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{i18n("add_context_menus")}</InputLabel>
<Select
name="contextMenus"
value={contextMenus}
label={i18n("add_context_menus")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{i18n("detect_lang_remote")}</InputLabel>
<Select
name="detectRemote"
value={detectRemote}
label={i18n("detect_lang_remote")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("disable")}</MenuItem>
<MenuItem value={true}>{i18n("enable")}</MenuItem>
</Select>
<FormHelperText>{i18n("detect_lang_remote_help")}</FormHelperText>
</FormControl>
<FormControl size="small">
<InputLabel>{i18n("disable_langs")}</InputLabel>
<Select
multiple
name="disableLangs"
value={disableLangs}
label={i18n("disable_langs")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([langKey, langName]) => (
<MenuItem key={langKey} value={langKey}>
{langName}
</MenuItem>
))}
</Select>
<FormHelperText>{i18n("disable_langs_helper")}</FormHelperText>
</FormControl>
{isExt ? (
<FormControl size="small">
<InputLabel>{i18n("if_clear_cache")}</InputLabel>
<Select
name="clearCache"
value={clearCache}
label={i18n("if_clear_cache")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("clear_cache_never")}</MenuItem>
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
</Select>
<FormHelperText>
<Link component="button" onClick={handleClearCache}>
{i18n("clear_all_cache_now")}
</Link>
</FormHelperText>
</FormControl>
) : (
<>
<FormControl size="small">
<InputLabel>{i18n("hide_fab_button")}</InputLabel>
<InputLabel>{i18n("if_clear_cache")}</InputLabel>
<Select
name="hideFab"
value={hideFab}
label={i18n("hide_fab_button")}
name="clearCache"
value={clearCache}
label={i18n("if_clear_cache")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("show")}</MenuItem>
<MenuItem value={true}>{i18n("hide")}</MenuItem>
<MenuItem value={false}>{i18n("clear_cache_never")}</MenuItem>
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
</Select>
<FormHelperText>
<Link component="button" onClick={handleClearCache}>
{i18n("clear_all_cache_now")}
</Link>
</FormHelperText>
</FormControl>
</>
) : (
<>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={12} sm={12} md={3} lg={3}>
@@ -229,6 +311,16 @@ export default function Settings() {
</Box>
</>
)}
<TextField
size="small"
label={i18n("translate_blacklist")}
helperText={i18n("pattern_helper")}
name="blacklist"
defaultValue={blacklist}
onChange={handleChange}
multiline
/>
</Stack>
</Box>
);

View File

@@ -5,7 +5,13 @@ import { useI18n } from "../../hooks/I18n";
import { useSync } from "../../hooks/Sync";
import Alert from "@mui/material/Alert";
import Link from "@mui/material/Link";
import { URL_KISS_WORKER } from "../../config";
import MenuItem from "@mui/material/MenuItem";
import {
URL_KISS_WORKER,
OPT_SYNCTYPE_ALL,
OPT_SYNCTYPE_WORKER,
OPT_SYNCTYPE_WEBDAV,
} from "../../config";
import { useState } from "react";
import { syncSettingAndRules } from "../../libs/sync";
import Button from "@mui/material/Button";
@@ -44,13 +50,37 @@ export default function SyncSetting() {
}
};
const { syncUrl = "", syncKey = "" } = sync;
if (!sync) {
return;
}
const {
syncType = OPT_SYNCTYPE_WORKER,
syncUrl = "",
syncUser = "",
syncKey = "",
} = sync;
return (
<Box>
<Stack spacing={3}>
<Alert severity="warning">{i18n("sync_warn")}</Alert>
<TextField
select
size="small"
name="syncType"
value={syncType}
label={i18n("data_sync_type")}
onChange={handleChange}
>
{OPT_SYNCTYPE_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={i18n("data_sync_url")}
@@ -58,12 +88,24 @@ export default function SyncSetting() {
value={syncUrl}
onChange={handleChange}
helperText={
<Link href={URL_KISS_WORKER} target="_blank">
{i18n("about_sync_api")}
</Link>
syncType === OPT_SYNCTYPE_WORKER && (
<Link href={URL_KISS_WORKER} target="_blank">
{i18n("about_sync_api")}
</Link>
)
}
/>
{syncType === OPT_SYNCTYPE_WEBDAV && (
<TextField
size="small"
label={i18n("data_sync_user")}
name="syncUser"
value={syncUser}
onChange={handleChange}
/>
)}
<TextField
size="small"
type="password"

View File

@@ -0,0 +1,170 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import { useI18n } from "../../hooks/I18n";
import { OPT_TRANS_ALL, OPT_LANGS_FROM, OPT_LANGS_TO } from "../../config";
import ShortcutInput from "./ShortcutInput";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { useCallback } from "react";
import { limitNumber } from "../../libs/utils";
import { useTranbox } from "../../hooks/Tranbox";
export default function Tranbox() {
const i18n = useI18n();
const { tranboxSetting, updateTranbox } = useTranbox();
const handleChange = (e) => {
e.preventDefault();
let { name, value } = e.target;
switch (name) {
case "btnOffsetX":
value = limitNumber(value, 0, 100);
break;
case "btnOffsetY":
value = limitNumber(value, 0, 100);
break;
default:
}
updateTranbox({
[name]: value,
});
};
const handleShortcutInput = useCallback(
(val) => {
updateTranbox({ tranboxShortcut: val });
},
[updateTranbox]
);
const {
transOpen,
translator,
fromLang,
toLang,
toLang2 = "en",
tranboxShortcut,
btnOffsetX,
btnOffsetY,
hideTranBtn = false,
} = tranboxSetting;
return (
<Box>
<Stack spacing={3}>
<FormControlLabel
control={
<Switch
size="small"
name="transOpen"
checked={transOpen}
onChange={() => {
updateTranbox({ transOpen: !transOpen });
}}
/>
}
label={i18n("toggle_selection_translate")}
/>
<TextField
select
size="small"
name="translator"
value={translator}
label={i18n("translate_service")}
onChange={handleChange}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="fromLang"
value={fromLang}
label={i18n("from_lang")}
onChange={handleChange}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="toLang"
value={toLang}
label={i18n("to_lang")}
onChange={handleChange}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
size="small"
name="toLang2"
value={toLang2}
label={i18n("to_lang2")}
helperText={i18n("to_lang2_helper")}
onChange={handleChange}
>
{[["none", "None"], ...OPT_LANGS_TO].map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
<TextField
size="small"
label={i18n("tranbtn_offset_x")}
type="number"
name="btnOffsetX"
defaultValue={btnOffsetX}
onChange={handleChange}
/>
<TextField
size="small"
label={i18n("tranbtn_offset_y")}
type="number"
name="btnOffsetY"
defaultValue={btnOffsetY}
onChange={handleChange}
/>
<TextField
select
size="small"
name="hideTranBtn"
value={hideTranBtn}
label={i18n("hide_tran_button")}
onChange={handleChange}
>
<MenuItem value={false}>{i18n("show")}</MenuItem>
<MenuItem value={true}>{i18n("hide")}</MenuItem>
</TextField>
<ShortcutInput
value={tranboxShortcut}
onChange={handleShortcutInput}
label={i18n("trigger_tranbox_shortcut")}
/>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,55 @@
import { useRef } from "react";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useI18n } from "../../hooks/I18n";
import Button from "@mui/material/Button";
export default function UploadButton({
handleImport,
text,
fileType = "json",
fileExts = [".json"],
}) {
const i18n = useI18n();
const inputRef = useRef(null);
const handleClick = () => {
if (inputRef.current) {
inputRef.current.click();
inputRef.current.value = null;
}
};
const onChange = (e) => {
const file = e.target.files[0];
if (!file) {
return;
}
if (!file.type.includes(fileType)) {
alert(i18n("error_wrong_file_type"));
return;
}
const reader = new FileReader();
reader.onload = async (e) => {
handleImport(e.target.result);
};
reader.readAsText(file);
};
return (
<Button
size="small"
variant="outlined"
onClick={handleClick}
startIcon={<FileUploadIcon />}
>
{text}
<input
type="file"
accept={fileExts.join(", ")}
ref={inputRef}
onChange={onChange}
hidden
/>
</Button>
);
}

View File

@@ -13,43 +13,204 @@ import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
import { useSetting } from "../../hooks/Setting";
import CircularProgress from "@mui/material/CircularProgress";
import { syncWebfix, loadOrFetchWebfix } from "../../libs/webfix";
import { syncWebfix, loadOrFetchWebfix, FIXER_ALL } from "../../libs/webfix";
import Button from "@mui/material/Button";
import SyncIcon from "@mui/icons-material/Sync";
import { useAlert } from "../../hooks/Alert";
import HelpButton from "./HelpButton";
import { URL_KISS_RULES_NEW_ISSUE } from "../../config";
import MenuItem from "@mui/material/MenuItem";
import { useWebfixRules } from "../../hooks/WebfixRules";
function WebfixFields({ rule, webfix, setShow }) {
const editMode = !!rule;
const initFormValues = rule || {
pattern: "",
selector: "",
rootSelector: "",
fixer: FIXER_ALL[0],
};
const i18n = useI18n();
const [disabled, setDisabled] = useState(editMode);
const [errors, setErrors] = useState({});
const [formValues, setFormValues] = useState(initFormValues);
const { pattern, selector, rootSelector, fixer } = formValues;
const hasSamePattern = (str) => {
for (const item of webfix.list || []) {
if (item.pattern === str && rule?.pattern !== str) {
return true;
}
}
return false;
};
const handleFocus = (e) => {
e.preventDefault();
const { name } = e.target;
setErrors((pre) => ({ ...pre, [name]: "" }));
};
const handleChange = (e) => {
e.preventDefault();
const { name, value } = e.target;
setFormValues((pre) => ({ ...pre, [name]: value }));
};
const handleCancel = (e) => {
e.preventDefault();
if (editMode) {
setDisabled(true);
} else {
setShow(false);
}
setFormValues(initFormValues);
};
const handleSubmit = (e) => {
e.preventDefault();
const errors = {};
if (!pattern.trim()) {
errors.pattern = i18n("error_cant_be_blank");
}
if (hasSamePattern(pattern)) {
errors.pattern = i18n("error_duplicate_values");
}
if (!selector.trim()) {
errors.selector = i18n("error_cant_be_blank");
}
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
if (editMode) {
// 编辑
setDisabled(true);
webfix.put(rule.pattern, formValues);
} else {
// 添加
webfix.add(formValues);
setShow(false);
setFormValues(initFormValues);
}
};
function ApiFields({ site }) {
const { selector, rootSelector, fixer } = site;
return (
<Stack spacing={3}>
<TextField
size="small"
label={"rootSelector"}
name="rootSelector"
value={rootSelector || "document"}
disabled
/>
<TextField
size="small"
label={"selector"}
name="selector"
value={selector}
disabled
/>
<TextField
size="small"
label={"fixer"}
name="fixer"
value={fixer}
disabled
/>
</Stack>
<form onSubmit={handleSubmit}>
<Stack spacing={3}>
<TextField
size="small"
label={i18n("pattern")}
error={!!errors.pattern}
helperText={errors.pattern}
name="pattern"
value={pattern}
disabled={disabled}
onChange={handleChange}
onFocus={handleFocus}
multiline
/>
<TextField
size="small"
label={i18n("root_selector")}
error={!!errors.rootSelector}
helperText={errors.rootSelector}
name="rootSelector"
value={rootSelector}
disabled={disabled}
onChange={handleChange}
onFocus={handleFocus}
multiline
/>
<TextField
size="small"
label={i18n("selector")}
error={!!errors.selector}
helperText={errors.selector}
name="selector"
value={selector}
disabled={disabled}
onChange={handleChange}
onFocus={handleFocus}
multiline
/>
<TextField
select
size="small"
name="fixer"
value={fixer}
label={i18n("fixer_function")}
disabled={disabled}
onChange={handleChange}
>
{FIXER_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
{webfix &&
(editMode ? (
// 编辑
<Stack direction="row" spacing={2}>
{disabled ? (
<>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.preventDefault();
setDisabled(false);
}}
>
{i18n("edit")}
</Button>
<Button
size="small"
variant="outlined"
onClick={(e) => {
e.preventDefault();
webfix.del(rule.pattern);
}}
>
{i18n("delete")}
</Button>
</>
) : (
<>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button
size="small"
variant="outlined"
onClick={handleCancel}
>
{i18n("cancel")}
</Button>
</>
)}
</Stack>
) : (
// 添加
<Stack direction="row" spacing={2}>
<Button size="small" variant="contained" type="submit">
{i18n("save")}
</Button>
<Button size="small" variant="outlined" onClick={handleCancel}>
{i18n("cancel")}
</Button>
</Stack>
))}
</Stack>
</form>
);
}
function ApiAccordion({ site }) {
function WebfixAccordion({ rule, webfix }) {
const [expanded, setExpanded] = useState(false);
const handleChange = (e) => {
@@ -59,10 +220,17 @@ function ApiAccordion({ site }) {
return (
<Accordion expanded={expanded} onChange={handleChange}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>{site.pattern}</Typography>
<Typography
sx={{
opacity: webfix ? 1 : 0.5,
overflowWrap: "anywhere",
}}
>
{rule.pattern}
</Typography>
</AccordionSummary>
<AccordionDetails>
{expanded && <ApiFields site={site} />}
{expanded && <WebfixFields rule={rule} webfix={webfix} />}
</AccordionDetails>
</Accordion>
);
@@ -74,6 +242,8 @@ export default function Webfix() {
const i18n = useI18n();
const alert = useAlert();
const { setting, updateSetting } = useSetting();
const [showAdd, setShowAdd] = useState(false);
const webfix = useWebfixRules();
const loadSites = useCallback(async () => {
const sites = await loadOrFetchWebfix(process.env.REACT_APP_WEBFIXURL);
@@ -120,6 +290,18 @@ export default function Webfix() {
useFlexGap
flexWrap="wrap"
>
<Button
size="small"
variant="contained"
disabled={showAdd}
onClick={(e) => {
e.preventDefault();
setShowAdd(true);
}}
>
{i18n("add")}
</Button>
<Button
size="small"
variant="outlined"
@@ -146,6 +328,16 @@ export default function Webfix() {
/>
</Stack>
{showAdd && <WebfixFields webfix={webfix} setShow={setShowAdd} />}
{webfix.list?.length > 0 && (
<Box>
{webfix.list.map((rule) => (
<WebfixAccordion key={rule.pattern} rule={rule} webfix={webfix} />
))}
</Box>
)}
{setting.injectWebfix && (
<Box>
{loading ? (
@@ -153,8 +345,8 @@ export default function Webfix() {
<CircularProgress size={16} />
</center>
) : (
sites.map((site) => (
<ApiAccordion key={site.pattern} site={site} />
sites.map((rule) => (
<WebfixAccordion key={rule.pattern} rule={rule} />
))
)}
</Box>

View File

@@ -20,6 +20,8 @@ import Alert from "@mui/material/Alert";
import Apis from "./Apis";
import Webfix from "./Webfix";
import InputSetting from "./InputSetting";
import Tranbox from "./Tranbox";
import FavWords from "./FavWords";
export default function Options() {
const [error, setError] = useState("");
@@ -37,7 +39,7 @@ export default function Options() {
// 检查版本是否一致
if (version !== process.env.REACT_APP_VERSION) {
setError(
`The version is inconsistent, please check whether the script(v${version}) is the latest version(v${process.env.REACT_APP_VERSION}). (版本不一致,请检查脚本(v${version})是否为最新版(v${process.env.REACT_APP_VERSION}))`
`The version of the local script(v${version}) is not the latest version(v${process.env.REACT_APP_VERSION}). 本地脚本之版本(v${version})最新版(v${process.env.REACT_APP_VERSION})`
);
break;
}
@@ -54,7 +56,9 @@ export default function Options() {
}
if (++i > 8) {
setError("Time out. (连接超时)");
setError(
"Time out. Please confirm whether to install or enable KISS Translator GreaseMonkey script? 连接超时,请检查是否安装或启用简约翻译油猴脚本。"
);
break;
}
@@ -71,39 +75,25 @@ export default function Options() {
if (error) {
return (
<center>
<Alert severity="error">{error}</Alert>
<Divider>
<Link
href={process.env.REACT_APP_HOMEPAGE}
>{`KISS Translator v${process.env.REACT_APP_VERSION}`}</Link>
</Divider>
<h2>
Please confirm whether to install or enable KISS Translator
GreaseMonkey script? (请检查是否安装或启用简约翻译油猴脚本)
</h2>
<Alert severity="error">{error}</Alert>
<Stack spacing={2}>
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL}>
Install Userscript for Tampermonkey/Violentmonkey 1 (油猴脚本
安装地址 1)
</Link>
<Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL2}>
Install Userscript for Tampermonkey/Violentmonkey 2 (油猴脚本
安装地址 2)
Install/Update Userscript for Tampermonkey/Violentmonkey
</Link>
{/* <Link href={process.env.REACT_APP_USERSCRIPT_DOWNLOADURL2}>
Install/Update Userscript for Tampermonkey/Violentmonkey 2
</Link> */}
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL}>
Install Userscript for iOS Safari 1 (油猴脚本 iOS Safari专用
安装地址 1)
</Link>
<Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2}>
Install Userscript for iOS Safari 2 (油猴脚本 iOS Safari专用
安装地址 2)
</Link>
<Link href={process.env.REACT_APP_OPTIONSPAGE}>
Open Options Page 1 (打开设置页面 1)
</Link>
<Link href={process.env.REACT_APP_OPTIONSPAGE2}>
Open Options Page 2 (打开设置页面 2)
Install/Update Userscript for iOS Safari
</Link>
{/* <Link href={process.env.REACT_APP_USERSCRIPT_IOS_DOWNLOADURL2}>
Install/Update Userscript for iOS Safari 2
</Link> */}
</Stack>
</center>
);
@@ -132,9 +122,11 @@ export default function Options() {
<Route index element={<Setting />} />
<Route path="rules" element={<Rules />} />
<Route path="input" element={<InputSetting />} />
<Route path="tranbox" element={<Tranbox />} />
<Route path="apis" element={<Apis />} />
<Route path="sync" element={<SyncSetting />} />
<Route path="webfix" element={<Webfix />} />
<Route path="words" element={<FavWords />} />
<Route path="about" element={<About />} />
</Route>
</Routes>

View File

@@ -1,9 +1,9 @@
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import HomeIcon from "@mui/icons-material/Home";
import Stack from "@mui/material/Stack";
import DarkModeButton from "../Options/DarkModeButton";
import Typography from "@mui/material/Typography";
export default function Header({ setShowPopup }) {
const handleHomepage = () => {
@@ -21,14 +21,16 @@ export default function Header({ setShowPopup }) {
<IconButton onClick={handleHomepage}>
<HomeIcon />
</IconButton>
<Box
<Typography
component="div"
sx={{
userSelect: "none",
WebkitUserSelect: "none",
fontWeight: "bold",
}}
>
{`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`}
</Box>
</Typography>
</Stack>
{setShowPopup ? (

View File

@@ -5,7 +5,7 @@ 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 { sendTabMsg, getTabInfo } from "../../libs/msg";
import { sendBgMsg, sendTabMsg, getTabInfo } from "../../libs/msg";
import { browser } from "../../libs/browser";
import { isExt } from "../../libs/client";
import { useI18n } from "../../hooks/I18n";
@@ -16,23 +16,27 @@ import {
MSG_TRANS_TOGGLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_OPEN_OPTIONS,
MSG_SAVE_RULE,
OPT_TRANS_ALL,
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
OPT_STYLE_USE_COLOR,
CACHE_NAME,
} from "../../config";
import { sendIframeMsg } from "../../libs/iframe";
import { saveRule } from "../../libs/rules";
import { tryClearCaches } from "../../libs";
export default function Popup({ setShowPopup, translator: tran }) {
const i18n = useI18n();
const [rule, setRule] = useState(tran?.rule);
const handleOpenSetting = () => {
if (isExt) {
if (!tran) {
browser?.runtime.openOptionsPage();
} else if (isExt) {
sendBgMsg(MSG_OPEN_OPTIONS);
} else {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}
@@ -43,7 +47,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
try {
setRule({ ...rule, transOpen: e.target.checked ? "true" : "false" });
if (isExt) {
if (!tran) {
await sendTabMsg(MSG_TRANS_TOGGLE);
} else {
tran.toggle();
@@ -59,7 +63,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
if (isExt) {
if (!tran) {
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} else {
tran.updateRule({ [name]: value });
@@ -71,28 +75,29 @@ export default function Popup({ setShowPopup, translator: tran }) {
};
const handleClearCache = () => {
try {
caches.delete(CACHE_NAME);
} catch (err) {
console.log("[clear cache]", err);
}
tryClearCaches();
};
const handleSaveRule = async () => {
try {
let href = window.location.href;
if (isExt) {
if (!tran) {
const tab = await getTabInfo();
href = tab.url;
}
saveRule({ ...rule, pattern: href });
const newRule = { ...rule, pattern: href };
if (isExt && tran) {
sendBgMsg(MSG_SAVE_RULE, newRule);
} else {
saveRule(newRule);
}
} catch (err) {
console.log("[save rule]", err);
}
};
useEffect(() => {
if (!isExt) {
if (tran) {
return;
}
(async () => {
@@ -105,12 +110,12 @@ export default function Popup({ setShowPopup, translator: tran }) {
console.log("[query rule]", err);
}
})();
}, []);
}, [tran]);
if (!rule) {
return (
<Box minWidth={300}>
{isExt && (
{!tran && (
<>
<Header />
<Divider />
@@ -129,7 +134,7 @@ export default function Popup({ setShowPopup, translator: tran }) {
return (
<Box minWidth={300}>
{isExt && (
{!tran && (
<>
<Header />
<Divider />

View File

@@ -0,0 +1,35 @@
import IconButton from "@mui/material/IconButton";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import LibraryAddCheckIcon from "@mui/icons-material/LibraryAddCheck";
import { useState } from "react";
export default function CopyBtn({ text }) {
const [copied, setCopied] = useState(false);
const handleClick = async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(text);
setCopied(true);
const timer = setTimeout(() => {
clearTimeout(timer);
setCopied(false);
}, 500);
};
return (
<IconButton
size="small"
sx={{
opacity: 0.5,
"&:hover": {
opacity: 1,
},
}}
onClick={handleClick}
>
{copied ? (
<LibraryAddCheckIcon fontSize="inherit" />
) : (
<ContentCopyIcon fontSize="inherit" />
)}
</IconButton>
);
}

View File

@@ -0,0 +1,65 @@
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import Stack from "@mui/material/Stack";
import FavBtn from "./FavBtn";
import Typography from "@mui/material/Typography";
const exchangeMap = {
word_third: "第三人称单数",
word_ing: "现在分词",
word_done: "过去式",
word_past: "过去分词",
word_pl: "复数",
word_proto: "词源",
};
export default function DictCont({ dictResult }) {
if (!dictResult) {
return;
}
return (
<Box>
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
>
<Typography variant="subtitle1" style={{ fontWeight: "bold" }}>
{dictResult.simple_means?.word_name}
</Typography>
<FavBtn word={dictResult.simple_means?.word_name} />
</Stack>
{dictResult.simple_means?.symbols?.map(({ ph_en, ph_am, parts }, idx) => (
<Typography key={idx} component="div">
{(ph_en || ph_am) && (
<Typography>{`英 /${ph_en || ""}/ 美 /${ph_am || ""}/`}</Typography>
)}
<ul style={{ margin: "0.5em 0" }}>
{parts.map(({ part, means }, idx) => (
<li key={idx}>
{part ? `[${part}] ${means.join("; ")}` : means.join("; ")}
</li>
))}
</ul>
</Typography>
))}
<Typography>
{Object.entries(dictResult.simple_means?.exchange || {})
.map(([key, val]) => `${exchangeMap[key] || key}: ${val.join(", ")}`)
.join("; ")}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{Object.values(dictResult.simple_means?.tags || {})
.flat()
.filter((item) => item)
.map((item) => (
<Chip label={item} size="small" />
))}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
function Pointer({
direction,
size,
setSize,
position,
setPosition,
children,
minSize,
maxSize,
...props
}) {
const [origin, setOrigin] = useState(null);
function handlePointerDown(e) {
e.target.setPointerCapture(e.pointerId);
setOrigin({
x: position.x,
y: position.y,
w: size.w,
h: size.h,
clientX: e.clientX,
clientY: e.clientY,
});
}
function handlePointerMove(e) {
if (origin) {
const dx = e.clientX - origin.clientX;
const dy = e.clientY - origin.clientY;
let x = position.x;
let y = position.y;
let w = size.w;
let h = size.h;
switch (direction) {
case "Header":
x = origin.x + dx;
y = origin.y + dy;
break;
case "TopLeft":
x = origin.x + dx;
y = origin.y + dy;
w = origin.w - dx;
h = origin.h - dy;
break;
case "Top":
y = origin.y + dy;
h = origin.h - dy;
break;
case "TopRight":
y = origin.y + dy;
w = origin.w + dx;
h = origin.h - dy;
break;
case "Left":
x = origin.x + dx;
w = origin.w - dx;
break;
case "Right":
w = origin.w + dx;
break;
case "BottomLeft":
x = origin.x + dx;
w = origin.w - dx;
h = origin.h + dy;
break;
case "Bottom":
h = origin.h + dy;
break;
case "BottomRight":
w = origin.w + dx;
h = origin.h + dy;
break;
default:
}
if (w < minSize.w) {
w = minSize.w;
x = position.x;
}
if (w > maxSize.w) {
w = maxSize.w;
x = position.x;
}
if (h < minSize.h) {
h = minSize.h;
y = position.y;
}
if (h > maxSize.h) {
h = maxSize.h;
y = position.y;
}
setPosition({ x, y });
setSize({ w, h });
}
}
function handlePointerUp(e) {
setOrigin(null);
}
return (
<div
{...props}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{children}
</div>
);
}
export default function DraggableResizable({
header,
children,
defaultPosition = {
x: 0,
y: 0,
},
defaultSize = {
w: 600,
h: 400,
},
minSize = {
w: 300,
h: 200,
},
maxSize = {
w: 1200,
h: 1200,
},
onChangeSize,
onChangePosition,
}) {
const lineWidth = 4;
const [position, setPosition] = useState(defaultPosition);
const [size, setSize] = useState(defaultSize);
const opts = {
size,
setSize,
position,
setPosition,
minSize,
maxSize,
};
useEffect(() => {
onChangeSize && onChangeSize(size);
}, [size, onChangeSize]);
useEffect(() => {
onChangePosition && onChangePosition(position);
}, [position, onChangePosition]);
return (
<Box
style={{
position: "fixed",
left: position.x,
top: position.y,
display: "grid",
gridTemplateColumns: `${lineWidth * 2}px auto ${lineWidth * 2}px`,
gridTemplateRows: `${lineWidth * 2}px auto ${lineWidth * 2}px`,
zIndex: 2147483647,
}}
>
<Pointer
direction="TopLeft"
style={{
transform: `translate(${lineWidth}px, ${lineWidth}px)`,
cursor: "nw-resize",
}}
{...opts}
/>
<Pointer
direction="Top"
style={{
margin: `0 ${lineWidth}px`,
transform: `translate(0px, ${lineWidth}px)`,
cursor: "row-resize",
}}
{...opts}
/>
<Pointer
direction="TopRight"
style={{
transform: `translate(-${lineWidth}px, ${lineWidth}px)`,
cursor: "ne-resize",
}}
{...opts}
/>
<Pointer
direction="Left"
style={{
margin: `${lineWidth}px 0`,
transform: `translate(${lineWidth}px, 0px)`,
cursor: "col-resize",
}}
{...opts}
/>
<Paper elevation={4}>
<Pointer direction="Header" style={{ cursor: "move" }} {...opts}>
{header}
</Pointer>
<div
style={{
width: size.w,
height: size.h,
overflow: "hidden auto",
}}
>
{children}
</div>
</Paper>
<Pointer
direction="Right"
style={{
margin: `${lineWidth}px 0`,
transform: `translate(-${lineWidth}px, 0px)`,
cursor: "col-resize",
}}
{...opts}
/>
<Pointer
direction="BottomLeft"
style={{
transform: `translate(${lineWidth}px, -${lineWidth}px)`,
cursor: "ne-resize",
}}
{...opts}
/>
<Pointer
direction="Bottom"
style={{
margin: `0 ${lineWidth}px`,
transform: `translate(0px, -${lineWidth}px)`,
cursor: "row-resize",
}}
{...opts}
/>
<Pointer
direction="BottomRight"
style={{
transform: `translate(-${lineWidth}px, -${lineWidth}px)`,
cursor: "nw-resize",
}}
{...opts}
/>
</Box>
);
}

View File

@@ -0,0 +1,31 @@
import IconButton from "@mui/material/IconButton";
import FavoriteIcon from "@mui/icons-material/Favorite";
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
import { useState } from "react";
import { useFavWords } from "../../hooks/FavWords";
export default function FavBtn({ word }) {
const { favWords, toggleFav } = useFavWords();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
await toggleFav(word);
} catch (err) {
console.log("[set fav]", err);
} finally {
setLoading(false);
}
};
return (
<IconButton disabled={loading} size="small" onClick={handleClick}>
{favWords[word] ? (
<FavoriteIcon fontSize="inherit" />
) : (
<FavoriteBorderIcon fontSize="inherit" />
)}
</IconButton>
);
}

View File

@@ -0,0 +1,195 @@
import { SettingProvider } from "../../hooks/Setting";
import ThemeProvider from "../../hooks/Theme";
import DraggableResizable from "./DraggableResizable";
import Header from "../Popup/Header";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
import Grid from "@mui/material/Grid";
import Box from "@mui/material/Box";
import Divider from "@mui/material/Divider";
import IconButton from "@mui/material/IconButton";
import DoneIcon from "@mui/icons-material/Done";
import { useI18n } from "../../hooks/I18n";
import { OPT_TRANS_ALL, OPT_LANGS_FROM, OPT_LANGS_TO } from "../../config";
import { useState, useRef } from "react";
import TranCont from "./TranCont";
import CopyBtn from "./CopyBtn";
function TranForm({ text, setText, tranboxSetting, transApis }) {
const i18n = useI18n();
const [editMode, setEditMode] = useState(false);
const [editText, setEditText] = useState("");
const [translator, setTranslator] = useState(tranboxSetting.translator);
const [fromLang, setFromLang] = useState(tranboxSetting.fromLang);
const [toLang, setToLang] = useState(tranboxSetting.toLang);
const [toLang2, setToLang2] = useState(tranboxSetting.toLang2);
const inputRef = useRef(null);
return (
<Stack sx={{ p: 2 }} spacing={2}>
<Box>
<Grid container spacing={2} columns={12}>
<Grid item xs={4} sm={4} md={4} lg={4}>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
fullWidth
size="small"
name="fromLang"
value={fromLang}
label={i18n("from_lang")}
onChange={(e) => {
setFromLang(e.target.value);
}}
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={4} sm={4} md={4} lg={4}>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
fullWidth
size="small"
name="toLang"
value={toLang}
label={i18n("to_lang")}
onChange={(e) => {
setToLang(e.target.value);
}}
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={4} sm={4} md={4} lg={4}>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
fullWidth
size="small"
value={translator}
name="translator"
label={i18n("translate_service")}
onChange={(e) => {
setTranslator(e.target.value);
}}
>
{OPT_TRANS_ALL.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</TextField>
</Grid>
</Grid>
</Box>
<Box>
<TextField
size="small"
label={i18n("original_text")}
inputRef={inputRef}
fullWidth
multiline
value={editMode ? editText : text}
disabled={!editMode}
onChange={(e) => {
setEditText(e.target.value);
}}
onClick={() => {
setEditMode(true);
setEditText(text);
const timer = setTimeout(() => {
clearTimeout(timer);
inputRef.current?.focus();
}, 100);
}}
onBlur={() => {
setEditMode(false);
setText(editText.trim());
}}
InputProps={{
endAdornment: (
<Stack
direction="row"
sx={{
position: "absolute",
right: 0,
top: 0,
}}
>
{editMode ? (
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
}}
>
<DoneIcon fontSize="inherit" />
</IconButton>
) : (
<CopyBtn text={text} />
)}
</Stack>
),
}}
/>
</Box>
<TranCont
text={text}
translator={translator}
fromLang={fromLang}
toLang={toLang}
toLang2={toLang2}
setToLang={setToLang}
setToLang2={setToLang2}
transApis={transApis}
/>
</Stack>
);
}
export default function TranBox({
text,
setText,
setShowBox,
tranboxSetting,
transApis,
boxSize,
setBoxSize,
boxPosition,
setBoxPosition,
}) {
return (
<SettingProvider>
<ThemeProvider>
<DraggableResizable
defaultPosition={boxPosition}
defaultSize={boxSize}
header={<Header setShowPopup={setShowBox} />}
onChangeSize={setBoxSize}
onChangePosition={setBoxPosition}
>
<Divider />
<TranForm
text={text}
setText={setText}
tranboxSetting={tranboxSetting}
transApis={transApis}
/>
</DraggableResizable>
</ThemeProvider>
</SettingProvider>
);
}

View File

@@ -0,0 +1,36 @@
export default function TranBtn({ onClick, position, tranboxSetting }) {
return (
<div
style={{
cursor: "pointer",
position: "absolute",
left: position.x + tranboxSetting.btnOffsetX,
top: position.y + tranboxSetting.btnOffsetY,
zIndex: 2147483647,
}}
onClick={onClick}
onMouseUp={(e) => {
e.stopPropagation();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 32 32"
version="1.1"
>
<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"
transform="translate(0,0)"
/>
<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"
transform="translate(4,5)"
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import TextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
import Stack from "@mui/material/Stack";
import { useI18n } from "../../hooks/I18n";
import { DEFAULT_TRANS_APIS, OPT_TRANS_BAIDU } from "../../config";
import { useEffect, useState } from "react";
import { apiTranslate, apiBaiduLangdetect } from "../../apis";
import { isValidWord } from "../../libs/utils";
import CopyBtn from "./CopyBtn";
import DictCont from "./DictCont";
export default function TranCont({
text,
translator,
fromLang,
toLang,
toLang2 = "en",
setToLang,
setToLang2,
transApis,
}) {
const i18n = useI18n();
const [trText, setTrText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [dictResult, setDictResult] = useState(null);
useEffect(() => {
(async () => {
try {
setLoading(true);
setTrText("");
setError("");
setDictResult(null);
// 互译
if (toLang !== toLang2 && toLang2 !== "none") {
const detectLang = await apiBaiduLangdetect(text);
if (detectLang === toLang) {
setToLang(toLang2);
setToLang2(toLang);
return;
}
}
const apiSetting =
transApis[translator] || DEFAULT_TRANS_APIS[translator];
const tranRes = await apiTranslate({
text,
translator,
fromLang,
toLang,
apiSetting,
});
setTrText(tranRes[0]);
// 词典
if (isValidWord(text) && toLang.startsWith("zh")) {
if (fromLang === "en" && translator === OPT_TRANS_BAIDU) {
setDictResult(tranRes[2].dict_result);
} else {
const dictRes = await apiTranslate({
text,
translator: OPT_TRANS_BAIDU,
fromLang: "en",
toLang: "zh-CN",
});
setDictResult(dictRes[2].dict_result);
}
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, [
text,
translator,
fromLang,
toLang,
toLang2,
setToLang,
setToLang2,
transApis,
]);
return (
<>
<Box>
<TextField
size="small"
label={i18n("translated_text")}
// disabled
fullWidth
multiline
value={trText}
InputProps={{
endAdornment: (
<Stack
direction="row"
sx={{
position: "absolute",
right: 0,
top: 0,
}}
>
<CopyBtn text={trText} />
</Stack>
),
}}
/>
</Box>
{loading && <CircularProgress size={24} />}
{error && <Alert severity="error">{error}</Alert>}
{dictResult && <DictCont dictResult={dictResult} />}
</>
);
}

View File

@@ -0,0 +1,137 @@
import { useState, useEffect, useCallback } from "react";
import TranBtn from "./TranBtn";
import TranBox from "./TranBox";
import { shortcutRegister } from "../../libs/shortcut";
import { sleep } from "../../libs/utils";
import { isGm } from "../../libs/client";
import { MSG_OPEN_TRANBOX, DEFAULT_TRANBOX_SHORTCUT } from "../../config";
export default function Slection({ contextMenus, tranboxSetting, transApis }) {
const [showBox, setShowBox] = useState(false);
const [showBtn, setShowBtn] = useState(false);
const [selectedText, setSelText] = useState("");
const [text, setText] = useState("");
const [position, setPosition] = useState({ x: 0, y: 0 });
const [boxSize, setBoxSize] = useState({ w: 600, h: 400 });
const [boxPosition, setBoxPosition] = useState({
x: (window.innerWidth - 600) / 2,
y: (window.innerHeight - 400) / 2,
});
const handleClick = (e) => {
e.stopPropagation();
setShowBtn(false);
setText(selectedText);
setShowBox(true);
};
const handleTranbox = useCallback(() => {
setShowBtn(false);
const selectedText = window.getSelection()?.toString()?.trim() || "";
if (!selectedText) {
setShowBox((pre) => !pre);
return;
}
setSelText(selectedText);
setText(selectedText);
setShowBox(true);
}, []);
useEffect(() => {
async function handleMouseup(e) {
await sleep(10);
const selectedText = window.getSelection()?.toString()?.trim() || "";
setSelText(selectedText);
if (!selectedText) {
setShowBtn(false);
return;
}
!tranboxSetting.hideTranBtn && setShowBtn(true);
// setPosition({ x: e.clientX, y: e.clientY });
setPosition({ x: e.pageX, y: e.pageY });
}
window.addEventListener("mouseup", handleMouseup);
return () => {
window.removeEventListener("mouseup", handleMouseup);
};
}, [tranboxSetting.hideTranBtn]);
useEffect(() => {
const clearShortcut = shortcutRegister(
tranboxSetting.tranboxShortcut || DEFAULT_TRANBOX_SHORTCUT,
handleTranbox
);
return () => {
clearShortcut();
};
}, [tranboxSetting.tranboxShortcut, handleTranbox]);
useEffect(() => {
window.addEventListener(MSG_OPEN_TRANBOX, handleTranbox);
return () => {
window.removeEventListener(MSG_OPEN_TRANBOX, handleTranbox);
};
}, [handleTranbox]);
useEffect(() => {
if (!isGm) {
return;
}
// 注册菜单
try {
const menuCommandIds = [];
contextMenus &&
menuCommandIds.push(
GM.registerMenuCommand(
"Translate Selected Text (Alt+S)",
(event) => {
handleTranbox();
},
"S"
)
);
return () => {
menuCommandIds.forEach((id) => {
GM.unregisterMenuCommand(id);
});
};
} catch (err) {
console.log("[registerMenuCommand]", err);
}
}, [handleTranbox, contextMenus]);
return (
<>
{showBox && (
<TranBox
text={text}
setText={setText}
boxSize={boxSize}
setBoxSize={setBoxSize}
boxPosition={boxPosition}
setBoxPosition={setBoxPosition}
tranboxSetting={tranboxSetting}
transApis={transApis}
setShowBox={setShowBox}
/>
)}
{showBtn && (
<TranBtn
position={position}
tranboxSetting={tranboxSetting}
onClick={handleClick}
/>
)}
</>
);
}

15289
yarn.lock

File diff suppressed because it is too large Load Diff