├── .babelrc ├── .env ├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.en.md ├── README.md ├── config-overrides.js ├── kiss-translator.png ├── kiss-translator.webm ├── package.json ├── pnpm-lock.yaml ├── public ├── .nojekyll ├── _locales │ ├── en │ │ └── messages.json │ └── zh_CN │ │ └── messages.json ├── content.html ├── favicon.ico ├── images │ ├── logo128.png │ ├── logo16.png │ ├── logo192.png │ ├── logo32.png │ └── logo48.png ├── index.html ├── manifest.firefox.json └── manifest.json ├── screenshot-01.jpg ├── screenshot-02.jpg └── src ├── apis ├── baidu.js ├── deepl.js ├── index.js └── trans.js ├── background.js ├── common.js ├── config ├── app.js ├── i18n.js ├── index.js └── rules.js ├── content.js ├── hooks ├── Alert.js ├── Api.js ├── Audio.js ├── ColorMode.js ├── Fab.js ├── FavWords.js ├── Fetch.js ├── I18n.js ├── InputRule.js ├── Rules.js ├── Setting.js ├── Shortcut.js ├── Storage.js ├── SubRules.js ├── Sync.js ├── Theme.js ├── Tranbox.js └── Translate.js ├── index.js ├── libs ├── auth.js ├── blacklist.js ├── browser.js ├── client.js ├── fetch.js ├── gm.js ├── iframe.js ├── index.js ├── injector.js ├── inputTranslate.js ├── interpreter.js ├── log.js ├── mobile.js ├── msg.js ├── pool.js ├── rules.js ├── shortcut.js ├── storage.js ├── subRules.js ├── svg.js ├── sync.js ├── touch.js ├── translator.js ├── utils.js └── webfix.js ├── options.js ├── popup.js ├── rules.js ├── userscript.js └── views ├── Action ├── Draggable.js └── index.js ├── Content ├── LoadingIcon.js └── index.js ├── Options ├── About.js ├── Apis.js ├── DarkModeButton.js ├── DownloadButton.js ├── FavWords.js ├── Header.js ├── HelpButton.js ├── InputSetting.js ├── Layout.js ├── Navigator.js ├── OwSubRule.js ├── Rules.js ├── Setting.js ├── ShortcutInput.js ├── SyncSetting.js ├── Tranbox.js ├── UploadButton.js └── index.js ├── Popup ├── Header.js └── index.js └── Selection ├── AudioBtn.js ├── CopyBtn.js ├── DictCont.js ├── DraggableResizable.js ├── FavBtn.js ├── SugCont.js ├── TranBox.js ├── TranBtn.js ├── TranCont.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | GENERATE_SOURCEMAP=false 2 | 3 | REACT_APP_NAME=KISS Translator 4 | REACT_APP_NAME_CN=简约翻译 5 | REACT_APP_VERSION=1.8.11 6 | 7 | REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator 8 | 9 | REACT_APP_OPTIONSPAGE=https://fishjar.github.io/kiss-translator/options.html 10 | REACT_APP_OPTIONSPAGE_DEV=http://localhost:3000/options.html 11 | 12 | REACT_APP_LOGOURL=https://fishjar.github.io/kiss-translator/images/logo192.png 13 | 14 | REACT_APP_RULESURL=https://fishjar.github.io/kiss-rules/kiss-rules.json 15 | REACT_APP_RULESURL_ON=https://fishjar.github.io/kiss-rules/kiss-rules-on.json 16 | REACT_APP_RULESURL_OFF=https://fishjar.github.io/kiss-rules/kiss-rules-off.json 17 | 18 | REACT_APP_USERSCRIPT_DOWNLOADURL=https://fishjar.github.io/kiss-translator/kiss-translator.user.js 19 | REACT_APP_USERSCRIPT_IOS_DOWNLOADURL=https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: publish release version 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-22.04 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: pnpm/action-setup@v2 14 | with: 15 | version: 8.7.6 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: "18.17.0" 19 | cache: "pnpm" 20 | - run: pnpm install 21 | - run: pnpm build 22 | - uses: actions/upload-artifact@v3 23 | with: 24 | name: build-artifacts 25 | path: build 26 | deploy-web: 27 | needs: build 28 | runs-on: ubuntu-22.04 29 | steps: 30 | - uses: actions/checkout@v3 31 | - uses: actions/download-artifact@v3 32 | with: 33 | name: build-artifacts 34 | path: build 35 | - name: Deploy to GitHub Pages 36 | uses: JamesIves/github-pages-deploy-action@v4 37 | with: 38 | folder: build/web 39 | create-release: 40 | runs-on: ubuntu-22.04 41 | outputs: 42 | upload_url: ${{ steps.create-release.outputs.upload_url }} 43 | steps: 44 | - uses: actions/create-release@v1 45 | id: create-release 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | tag_name: ${{ github.ref }} 50 | release_name: Release ${{ github.ref }} 51 | draft: false 52 | prerelease: false 53 | upload-release: 54 | needs: [build, create-release] 55 | strategy: 56 | matrix: 57 | client: ["chrome", "edge", "firefox", "userscript"] 58 | runs-on: ubuntu-22.04 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/download-artifact@v3 62 | with: 63 | name: build-artifacts 64 | path: build 65 | - name: Zip Release 66 | run: | 67 | cd build 68 | zip -r ${{ matrix.client }}.zip ${{ matrix.client }} 69 | - uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ needs.create-release.outputs.upload_url }} 74 | asset_path: ./build/${{ matrix.client }}.zip 75 | asset_name: kiss-translator_${{ github.ref_name }}_${{ matrix.client }}.zip 76 | asset_content_type: application/zip 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | *.crx 27 | *.pem 28 | *.zip 29 | -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- 1 | # KISS Translator 2 | 3 | English | [简体中文](README.md) 4 | 5 | A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator). 6 | 7 | [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) 8 | 9 | ## Features 10 | 11 | - [x] Keep it simple, smart 12 | - [x] Open source 13 | - [x] Adapt to common browsers 14 | - [x] Chrome/Edge/Firefox/Kiwi/Orion 15 | - [ ] Safari 16 | - [x] Supports multiple translation services 17 | - [x] Google/Microsoft/DeepL/NiuTrans/OpenAI/Gemini/CloudflareAI/Baidu/Tencent 18 | - [x] Custom translation interface 19 | - [x] Covers common translation scenarios 20 | - [x] Web bilingual translation 21 | - [x] Input box translation 22 | - [x] Seletction translation 23 | - [x] Favorite Words 24 | - [x] Mouseover translation 25 | - [x] YouTube subtitle translation 26 | - [x] Cross-client data synchronization 27 | - [x] KISS-Worker(cloudflare/docker) 28 | - [x] WebDAV 29 | - [x] Custom translation rules 30 | - [x] Rule subscription/rule sharing 31 | - [x] Customized terminology 32 | - [x] Custom translation style 33 | - [x] Custom shortcut keys 34 | - `Alt+Q` Toggle Translation 35 | - `Alt+C` Toggle Styles 36 | - `Alt+K` Open Setting Popup 37 | - `Alt+S` Open Translate Popup / Translate Selected Text 38 | - `Alt+O` Open Options Page 39 | - `Alt+I` Input Box Translation 40 | 41 | ## Install 42 | 43 | > Note: For the following reasons, it is recommended to use browser extensions first 44 | > 45 | > - Browser extensions have more complete functions (local language recognition, context menu, etc.) 46 | > - Grease Monkey script will encounter more usage problems (cross domain issues, script conflicts, etc.) 47 | 48 | - [x] Browser extension 49 | - [x] Chrome [Installation address](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN) 50 | - [x] Kiwi (Android) 51 | - [x] Orion (iOS) 52 | - [x] Edge [Installation address](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN) 53 | - [x] Firefox [Installation address](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/) 54 | - [ ] Safari 55 | - [x] GreaseMonkey Script 56 | - [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) 57 | - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) 58 | - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [Installation link](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) 59 | 60 | ## Associated Projects 61 | 62 | - Data synchronization service: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) 63 | - Data synchronization service available for this project. 64 | - Can also be used to share personal private rule lists. 65 | - Deploy by yourself, manage by yourself, data is private. 66 | - Community subscription rules: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) 67 | - Provides the latest and most complete list of subscription rules maintained by the community. 68 | - Help with rules-related issues. 69 | - Translation interface agent: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy) 70 | - If you encounter network problems when accessing a certain translation interface, this proxy service may help you. 71 | - Deploy and manage by yourself. 72 | - Minimalistic Dictionary Plugin: [https://github.com/fishjar/kiss-dictionary](https://github.com/fishjar/kiss-dictionary) 73 | - A word-marking translation plug-in used with this project. 74 | - Supports query of English words, sentences and Chinese characters. 75 | - Supports history records and word collections. 76 | 77 | ## Development Guidelines 78 | 79 | ```sh 80 | git clone https://github.com/fishjar/kiss-translator.git 81 | cd kiss-translator 82 | pnpm install 83 | pnpm build 84 | ``` 85 | 86 | ## Discussion 87 | 88 | - Join [Telegram Group](https://t.me/+RRCu_4oNwrM2NmFl) 89 | 90 | ## Appreciate 91 | 92 | ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 简约翻译 2 | 3 | [English](README.en.md) | 简体中文 4 | 5 | 一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。 6 | 7 | [kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f) 8 | 9 | ## 特性 10 | 11 | - [x] 保持简约 12 | - [x] 开放源代码 13 | - [x] 适配常见浏览器 14 | - [x] Chrome/Edge/Firefox/Kiwi/Orion 15 | - [ ] Safari 16 | - [x] 支持多种翻译服务 17 | - [x] Google/Microsoft/DeepL/NiuTrans/OpenAI/Gemini/CloudflareAI/Baidu/Tencent 18 | - [x] 自定义翻译接口 19 | - [x] 覆盖常见翻译场景 20 | - [x] 网页双语对照翻译 21 | - [x] 输入框翻译 22 | - [x] 划词翻译 23 | - [x] 收藏词汇 24 | - [x] 鼠标悬停翻译 25 | - [x] YouTube 字幕翻译 26 | - [x] 跨客户端数据同步 27 | - [x] KISS-Worker(cloudflare/docker) 28 | - [x] WebDAV 29 | - [x] 自定义翻译规则 30 | - [x] 规则订阅/规则分享 31 | - [x] 自定义专业术语 32 | - [x] 自定义译文样式 33 | - [x] 自定义快捷键 34 | - `Alt+Q` 开启翻译 35 | - `Alt+C` 切换样式 36 | - `Alt+K` 打开设置弹窗 37 | - `Alt+S` 打开翻译弹窗/翻译选中文字 38 | - `Alt+O` 打开设置页面 39 | - `Alt+I` 输入框翻译 40 | 41 | ## 安装 42 | 43 | > 注:基于以下原因,建议优先使用浏览器扩展 44 | > 45 | > - 浏览器扩展的功能更完整(本地语言识别、右键菜单等) 46 | > - 油猴脚本会遇到更多使用上的问题(跨域问题、脚本冲突等) 47 | 48 | - [x] 浏览器扩展 49 | - [x] Chrome [安装地址](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN) 50 | - [x] Kiwi (Android) 51 | - [x] Orion (iOS) 52 | - [x] Edge [安装地址](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN) 53 | - [x] Firefox [安装地址](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/) 54 | - [ ] Safari 55 | - [x] 油猴脚本 56 | - [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator.user.js) 57 | - [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator) 58 | - [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [安装链接](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js) 59 | 60 | ## 关联项目 61 | 62 | - 数据同步服务: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker) 63 | - 可用于本项目的数据同步服务。 64 | - 亦可用于分享个人的私有规则列表。 65 | - 自己部署,自己管理,数据私有。 66 | - 社区订阅规则: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules) 67 | - 提供社区维护的,最新最全的订阅规则列表。 68 | - 求助规则相关的问题。 69 | - 翻译接口代理: [https://github.com/fishjar/kiss-proxy](https://github.com/fishjar/kiss-proxy) 70 | - 如果访问某个翻译接口遇到网络问题,这个代理服务也许可以帮到你。 71 | - 自己部署,自己管理。 72 | - 简约词典插件: [https://github.com/fishjar/kiss-dictionary](https://github.com/fishjar/kiss-dictionary) 73 | - 搭配本项目一起使用的划词翻译插件。 74 | - 支持英文单词、句子、汉字的查询。 75 | - 支持历史记录、单词收藏。 76 | 77 | ## 开发指引 78 | 79 | ```sh 80 | git clone https://github.com/fishjar/kiss-translator.git 81 | cd kiss-translator 82 | pnpm install 83 | pnpm build 84 | ``` 85 | 86 | ## 交流 87 | 88 | - 加入 [Telegram 群](https://t.me/+RRCu_4oNwrM2NmFl) 89 | 90 | ## 赞赏 91 | 92 | ![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399) 93 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const paths = require("react-scripts/config/paths"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const { WebpackManifestPlugin } = require("webpack-manifest-plugin"); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | const webpack = require("webpack"); 6 | 7 | console.log("process.env.REACT_APP_CLIENT", process.env.REACT_APP_CLIENT); 8 | 9 | // 扩展 10 | const extWebpack = (config, env) => { 11 | const isEnvProduction = env === "production"; 12 | const minify = isEnvProduction && { 13 | removeComments: true, 14 | collapseWhitespace: true, 15 | removeRedundantAttributes: true, 16 | useShortDoctype: true, 17 | removeEmptyAttributes: true, 18 | removeStyleLinkTypeAttributes: true, 19 | keepClosingSlash: true, 20 | minifyJS: true, 21 | minifyCSS: true, 22 | minifyURLs: true, 23 | }; 24 | const names = [ 25 | "HtmlWebpackPlugin", 26 | "WebpackManifestPlugin", 27 | "MiniCssExtractPlugin", 28 | ]; 29 | 30 | config.entry = { 31 | popup: paths.appSrc + "/popup.js", 32 | options: paths.appSrc + "/options.js", 33 | background: paths.appSrc + "/background.js", 34 | content: paths.appSrc + "/content.js", 35 | }; 36 | 37 | config.output.filename = "[name].js"; 38 | config.output.assetModuleFilename = "media/[name][ext]"; 39 | config.optimization.splitChunks = { cacheGroups: { default: false } }; 40 | config.optimization.runtimeChunk = false; 41 | 42 | config.plugins = config.plugins.filter( 43 | (plugin) => !names.includes(plugin.constructor.name) 44 | ); 45 | 46 | config.plugins.push( 47 | new HtmlWebpackPlugin({ 48 | inject: true, 49 | chunks: ["options"], 50 | template: paths.appHtml, 51 | filename: "options.html", 52 | minify, 53 | }), 54 | new HtmlWebpackPlugin({ 55 | inject: true, 56 | chunks: ["popup"], 57 | template: paths.appHtml, 58 | filename: "popup.html", 59 | minify, 60 | }), 61 | new WebpackManifestPlugin({ 62 | fileName: "asset-manifest.json", 63 | }), 64 | new MiniCssExtractPlugin({ 65 | filename: "css/[name].css", 66 | }) 67 | ); 68 | 69 | return config; 70 | }; 71 | 72 | // 油猴 73 | const userscriptWebpack = (config, env) => { 74 | const banner = `// ==UserScript== 75 | // @name ${process.env.REACT_APP_NAME} 76 | // @namespace ${process.env.REACT_APP_HOMEPAGE} 77 | // @version ${process.env.REACT_APP_VERSION} 78 | // @description A simple bilingual translation extension & Greasemonkey script (一个简约的双语对照翻译扩展 & 油猴脚本) 79 | // @author Gabe 80 | // @homepageURL ${process.env.REACT_APP_HOMEPAGE} 81 | // @license GPL-3.0 82 | // @match *://*/* 83 | // @icon ${process.env.REACT_APP_LOGOURL} 84 | // @downloadURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL} 85 | // @updateURL ${process.env.REACT_APP_USERSCRIPT_DOWNLOADURL} 86 | // @grant GM.xmlHttpRequest 87 | // @grant GM.registerMenuCommand 88 | // @grant GM.unregisterMenuCommand 89 | // @grant GM.setValue 90 | // @grant GM.getValue 91 | // @grant GM.deleteValue 92 | // @grant GM.info 93 | // @grant unsafeWindow 94 | // @connect translate.googleapis.com 95 | // @connect api-edge.cognitive.microsofttranslator.com 96 | // @connect edge.microsoft.com 97 | // @connect api-free.deepl.com 98 | // @connect api.deepl.com 99 | // @connect www2.deepl.com 100 | // @connect api.openai.com 101 | // @connect generativelanguage.googleapis.com 102 | // @connect openai.azure.com 103 | // @connect workers.dev 104 | // @connect github.io 105 | // @connect githubusercontent.com 106 | // @connect kiss-translator.rayjar.com 107 | // @connect ghproxy.com 108 | // @connect dav.jianguoyun.com 109 | // @connect fanyi.baidu.com 110 | // @connect transmart.qq.com 111 | // @connect localhost:3000 112 | // @connect 127.0.0.1:3000 113 | // @connect localhost:1188 114 | // @connect 127.0.0.1:1188 115 | // @connect localhost:11434 116 | // @connect 127.0.0.1:11434 117 | // @run-at document-end 118 | // ==/UserScript== 119 | 120 | `; 121 | 122 | const names = ["HtmlWebpackPlugin"]; 123 | 124 | config.entry = { 125 | main: paths.appIndexJs, 126 | options: paths.appSrc + "/options.js", 127 | "kiss-translator.user": paths.appSrc + "/userscript.js", 128 | }; 129 | 130 | config.output.filename = "[name].js"; 131 | config.output.publicPath = env === "production" ? "./" : "/"; 132 | config.optimization.splitChunks = { cacheGroups: { default: false } }; 133 | config.optimization.runtimeChunk = false; 134 | config.optimization.minimize = false; 135 | 136 | config.plugins = config.plugins.filter( 137 | (plugin) => !names.includes(plugin.constructor.name) 138 | ); 139 | 140 | config.plugins.push( 141 | new HtmlWebpackPlugin({ 142 | inject: true, 143 | chunks: ["main"], 144 | template: paths.appHtml, 145 | filename: "index.html", 146 | }), 147 | new HtmlWebpackPlugin({ 148 | inject: true, 149 | chunks: ["options"], 150 | template: paths.appHtml, 151 | filename: "options.html", 152 | }), 153 | new webpack.BannerPlugin({ 154 | banner, 155 | raw: true, 156 | entryOnly: true, 157 | include: "kiss-translator.user", 158 | }) 159 | ); 160 | 161 | return config; 162 | }; 163 | 164 | // 开发 165 | const webWebpack = (config, env) => { 166 | const names = ["HtmlWebpackPlugin"]; 167 | 168 | config.entry = { 169 | main: paths.appIndexJs, 170 | options: paths.appSrc + "/options.js", 171 | content: paths.appSrc + "/userscript.js", 172 | }; 173 | 174 | config.output.filename = "[name].js"; 175 | config.output.publicPath = "/"; 176 | 177 | config.plugins = config.plugins.filter( 178 | (plugin) => !names.includes(plugin.constructor.name) 179 | ); 180 | 181 | config.plugins.push( 182 | new HtmlWebpackPlugin({ 183 | inject: true, 184 | chunks: ["main"], 185 | template: paths.appHtml, 186 | filename: "index.html", 187 | }), 188 | new HtmlWebpackPlugin({ 189 | inject: true, 190 | chunks: ["options"], 191 | template: paths.appHtml, 192 | filename: "options.html", 193 | }), 194 | new HtmlWebpackPlugin({ 195 | inject: true, 196 | chunks: ["content"], 197 | template: paths.appPublic + "/content.html", 198 | filename: "content.html", 199 | }) 200 | ); 201 | 202 | return config; 203 | }; 204 | 205 | let webpackConfig; 206 | switch (process.env.REACT_APP_CLIENT) { 207 | case "userscript": 208 | webpackConfig = userscriptWebpack; 209 | break; 210 | case "web": 211 | webpackConfig = webWebpack; 212 | break; 213 | default: 214 | webpackConfig = extWebpack; 215 | } 216 | 217 | module.exports = { 218 | webpack: webpackConfig, 219 | }; 220 | -------------------------------------------------------------------------------- /kiss-translator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/kiss-translator.png -------------------------------------------------------------------------------- /kiss-translator.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/kiss-translator.webm -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kiss-translator", 3 | "description": "A minimalist bilingual translation Extension & Greasemonkey Script", 4 | "version": "1.8.11", 5 | "author": "Gabe", 6 | "private": true, 7 | "dependencies": { 8 | "@emotion/cache": "^11.11.0", 9 | "@emotion/react": "^11.11.1", 10 | "@emotion/styled": "^11.11.0", 11 | "@mui/icons-material": "^5.15.15", 12 | "@mui/lab": "5.0.0-alpha.170", 13 | "@mui/material": "^5.15.15", 14 | "query-string": "^8.1.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-markdown": "^8.0.7", 18 | "react-router-dom": "^6.16.0", 19 | "react-scripts": "5.0.1", 20 | "sval": "^0.5.2", 21 | "webdav": "^5.3.0", 22 | "webextension-polyfill": "^0.10.0" 23 | }, 24 | "scripts": { 25 | "start": "REACT_APP_CLIENT=web react-app-rewired start", 26 | "start:userscript": "REACT_APP_CLIENT=userscript react-app-rewired start", 27 | "build:chrome": "rm -rf build/chrome && BUILD_PATH=./build/chrome REACT_APP_CLIENT=chrome react-app-rewired build && rm build/chrome/content.html", 28 | "build:edge": "rm -rf build/edge && cp -r build/chrome build/edge", 29 | "build:firefox": "rm -rf build/firefox && cp -r build/chrome build/firefox && cat ./build/firefox/manifest.firefox.json > ./build/firefox/manifest.json && rm build/*/manifest.firefox.json", 30 | "build:web": "rm -rf build/web && BUILD_PATH=./build/web REACT_APP_CLIENT=userscript react-app-rewired build", 31 | "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", 32 | "build:userscript": "rm -rf build/userscript && mkdir build/userscript && cp build/web/*.user.js build/userscript/", 33 | "build:rules": "babel-node src/rules.js", 34 | "build": "pnpm build:chrome && pnpm build:edge && pnpm build:firefox && pnpm build:web && pnpm build:userscript-ios && pnpm build:userscript && pnpm build:rules", 35 | "zip": "cd build && zip -r chrome.zip chrome && zip -r edge.zip edge && cd firefox && zip -r ../firefox.zip .", 36 | "test": "react-app-rewired test", 37 | "eject": "react-scripts eject" 38 | }, 39 | "eslintConfig": { 40 | "extends": [ 41 | "react-app", 42 | "react-app/jest" 43 | ], 44 | "globals": { 45 | "GM": true, 46 | "unsafeWindow": true, 47 | "globalThis": true 48 | } 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.22.20", 64 | "@babel/node": "^7.22.19", 65 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 66 | "@babel/preset-env": "^7.22.20", 67 | "react-app-rewired": "^2.2.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /public/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/.nojekyll -------------------------------------------------------------------------------- /public/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": { 3 | "message": "KISS Translator" 4 | }, 5 | "app_description": { 6 | "message": "A simple bilingual translation extension & Greasemonkey script" 7 | }, 8 | "toggle_translate": { 9 | "message": "Toggle Translate" 10 | }, 11 | "toggle_style": { 12 | "message": "Toggle Style" 13 | }, 14 | "open_options": { 15 | "message": "Open Options" 16 | }, 17 | "open_tranbox": { 18 | "message": "Translate Popup/Selected" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": { 3 | "message": "简约翻译" 4 | }, 5 | "app_description": { 6 | "message": "一个简约的双语对照翻译扩展 & 油猴脚本" 7 | }, 8 | "toggle_translate": { 9 | "message": "开启翻译" 10 | }, 11 | "toggle_style": { 12 | "message": "切换样式" 13 | }, 14 | "open_options": { 15 | "message": "打开设置" 16 | }, 17 | "open_tranbox": { 18 | "message": "翻译弹窗/选中文字" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/favicon.ico -------------------------------------------------------------------------------- /public/images/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/images/logo128.png -------------------------------------------------------------------------------- /public/images/logo16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/images/logo16.png -------------------------------------------------------------------------------- /public/images/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/images/logo192.png -------------------------------------------------------------------------------- /public/images/logo32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/images/logo32.png -------------------------------------------------------------------------------- /public/images/logo48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/public/images/logo48.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %REACT_APP_NAME% 7 | 8 | 9 | 10 |
11 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /public/manifest.firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "__MSG_app_name__", 4 | "description": "__MSG_app_description__", 5 | "version": "1.8.11", 6 | "default_locale": "en", 7 | "author": "Gabe", 8 | "homepage_url": "https://github.com/fishjar/kiss-translator", 9 | "background": { 10 | "scripts": ["background.js"] 11 | }, 12 | "content_scripts": [ 13 | { 14 | "js": ["content.js"], 15 | "matches": [""], 16 | "all_frames": true 17 | } 18 | ], 19 | "commands": { 20 | "_execute_browser_action": { 21 | "suggested_key": { 22 | "default": "Alt+K" 23 | } 24 | }, 25 | "toggleTranslate": { 26 | "suggested_key": { 27 | "default": "Alt+Q" 28 | }, 29 | "description": "__MSG_toggle_translate__" 30 | }, 31 | "openTranbox": { 32 | "suggested_key": { 33 | "default": "Alt+S" 34 | }, 35 | "description": "__MSG_open_tranbox__" 36 | }, 37 | "toggleStyle": { 38 | "suggested_key": { 39 | "default": "Alt+C" 40 | }, 41 | "description": "__MSG_toggle_style__" 42 | }, 43 | "openOptions": { 44 | "description": "__MSG_open_options__" 45 | } 46 | }, 47 | "permissions": [ 48 | "", 49 | "storage", 50 | "contextMenus", 51 | "scripting", 52 | "declarativeNetRequest" 53 | ], 54 | "icons": { 55 | "16": "images/logo16.png", 56 | "32": "images/logo32.png", 57 | "48": "images/logo48.png", 58 | "128": "images/logo128.png" 59 | }, 60 | "browser_action": { 61 | "default_icon": { 62 | "128": "images/logo128.png" 63 | }, 64 | "default_title": "__MSG_app_name__", 65 | "default_popup": "popup.html" 66 | }, 67 | "options_ui": { 68 | "page": "options.html", 69 | "open_in_tab": true 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "__MSG_app_name__", 4 | "description": "__MSG_app_description__", 5 | "version": "1.8.11", 6 | "default_locale": "en", 7 | "author": "Gabe", 8 | "homepage_url": "https://github.com/fishjar/kiss-translator", 9 | "background": { 10 | "service_worker": "background.js", 11 | "type": "module" 12 | }, 13 | "content_scripts": [ 14 | { 15 | "js": ["content.js"], 16 | "matches": [""], 17 | "all_frames": true 18 | } 19 | ], 20 | "commands": { 21 | "_execute_action": { 22 | "suggested_key": { 23 | "default": "Alt+K" 24 | } 25 | }, 26 | "toggleTranslate": { 27 | "suggested_key": { 28 | "default": "Alt+Q" 29 | }, 30 | "description": "__MSG_toggle_translate__" 31 | }, 32 | "openTranbox": { 33 | "suggested_key": { 34 | "default": "Alt+S" 35 | }, 36 | "description": "__MSG_open_tranbox__" 37 | }, 38 | "toggleStyle": { 39 | "suggested_key": { 40 | "default": "Alt+C" 41 | }, 42 | "description": "__MSG_toggle_style__" 43 | }, 44 | "openOptions": { 45 | "description": "__MSG_open_options__" 46 | } 47 | }, 48 | "permissions": [ 49 | "storage", 50 | "contextMenus", 51 | "scripting", 52 | "declarativeNetRequest" 53 | ], 54 | "host_permissions": [""], 55 | "icons": { 56 | "16": "images/logo16.png", 57 | "32": "images/logo32.png", 58 | "48": "images/logo48.png", 59 | "128": "images/logo128.png" 60 | }, 61 | "action": { 62 | "default_icon": { 63 | "128": "images/logo128.png" 64 | }, 65 | "default_title": "__MSG_app_name__", 66 | "default_popup": "popup.html" 67 | }, 68 | "options_ui": { 69 | "page": "options.html", 70 | "open_in_tab": true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /screenshot-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/screenshot-01.jpg -------------------------------------------------------------------------------- /screenshot-02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fishjar/kiss-translator/72ccfc8aec10bbcbfc1e2da25785da5cfd4d1361/screenshot-02.jpg -------------------------------------------------------------------------------- /src/apis/baidu.js: -------------------------------------------------------------------------------- 1 | import queryString from "query-string"; 2 | import { URL_BAIDU_TRANSAPI, DEFAULT_USER_AGENT } from "../config"; 3 | 4 | export const genBaidu = async ({ text, from, to }) => { 5 | const data = { 6 | from, 7 | to, 8 | query: text, 9 | source: "txt", 10 | }; 11 | 12 | const init = { 13 | headers: { 14 | // Origin: "https://fanyi.baidu.com", 15 | "content-type": "application/x-www-form-urlencoded; charset=UTF-8", 16 | "User-Agent": DEFAULT_USER_AGENT, 17 | }, 18 | method: "POST", 19 | body: queryString.stringify(data), 20 | }; 21 | 22 | return [URL_BAIDU_TRANSAPI, init]; 23 | }; 24 | -------------------------------------------------------------------------------- /src/apis/deepl.js: -------------------------------------------------------------------------------- 1 | import { URL_DEEPLFREE_TRAN } from "../config"; 2 | 3 | let id = 1e4 * Math.round(1e4 * Math.random()); 4 | 5 | export const genDeeplFree = ({ text, from, to }) => { 6 | const iCount = (text.match(/[i]/g) || []).length + 1; 7 | let timestamp = Date.now(); 8 | timestamp = timestamp + (iCount - (timestamp % iCount)); 9 | id++; 10 | 11 | let body = JSON.stringify({ 12 | jsonrpc: "2.0", 13 | method: "LMT_handle_texts", 14 | params: { 15 | splitting: "newlines", 16 | lang: { 17 | target_lang: to, 18 | source_lang_user_selected: from, 19 | }, 20 | commonJobParams: { 21 | wasSpoken: false, 22 | transcribe_as: "", 23 | }, 24 | id, 25 | timestamp, 26 | texts: [ 27 | { 28 | text, 29 | requestAlternatives: 3, 30 | }, 31 | ], 32 | }, 33 | }); 34 | 35 | body = body.replace( 36 | 'method":"', 37 | (id + 3) % 13 === 0 || (id + 5) % 29 === 0 ? 'method" : "' : 'method": "' 38 | ); 39 | 40 | const init = { 41 | headers: { 42 | "Content-Type": "application/json", 43 | Accept: "*/*", 44 | "x-app-os-name": "iOS", 45 | "x-app-os-version": "16.3.0", 46 | "Accept-Language": "en-US,en;q=0.9", 47 | "Accept-Encoding": "gzip, deflate, br", 48 | "x-app-device": "iPhone13,2", 49 | "User-Agent": "DeepL-iOS/2.9.1 iOS 16.3.0 (iPhone13,2)", 50 | "x-app-build": "510265", 51 | "x-app-version": "2.9.1", 52 | }, 53 | method: "POST", 54 | body, 55 | }; 56 | 57 | return [URL_DEEPLFREE_TRAN, init]; 58 | }; 59 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import browser from "webextension-polyfill"; 2 | import { 3 | MSG_FETCH, 4 | MSG_GET_HTTPCACHE, 5 | MSG_TRANS_TOGGLE, 6 | MSG_OPEN_OPTIONS, 7 | MSG_SAVE_RULE, 8 | MSG_TRANS_TOGGLE_STYLE, 9 | MSG_OPEN_TRANBOX, 10 | MSG_CONTEXT_MENUS, 11 | MSG_COMMAND_SHORTCUTS, 12 | MSG_INJECT_JS, 13 | MSG_INJECT_CSS, 14 | MSG_UPDATE_CSP, 15 | DEFAULT_CSPLIST, 16 | CMD_TOGGLE_TRANSLATE, 17 | CMD_TOGGLE_STYLE, 18 | CMD_OPEN_OPTIONS, 19 | CMD_OPEN_TRANBOX, 20 | } from "./config"; 21 | import { getSettingWithDefault, tryInitDefaultData } from "./libs/storage"; 22 | import { trySyncSettingAndRules } from "./libs/sync"; 23 | import { fetchHandle, getHttpCache } from "./libs/fetch"; 24 | import { sendTabMsg } from "./libs/msg"; 25 | import { trySyncAllSubRules } from "./libs/subRules"; 26 | import { tryClearCaches } from "./libs"; 27 | import { saveRule } from "./libs/rules"; 28 | import { getCurTabId } from "./libs/msg"; 29 | import { injectInlineJs, injectInternalCss } from "./libs/injector"; 30 | import { kissLog } from "./libs/log"; 31 | 32 | globalThis.ContextType = "BACKGROUND"; 33 | 34 | const REMOVE_HEADERS = [ 35 | `content-security-policy`, 36 | `content-security-policy-report-only`, 37 | `x-webkit-csp`, 38 | `x-content-security-policy`, 39 | ]; 40 | 41 | /** 42 | * 添加右键菜单 43 | */ 44 | async function addContextMenus(contextMenuType = 1) { 45 | // 添加前先删除,避免重复ID的错误 46 | try { 47 | await browser.contextMenus.removeAll(); 48 | } catch (err) { 49 | // 50 | } 51 | 52 | switch (contextMenuType) { 53 | case 1: 54 | browser.contextMenus.create({ 55 | id: CMD_TOGGLE_TRANSLATE, 56 | title: browser.i18n.getMessage("app_name"), 57 | contexts: ["page", "selection"], 58 | }); 59 | break; 60 | case 2: 61 | browser.contextMenus.create({ 62 | id: CMD_TOGGLE_TRANSLATE, 63 | title: browser.i18n.getMessage("toggle_translate"), 64 | contexts: ["page", "selection"], 65 | }); 66 | browser.contextMenus.create({ 67 | id: CMD_TOGGLE_STYLE, 68 | title: browser.i18n.getMessage("toggle_style"), 69 | contexts: ["page", "selection"], 70 | }); 71 | browser.contextMenus.create({ 72 | id: CMD_OPEN_TRANBOX, 73 | title: browser.i18n.getMessage("open_tranbox"), 74 | contexts: ["page", "selection"], 75 | }); 76 | browser.contextMenus.create({ 77 | id: "options_separator", 78 | type: "separator", 79 | contexts: ["page", "selection"], 80 | }); 81 | browser.contextMenus.create({ 82 | id: CMD_OPEN_OPTIONS, 83 | title: browser.i18n.getMessage("open_options"), 84 | contexts: ["page", "selection"], 85 | }); 86 | break; 87 | default: 88 | } 89 | } 90 | 91 | /** 92 | * 更新CSP策略 93 | * @param {*} csplist 94 | */ 95 | async function updateCspRules(csplist = DEFAULT_CSPLIST.join(",\n")) { 96 | try { 97 | const newRules = csplist 98 | .split(/\n|,/) 99 | .map((url) => url.trim()) 100 | .filter(Boolean) 101 | .map((url, idx) => ({ 102 | id: idx + 1, 103 | action: { 104 | type: "modifyHeaders", 105 | responseHeaders: REMOVE_HEADERS.map((header) => ({ 106 | operation: "remove", 107 | header, 108 | })), 109 | }, 110 | condition: { 111 | urlFilter: url, 112 | resourceTypes: ["main_frame", "sub_frame"], 113 | }, 114 | })); 115 | const oldRules = await browser.declarativeNetRequest.getDynamicRules(); 116 | const oldRuleIds = oldRules.map((rule) => rule.id); 117 | await browser.declarativeNetRequest.updateDynamicRules({ 118 | removeRuleIds: oldRuleIds, 119 | addRules: newRules, 120 | }); 121 | } catch (err) { 122 | kissLog(err, "update csp rules"); 123 | } 124 | } 125 | 126 | /** 127 | * 插件安装 128 | */ 129 | browser.runtime.onInstalled.addListener(() => { 130 | tryInitDefaultData(); 131 | 132 | // 右键菜单 133 | addContextMenus(); 134 | 135 | // 禁用CSP 136 | updateCspRules(); 137 | }); 138 | 139 | /** 140 | * 浏览器启动 141 | */ 142 | browser.runtime.onStartup.addListener(async () => { 143 | // 同步数据 144 | await trySyncSettingAndRules(); 145 | 146 | const { clearCache, contextMenuType, subrulesList, csplist } = 147 | await getSettingWithDefault(); 148 | 149 | // 清除缓存 150 | if (clearCache) { 151 | tryClearCaches(); 152 | } 153 | 154 | // 右键菜单 155 | // firefox重启后菜单会消失,故重复添加 156 | addContextMenus(contextMenuType); 157 | 158 | // 禁用CSP 159 | updateCspRules(csplist); 160 | 161 | // 同步订阅规则 162 | trySyncAllSubRules({ subrulesList }); 163 | }); 164 | 165 | /** 166 | * 监听消息 167 | */ 168 | browser.runtime.onMessage.addListener(async ({ action, args }) => { 169 | switch (action) { 170 | case MSG_FETCH: 171 | return await fetchHandle(args); 172 | case MSG_GET_HTTPCACHE: 173 | const { input, init } = args; 174 | return await getHttpCache(input, init); 175 | case MSG_OPEN_OPTIONS: 176 | return await browser.runtime.openOptionsPage(); 177 | case MSG_SAVE_RULE: 178 | return await saveRule(args); 179 | case MSG_INJECT_JS: 180 | return await browser.scripting.executeScript({ 181 | target: { tabId: await getCurTabId(), allFrames: true }, 182 | func: injectInlineJs, 183 | args: [args], 184 | world: "MAIN", 185 | }); 186 | case MSG_INJECT_CSS: 187 | return await browser.scripting.executeScript({ 188 | target: { tabId: await getCurTabId(), allFrames: true }, 189 | func: injectInternalCss, 190 | args: [args], 191 | world: "MAIN", 192 | }); 193 | case MSG_UPDATE_CSP: 194 | return await updateCspRules(args); 195 | case MSG_CONTEXT_MENUS: 196 | return await addContextMenus(args); 197 | case MSG_COMMAND_SHORTCUTS: 198 | return await browser.commands.getAll(); 199 | default: 200 | throw new Error(`message action is unavailable: ${action}`); 201 | } 202 | }); 203 | 204 | /** 205 | * 监听快捷键 206 | */ 207 | browser.commands.onCommand.addListener((command) => { 208 | // console.log(`Command: ${command}`); 209 | switch (command) { 210 | case CMD_TOGGLE_TRANSLATE: 211 | sendTabMsg(MSG_TRANS_TOGGLE); 212 | break; 213 | case CMD_OPEN_TRANBOX: 214 | sendTabMsg(MSG_OPEN_TRANBOX); 215 | break; 216 | case CMD_TOGGLE_STYLE: 217 | sendTabMsg(MSG_TRANS_TOGGLE_STYLE); 218 | break; 219 | case CMD_OPEN_OPTIONS: 220 | browser.runtime.openOptionsPage(); 221 | break; 222 | default: 223 | } 224 | }); 225 | 226 | /** 227 | * 监听右键菜单 228 | */ 229 | browser.contextMenus.onClicked.addListener(({ menuItemId }) => { 230 | switch (menuItemId) { 231 | case CMD_TOGGLE_TRANSLATE: 232 | sendTabMsg(MSG_TRANS_TOGGLE); 233 | break; 234 | case CMD_TOGGLE_STYLE: 235 | sendTabMsg(MSG_TRANS_TOGGLE_STYLE); 236 | break; 237 | case CMD_OPEN_TRANBOX: 238 | sendTabMsg(MSG_OPEN_TRANBOX); 239 | break; 240 | case CMD_OPEN_OPTIONS: 241 | browser.runtime.openOptionsPage(); 242 | break; 243 | default: 244 | } 245 | }); 246 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import Action from "./views/Action"; 4 | import createCache from "@emotion/cache"; 5 | import { CacheProvider } from "@emotion/react"; 6 | import { 7 | MSG_TRANS_TOGGLE, 8 | MSG_TRANS_TOGGLE_STYLE, 9 | MSG_TRANS_GETRULE, 10 | MSG_TRANS_PUTRULE, 11 | MSG_OPEN_TRANBOX, 12 | APP_LCNAME, 13 | DEFAULT_TRANBOX_SETTING, 14 | } from "./config"; 15 | import { getFabWithDefault, getSettingWithDefault } from "./libs/storage"; 16 | import { Translator } from "./libs/translator"; 17 | import { isIframe, sendIframeMsg } from "./libs/iframe"; 18 | import Slection from "./views/Selection"; 19 | import { touchTapListener } from "./libs/touch"; 20 | import { debounce, genEventName } from "./libs/utils"; 21 | import { handlePing, injectScript } from "./libs/gm"; 22 | import { browser } from "./libs/browser"; 23 | import { matchRule } from "./libs/rules"; 24 | import { trySyncAllSubRules } from "./libs/subRules"; 25 | import { isInBlacklist } from "./libs/blacklist"; 26 | import inputTranslate from "./libs/inputTranslate"; 27 | 28 | /** 29 | * 油猴脚本设置页面 30 | */ 31 | function runSettingPage() { 32 | if (GM?.info?.script?.grant?.includes("unsafeWindow")) { 33 | unsafeWindow.GM = GM; 34 | unsafeWindow.APP_INFO = { 35 | name: process.env.REACT_APP_NAME, 36 | version: process.env.REACT_APP_VERSION, 37 | }; 38 | } else { 39 | const ping = genEventName(); 40 | window.addEventListener(ping, handlePing); 41 | // window.eval(`(${injectScript})("${ping}")`); // eslint-disable-line 42 | const script = document.createElement("script"); 43 | script.textContent = `(${injectScript})("${ping}")`; 44 | document.head.append(script); 45 | } 46 | } 47 | 48 | /** 49 | * 插件监听后端事件 50 | * @param {*} translator 51 | */ 52 | function runtimeListener(translator) { 53 | browser?.runtime.onMessage.addListener(async ({ action, args }) => { 54 | switch (action) { 55 | case MSG_TRANS_TOGGLE: 56 | translator.toggle(); 57 | sendIframeMsg(MSG_TRANS_TOGGLE); 58 | break; 59 | case MSG_TRANS_TOGGLE_STYLE: 60 | translator.toggleStyle(); 61 | sendIframeMsg(MSG_TRANS_TOGGLE_STYLE); 62 | break; 63 | case MSG_TRANS_GETRULE: 64 | break; 65 | case MSG_TRANS_PUTRULE: 66 | translator.updateRule(args); 67 | sendIframeMsg(MSG_TRANS_PUTRULE, args); 68 | break; 69 | case MSG_OPEN_TRANBOX: 70 | window.dispatchEvent(new CustomEvent(MSG_OPEN_TRANBOX)); 71 | break; 72 | default: 73 | return { error: `message action is unavailable: ${action}` }; 74 | } 75 | return { data: translator.rule }; 76 | }); 77 | } 78 | 79 | /** 80 | * iframe 页面执行 81 | * @param {*} translator 82 | */ 83 | function runIframe(translator) { 84 | window.addEventListener("message", (e) => { 85 | const { action, args } = e.data || {}; 86 | switch (action) { 87 | case MSG_TRANS_TOGGLE: 88 | translator?.toggle(); 89 | break; 90 | case MSG_TRANS_TOGGLE_STYLE: 91 | translator?.toggleStyle(); 92 | break; 93 | case MSG_TRANS_PUTRULE: 94 | translator.updateRule(args || {}); 95 | break; 96 | default: 97 | } 98 | }); 99 | } 100 | 101 | /** 102 | * 悬浮按钮 103 | * @param {*} translator 104 | * @returns 105 | */ 106 | async function showFab(translator) { 107 | const fab = await getFabWithDefault(); 108 | const $action = document.createElement("div"); 109 | $action.setAttribute("id", APP_LCNAME); 110 | $action.style.fontSize = "0"; 111 | $action.style.width = "0"; 112 | $action.style.height = "0"; 113 | document.body.parentElement.appendChild($action); 114 | const shadowContainer = $action.attachShadow({ mode: "closed" }); 115 | const emotionRoot = document.createElement("style"); 116 | const shadowRootElement = document.createElement("div"); 117 | shadowContainer.appendChild(emotionRoot); 118 | shadowContainer.appendChild(shadowRootElement); 119 | const cache = createCache({ 120 | key: APP_LCNAME, 121 | prepend: true, 122 | container: emotionRoot, 123 | }); 124 | ReactDOM.createRoot(shadowRootElement).render( 125 | 126 | 127 | 128 | 129 | 130 | ); 131 | } 132 | 133 | /** 134 | * 划词翻译 135 | * @param {*} param0 136 | * @returns 137 | */ 138 | function showTransbox({ 139 | contextMenuType, 140 | tranboxSetting = DEFAULT_TRANBOX_SETTING, 141 | transApis, 142 | darkMode, 143 | uiLang, 144 | langDetector, 145 | }) { 146 | if (!tranboxSetting?.transOpen) { 147 | return; 148 | } 149 | 150 | const $tranbox = document.createElement("div"); 151 | $tranbox.setAttribute("id", "kiss-transbox"); 152 | $tranbox.style.fontSize = "0"; 153 | $tranbox.style.width = "0"; 154 | $tranbox.style.height = "0"; 155 | document.body.parentElement.appendChild($tranbox); 156 | const shadowContainer = $tranbox.attachShadow({ mode: "closed" }); 157 | const emotionRoot = document.createElement("style"); 158 | const shadowRootElement = document.createElement("div"); 159 | shadowRootElement.classList.add(`KT-transbox`); 160 | shadowRootElement.classList.add(`KT-transbox_${darkMode ? "dark" : "light"}`); 161 | shadowContainer.appendChild(emotionRoot); 162 | shadowContainer.appendChild(shadowRootElement); 163 | const cache = createCache({ 164 | key: "kiss-transbox", 165 | prepend: true, 166 | container: emotionRoot, 167 | }); 168 | ReactDOM.createRoot(shadowRootElement).render( 169 | 170 | 171 | 178 | 179 | 180 | ); 181 | } 182 | 183 | /** 184 | * 显示错误信息到页面顶部 185 | * @param {*} message 186 | */ 187 | function showErr(message) { 188 | const $err = document.createElement("div"); 189 | $err.innerText = `KISS-Translator: ${message}`; 190 | $err.style.cssText = "background:red; color:#fff;"; 191 | document.body.prepend($err); 192 | } 193 | 194 | /** 195 | * 监听触屏操作 196 | * @param {*} translator 197 | * @returns 198 | */ 199 | function touchOperation(translator) { 200 | const { touchTranslate = 2 } = translator.setting; 201 | if (touchTranslate === 0) { 202 | return; 203 | } 204 | 205 | const handleTap = debounce(() => { 206 | translator.toggle(); 207 | sendIframeMsg(MSG_TRANS_TOGGLE); 208 | }); 209 | touchTapListener(handleTap, touchTranslate); 210 | } 211 | 212 | /** 213 | * 入口函数 214 | */ 215 | export async function run(isUserscript = false) { 216 | try { 217 | const href = document.location.href; 218 | 219 | // 设置页面 220 | if ( 221 | isUserscript && 222 | (href.includes(process.env.REACT_APP_OPTIONSPAGE_DEV) || 223 | href.includes(process.env.REACT_APP_OPTIONSPAGE)) 224 | ) { 225 | runSettingPage(); 226 | return; 227 | } 228 | 229 | // 读取设置信息 230 | const setting = await getSettingWithDefault(); 231 | 232 | // 黑名单 233 | if (isInBlacklist(href, setting)) { 234 | return; 235 | } 236 | 237 | // 翻译网页 238 | const rule = await matchRule(href, setting); 239 | const translator = new Translator(rule, setting); 240 | 241 | // 适配iframe 242 | if (isIframe) { 243 | runIframe(translator); 244 | return; 245 | } 246 | 247 | // 监听消息 248 | !isUserscript && runtimeListener(translator); 249 | 250 | // 输入框翻译 251 | inputTranslate(setting); 252 | 253 | // 划词翻译 254 | showTransbox(setting); 255 | 256 | // 浮球按钮 257 | await showFab(translator); 258 | 259 | // 触屏操作 260 | touchOperation(translator); 261 | 262 | // 同步订阅规则 263 | isUserscript && (await trySyncAllSubRules(setting)); 264 | } catch (err) { 265 | console.error("[KISS-Translator]", err); 266 | showErr(err.message); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/config/app.js: -------------------------------------------------------------------------------- 1 | export const APP_NAME = process.env.REACT_APP_NAME.trim() 2 | .split(/\s+/) 3 | .join("-"); 4 | export const APP_LCNAME = APP_NAME.toLowerCase(); 5 | -------------------------------------------------------------------------------- /src/content.js: -------------------------------------------------------------------------------- 1 | import { run } from "./common"; 2 | 3 | run(); 4 | -------------------------------------------------------------------------------- /src/hooks/Alert.js: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, forwardRef } from "react"; 2 | import Snackbar from "@mui/material/Snackbar"; 3 | import MuiAlert from "@mui/material/Alert"; 4 | 5 | const Alert = forwardRef(function Alert(props, ref) { 6 | return ; 7 | }); 8 | 9 | const AlertContext = createContext(null); 10 | 11 | /** 12 | * 左下角提示,注入context后,方便全局调用 13 | * @param {*} param0 14 | * @returns 15 | */ 16 | export function AlertProvider({ children }) { 17 | const vertical = "top"; 18 | const horizontal = "center"; 19 | const [open, setOpen] = useState(false); 20 | const [severity, setSeverity] = useState("info"); 21 | const [message, setMessage] = useState(""); 22 | 23 | const showAlert = (msg, type) => { 24 | setOpen(true); 25 | setMessage(msg); 26 | setSeverity(type); 27 | }; 28 | 29 | const handleClose = (_, reason) => { 30 | if (reason === "clickaway") { 31 | return; 32 | } 33 | setOpen(false); 34 | }; 35 | 36 | const error = (msg) => showAlert(msg, "error"); 37 | const warning = (msg) => showAlert(msg, "warning"); 38 | const info = (msg) => showAlert(msg, "info"); 39 | const success = (msg) => showAlert(msg, "success"); 40 | 41 | return ( 42 | 43 | {children} 44 | 50 | 51 | {message} 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export function useAlert() { 59 | return useContext(AlertContext); 60 | } 61 | -------------------------------------------------------------------------------- /src/hooks/Api.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { DEFAULT_TRANS_APIS } from "../config"; 3 | import { useSetting } from "./Setting"; 4 | 5 | export function useApi(translator) { 6 | const { setting, updateSetting } = useSetting(); 7 | const transApis = setting?.transApis || DEFAULT_TRANS_APIS; 8 | 9 | const updateApi = useCallback( 10 | async (obj) => { 11 | const api = { 12 | ...DEFAULT_TRANS_APIS[translator], 13 | ...(transApis[translator] || {}), 14 | }; 15 | Object.assign(transApis, { [translator]: { ...api, ...obj } }); 16 | await updateSetting({ transApis }); 17 | }, 18 | [translator, transApis, updateSetting] 19 | ); 20 | 21 | const resetApi = useCallback(async () => { 22 | Object.assign(transApis, { [translator]: DEFAULT_TRANS_APIS[translator] }); 23 | await updateSetting({ transApis }); 24 | }, [translator, transApis, updateSetting]); 25 | 26 | return { 27 | api: { 28 | ...DEFAULT_TRANS_APIS[translator], 29 | ...(transApis[translator] || {}), 30 | }, 31 | updateApi, 32 | resetApi, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/Audio.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | import { apiBaiduTTS } from "../apis"; 3 | import { kissLog } from "../libs/log"; 4 | 5 | /** 6 | * 声音播放hook 7 | * @param {*} src 8 | * @returns 9 | */ 10 | export function useAudio(src) { 11 | const audioRef = useRef(null); 12 | const [error, setError] = useState(null); 13 | const [ready, setReady] = useState(false); 14 | const [playing, setPlaying] = useState(false); 15 | 16 | const onPlay = useCallback(() => { 17 | audioRef.current?.play(); 18 | }, []); 19 | 20 | useEffect(() => { 21 | if (!src) { 22 | return; 23 | } 24 | const audio = new Audio(src); 25 | audio.addEventListener("error", (err) => setError(err)); 26 | audio.addEventListener("canplaythrough", () => setReady(true)); 27 | audio.addEventListener("play", () => setPlaying(true)); 28 | audio.addEventListener("ended", () => setPlaying(false)); 29 | audioRef.current = audio; 30 | }, [src]); 31 | 32 | return { 33 | error, 34 | ready, 35 | playing, 36 | onPlay, 37 | }; 38 | } 39 | 40 | /** 41 | * 获取语音hook 42 | * @param {*} text 43 | * @param {*} lan 44 | * @param {*} spd 45 | * @returns 46 | */ 47 | export function useTextAudio(text, lan = "uk", spd = 3) { 48 | const [src, setSrc] = useState(""); 49 | 50 | useEffect(() => { 51 | (async () => { 52 | try { 53 | setSrc(await apiBaiduTTS(text, lan, spd)); 54 | } catch (err) { 55 | kissLog(err, "baidu tts"); 56 | } 57 | })(); 58 | }, [text, lan, spd]); 59 | 60 | return useAudio(src); 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/ColorMode.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useSetting } from "./Setting"; 3 | 4 | /** 5 | * 深色模式hook 6 | * @returns 7 | */ 8 | export function useDarkMode() { 9 | const { 10 | setting: { darkMode }, 11 | updateSetting, 12 | } = useSetting(); 13 | 14 | const toggleDarkMode = useCallback(async () => { 15 | await updateSetting({ darkMode: !darkMode }); 16 | }, [darkMode, updateSetting]); 17 | 18 | return { darkMode, toggleDarkMode }; 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/Fab.js: -------------------------------------------------------------------------------- 1 | import { STOKEY_FAB } from "../config"; 2 | import { useStorage } from "./Storage"; 3 | 4 | /** 5 | * fab hook 6 | * @returns 7 | */ 8 | export function useFab() { 9 | const { data, update } = useStorage(STOKEY_FAB); 10 | return { fab: data, updateFab: update }; 11 | } 12 | -------------------------------------------------------------------------------- /src/hooks/FavWords.js: -------------------------------------------------------------------------------- 1 | import { KV_WORDS_KEY } from "../config"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { trySyncWords } from "../libs/sync"; 4 | import { getWordsWithDefault, setWords } from "../libs/storage"; 5 | import { useSyncMeta } from "./Sync"; 6 | import { kissLog } from "../libs/log"; 7 | 8 | export function useFavWords() { 9 | const [loading, setLoading] = useState(false); 10 | const [favWords, setFavWords] = useState({}); 11 | const { updateSyncMeta } = useSyncMeta(); 12 | 13 | const toggleFav = useCallback( 14 | async (word) => { 15 | const favs = { ...favWords }; 16 | if (favs[word]) { 17 | delete favs[word]; 18 | } else { 19 | favs[word] = { createdAt: Date.now() }; 20 | } 21 | await setWords(favs); 22 | await updateSyncMeta(KV_WORDS_KEY); 23 | await trySyncWords(); 24 | setFavWords(favs); 25 | }, 26 | [updateSyncMeta, favWords] 27 | ); 28 | 29 | const mergeWords = useCallback( 30 | async (newWords) => { 31 | const favs = { ...favWords }; 32 | newWords.forEach((word) => { 33 | if (!favs[word]) { 34 | favs[word] = { createdAt: Date.now() }; 35 | } 36 | }); 37 | await setWords(favs); 38 | await updateSyncMeta(KV_WORDS_KEY); 39 | await trySyncWords(); 40 | setFavWords(favs); 41 | }, 42 | [updateSyncMeta, favWords] 43 | ); 44 | 45 | const clearWords = useCallback(async () => { 46 | await setWords({}); 47 | await updateSyncMeta(KV_WORDS_KEY); 48 | await trySyncWords(); 49 | setFavWords({}); 50 | }, [updateSyncMeta]); 51 | 52 | useEffect(() => { 53 | (async () => { 54 | try { 55 | setLoading(true); 56 | await trySyncWords(); 57 | const favWords = await getWordsWithDefault(); 58 | setFavWords(favWords); 59 | } catch (err) { 60 | kissLog(err, "query fav"); 61 | } finally { 62 | setLoading(false); 63 | } 64 | })(); 65 | }, []); 66 | 67 | return { loading, favWords, toggleFav, mergeWords, clearWords }; 68 | } 69 | -------------------------------------------------------------------------------- /src/hooks/Fetch.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | /** 4 | * fetch data hook 5 | * @returns 6 | */ 7 | export const useFetch = (url) => { 8 | const [data, setData] = useState(null); 9 | const [loading, setLoading] = useState(false); 10 | const [error, setError] = useState(null); 11 | 12 | useEffect(() => { 13 | if (!url) { 14 | return; 15 | } 16 | 17 | (async () => { 18 | setLoading(true); 19 | try { 20 | const res = await fetch(url); 21 | if (!res.ok) { 22 | throw new Error(`[${res.status}] ${res.statusText}`); 23 | } 24 | let data; 25 | if (res.headers.get("Content-Type")?.includes("json")) { 26 | data = await res.json(); 27 | } else { 28 | data = await res.text(); 29 | } 30 | setData(data); 31 | } catch (err) { 32 | setError(err); 33 | } finally { 34 | setLoading(false); 35 | } 36 | })(); 37 | }, [url]); 38 | 39 | return [data, loading, error]; 40 | }; 41 | -------------------------------------------------------------------------------- /src/hooks/I18n.js: -------------------------------------------------------------------------------- 1 | import { useSetting } from "./Setting"; 2 | import { I18N, URL_RAW_PREFIX } from "../config"; 3 | import { useFetch } from "./Fetch"; 4 | 5 | export const getI18n = (uiLang, key, defaultText = "") => { 6 | return I18N?.[key]?.[uiLang] ?? defaultText; 7 | }; 8 | 9 | export const useLangMap = (uiLang) => { 10 | return (key, defaultText = "") => getI18n(uiLang, key, defaultText); 11 | }; 12 | 13 | /** 14 | * 多语言 hook 15 | * @returns 16 | */ 17 | export const useI18n = () => { 18 | const { 19 | setting: { uiLang }, 20 | } = useSetting(); 21 | return useLangMap(uiLang); 22 | }; 23 | 24 | export const useI18nMd = (key) => { 25 | const i18n = useI18n(); 26 | const fileName = i18n(key); 27 | const url = fileName ? `${URL_RAW_PREFIX}/${fileName}` : ""; 28 | return useFetch(url); 29 | }; 30 | -------------------------------------------------------------------------------- /src/hooks/InputRule.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { DEFAULT_INPUT_RULE } from "../config"; 3 | import { useSetting } from "./Setting"; 4 | 5 | export function useInputRule() { 6 | const { setting, updateSetting } = useSetting(); 7 | const inputRule = setting?.inputRule || DEFAULT_INPUT_RULE; 8 | 9 | const updateInputRule = useCallback( 10 | async (obj) => { 11 | Object.assign(inputRule, obj); 12 | await updateSetting({ inputRule }); 13 | }, 14 | [inputRule, updateSetting] 15 | ); 16 | 17 | return { inputRule, updateInputRule }; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/Rules.js: -------------------------------------------------------------------------------- 1 | import { STOKEY_RULES, DEFAULT_RULES, KV_RULES_KEY } from "../config"; 2 | import { useStorage } from "./Storage"; 3 | import { trySyncRules } from "../libs/sync"; 4 | import { checkRules } from "../libs/rules"; 5 | import { useCallback } from "react"; 6 | import { useSyncMeta } from "./Sync"; 7 | 8 | /** 9 | * 规则 hook 10 | * @returns 11 | */ 12 | export function useRules() { 13 | const { data: list, save } = useStorage(STOKEY_RULES, DEFAULT_RULES); 14 | const { updateSyncMeta } = useSyncMeta(); 15 | 16 | const updateRules = useCallback( 17 | async (rules) => { 18 | await save(rules); 19 | await updateSyncMeta(KV_RULES_KEY); 20 | trySyncRules(); 21 | }, 22 | [save, updateSyncMeta] 23 | ); 24 | 25 | const add = useCallback( 26 | async (rule) => { 27 | const rules = [...list]; 28 | if (rule.pattern === "*") { 29 | return; 30 | } 31 | if (rules.map((item) => item.pattern).includes(rule.pattern)) { 32 | return; 33 | } 34 | rules.unshift(rule); 35 | await updateRules(rules); 36 | }, 37 | [list, updateRules] 38 | ); 39 | 40 | const del = useCallback( 41 | async (pattern) => { 42 | let rules = [...list]; 43 | if (pattern === "*") { 44 | return; 45 | } 46 | rules = rules.filter((item) => item.pattern !== pattern); 47 | await updateRules(rules); 48 | }, 49 | [list, updateRules] 50 | ); 51 | 52 | const clear = useCallback(async () => { 53 | let rules = [...list]; 54 | rules = rules.filter((item) => item.pattern === "*"); 55 | await updateRules(rules); 56 | }, [list, updateRules]); 57 | 58 | const put = useCallback( 59 | async (pattern, obj) => { 60 | const rules = [...list]; 61 | if (pattern === "*") { 62 | obj.pattern = "*"; 63 | } 64 | const rule = rules.find((r) => r.pattern === pattern); 65 | rule && Object.assign(rule, obj); 66 | await updateRules(rules); 67 | }, 68 | [list, updateRules] 69 | ); 70 | 71 | const merge = useCallback( 72 | async (newRules) => { 73 | const rules = [...list]; 74 | newRules = checkRules(newRules); 75 | newRules.forEach((newRule) => { 76 | const rule = rules.find( 77 | (oldRule) => oldRule.pattern === newRule.pattern 78 | ); 79 | if (rule) { 80 | Object.assign(rule, newRule); 81 | } else { 82 | rules.unshift(newRule); 83 | } 84 | }); 85 | await updateRules(rules); 86 | }, 87 | [list, updateRules] 88 | ); 89 | 90 | return { list, add, del, clear, put, merge }; 91 | } 92 | -------------------------------------------------------------------------------- /src/hooks/Setting.js: -------------------------------------------------------------------------------- 1 | import { STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY } from "../config"; 2 | import { useStorage } from "./Storage"; 3 | import { trySyncSetting } from "../libs/sync"; 4 | import { createContext, useCallback, useContext, useMemo } from "react"; 5 | import { debounce } from "../libs/utils"; 6 | import { useSyncMeta } from "./Sync"; 7 | 8 | const SettingContext = createContext({ 9 | setting: null, 10 | updateSetting: async () => {}, 11 | reloadSetting: async () => {}, 12 | }); 13 | 14 | export function SettingProvider({ children }) { 15 | const { data, update, reload } = useStorage(STOKEY_SETTING, DEFAULT_SETTING); 16 | const { updateSyncMeta } = useSyncMeta(); 17 | 18 | const syncSetting = useMemo( 19 | () => 20 | debounce(() => { 21 | trySyncSetting(); 22 | }, [2000]), 23 | [] 24 | ); 25 | 26 | const updateSetting = useCallback( 27 | async (obj) => { 28 | await update(obj); 29 | await updateSyncMeta(KV_SETTING_KEY); 30 | syncSetting(); 31 | }, 32 | [update, syncSetting, updateSyncMeta] 33 | ); 34 | 35 | if (!data) { 36 | return; 37 | } 38 | 39 | return ( 40 | 47 | {children} 48 | 49 | ); 50 | } 51 | 52 | /** 53 | * 设置 hook 54 | * @returns 55 | */ 56 | export function useSetting() { 57 | return useContext(SettingContext); 58 | } 59 | -------------------------------------------------------------------------------- /src/hooks/Shortcut.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { DEFAULT_SHORTCUTS } from "../config"; 3 | import { useSetting } from "./Setting"; 4 | 5 | export function useShortcut(action) { 6 | const { setting, updateSetting } = useSetting(); 7 | const shortcuts = setting?.shortcuts || DEFAULT_SHORTCUTS; 8 | const shortcut = shortcuts[action] || []; 9 | 10 | const setShortcut = useCallback( 11 | async (val) => { 12 | Object.assign(shortcuts, { [action]: val }); 13 | await updateSetting({ shortcuts }); 14 | }, 15 | [action, shortcuts, updateSetting] 16 | ); 17 | 18 | return { shortcut, setShortcut }; 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/Storage.js: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { storage } from "../libs/storage"; 3 | import { kissLog } from "../libs/log"; 4 | 5 | /** 6 | * 7 | * @param {*} key 8 | * @param {*} defaultVal 需为调用hook外的常量 9 | * @returns 10 | */ 11 | export function useStorage(key, defaultVal) { 12 | const [loading, setLoading] = useState(false); 13 | const [data, setData] = useState(null); 14 | 15 | const save = useCallback( 16 | async (val) => { 17 | setData(val); 18 | await storage.setObj(key, val); 19 | }, 20 | [key] 21 | ); 22 | 23 | const update = useCallback( 24 | async (obj) => { 25 | setData((pre = {}) => ({ ...pre, ...obj })); 26 | await storage.putObj(key, obj); 27 | }, 28 | [key] 29 | ); 30 | 31 | const remove = useCallback(async () => { 32 | setData(null); 33 | await storage.del(key); 34 | }, [key]); 35 | 36 | const reload = useCallback(async () => { 37 | try { 38 | setLoading(true); 39 | const val = await storage.getObj(key); 40 | if (val) { 41 | setData(val); 42 | } 43 | } catch (err) { 44 | kissLog(err, "storage reload"); 45 | } finally { 46 | setLoading(false); 47 | } 48 | }, [key]); 49 | 50 | useEffect(() => { 51 | (async () => { 52 | try { 53 | setLoading(true); 54 | const val = await storage.getObj(key); 55 | if (val) { 56 | setData(val); 57 | } else if (defaultVal) { 58 | setData(defaultVal); 59 | await storage.setObj(key, defaultVal); 60 | } 61 | } catch (err) { 62 | kissLog(err, "storage load"); 63 | } finally { 64 | setLoading(false); 65 | } 66 | })(); 67 | }, [key, defaultVal]); 68 | 69 | return { data, save, update, remove, reload, loading }; 70 | } 71 | -------------------------------------------------------------------------------- /src/hooks/SubRules.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SUBRULES_LIST, DEFAULT_OW_RULE } from "../config"; 2 | import { useSetting } from "./Setting"; 3 | import { useCallback, useEffect, useMemo, useState } from "react"; 4 | import { loadOrFetchSubRules } from "../libs/subRules"; 5 | import { delSubRules } from "../libs/storage"; 6 | import { kissLog } from "../libs/log"; 7 | 8 | /** 9 | * 订阅规则 10 | * @returns 11 | */ 12 | export function useSubRules() { 13 | const [loading, setLoading] = useState(false); 14 | const [selectedRules, setSelectedRules] = useState([]); 15 | const { setting, updateSetting } = useSetting(); 16 | const list = setting?.subrulesList || DEFAULT_SUBRULES_LIST; 17 | 18 | const selectedSub = useMemo(() => list.find((item) => item.selected), [list]); 19 | const selectedUrl = selectedSub.url; 20 | 21 | const selectSub = useCallback( 22 | async (url) => { 23 | const subrulesList = [...list]; 24 | subrulesList.forEach((item) => { 25 | if (item.url === url) { 26 | item.selected = true; 27 | } else { 28 | item.selected = false; 29 | } 30 | }); 31 | await updateSetting({ subrulesList }); 32 | }, 33 | [list, updateSetting] 34 | ); 35 | 36 | const updateSub = useCallback( 37 | async (url, obj) => { 38 | const subrulesList = [...list]; 39 | subrulesList.forEach((item) => { 40 | if (item.url === url) { 41 | Object.assign(item, obj); 42 | } 43 | }); 44 | await updateSetting({ subrulesList }); 45 | }, 46 | [list, updateSetting] 47 | ); 48 | 49 | const addSub = useCallback( 50 | async (url) => { 51 | const subrulesList = [...list]; 52 | subrulesList.push({ url, selected: false }); 53 | await updateSetting({ subrulesList }); 54 | }, 55 | [list, updateSetting] 56 | ); 57 | 58 | const delSub = useCallback( 59 | async (url) => { 60 | let subrulesList = [...list]; 61 | subrulesList = subrulesList.filter((item) => item.url !== url); 62 | await updateSetting({ subrulesList }); 63 | await delSubRules(url); 64 | }, 65 | [list, updateSetting] 66 | ); 67 | 68 | useEffect(() => { 69 | (async () => { 70 | if (selectedUrl) { 71 | try { 72 | setLoading(true); 73 | const rules = await loadOrFetchSubRules(selectedUrl); 74 | setSelectedRules(rules); 75 | } catch (err) { 76 | kissLog(err, "loadOrFetchSubRules"); 77 | } finally { 78 | setLoading(false); 79 | } 80 | } 81 | })(); 82 | }, [selectedUrl]); 83 | 84 | return { 85 | subList: list, 86 | selectSub, 87 | updateSub, 88 | addSub, 89 | delSub, 90 | selectedSub, 91 | selectedUrl, 92 | selectedRules, 93 | setSelectedRules, 94 | loading, 95 | }; 96 | } 97 | 98 | /** 99 | * 覆写订阅规则 100 | * @returns 101 | */ 102 | export function useOwSubRule() { 103 | const { setting, updateSetting } = useSetting(); 104 | const { owSubrule = DEFAULT_OW_RULE } = setting; 105 | 106 | const updateOwSubrule = useCallback( 107 | async (obj) => { 108 | await updateSetting({ owSubrule: { ...owSubrule, ...obj } }); 109 | }, 110 | [owSubrule, updateSetting] 111 | ); 112 | 113 | return { owSubrule, updateOwSubrule }; 114 | } 115 | -------------------------------------------------------------------------------- /src/hooks/Sync.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { STOKEY_SYNC, DEFAULT_SYNC } from "../config"; 3 | import { useStorage } from "./Storage"; 4 | 5 | /** 6 | * sync hook 7 | * @returns 8 | */ 9 | export function useSync() { 10 | const { data, update, reload } = useStorage(STOKEY_SYNC, DEFAULT_SYNC); 11 | return { sync: data, updateSync: update, reloadSync: reload }; 12 | } 13 | 14 | /** 15 | * update syncmeta hook 16 | * @returns 17 | */ 18 | export function useSyncMeta() { 19 | const { sync, updateSync } = useSync(); 20 | const updateSyncMeta = useCallback( 21 | async (key) => { 22 | const syncMeta = sync?.syncMeta || {}; 23 | syncMeta[key] = { ...(syncMeta[key] || {}), updateAt: Date.now() }; 24 | await updateSync({ syncMeta }); 25 | }, 26 | [sync?.syncMeta, updateSync] 27 | ); 28 | return { updateSyncMeta }; 29 | } 30 | 31 | /** 32 | * caches sync hook 33 | * @param {*} url 34 | * @returns 35 | */ 36 | export function useSyncCaches() { 37 | const { sync, updateSync, reloadSync } = useSync(); 38 | 39 | const updateDataCache = useCallback( 40 | async (url) => { 41 | const dataCaches = sync?.dataCaches || {}; 42 | dataCaches[url] = Date.now(); 43 | await updateSync({ dataCaches }); 44 | }, 45 | [sync, updateSync] 46 | ); 47 | 48 | const deleteDataCache = useCallback( 49 | async (url) => { 50 | const dataCaches = sync?.dataCaches || {}; 51 | delete dataCaches[url]; 52 | await updateSync({ dataCaches }); 53 | }, 54 | [sync, updateSync] 55 | ); 56 | 57 | return { 58 | dataCaches: sync?.dataCaches || {}, 59 | updateDataCache, 60 | deleteDataCache, 61 | reloadSync, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/hooks/Theme.js: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { ThemeProvider, createTheme } from "@mui/material/styles"; 3 | import { CssBaseline, GlobalStyles } from "@mui/material"; 4 | import { useDarkMode } from "./ColorMode"; 5 | import { THEME_DARK, THEME_LIGHT } from "../config"; 6 | 7 | /** 8 | * mui 主题配置 9 | * @param {*} param0 10 | * @returns 11 | */ 12 | export default function Theme({ children, options, styles }) { 13 | const { darkMode } = useDarkMode(); 14 | const theme = useMemo(() => { 15 | let htmlFontSize = 16; 16 | try { 17 | const s = window.getComputedStyle(document.body.parentNode).fontSize; 18 | const fontSize = parseInt(s.replace("px", "")); 19 | if (fontSize > 0 && fontSize < 1000) { 20 | htmlFontSize = fontSize; 21 | } 22 | } catch (err) { 23 | // 24 | } 25 | 26 | return createTheme({ 27 | palette: { 28 | mode: darkMode ? THEME_DARK : THEME_LIGHT, 29 | }, 30 | typography: { 31 | htmlFontSize, 32 | }, 33 | ...options, 34 | }); 35 | }, [darkMode, options]); 36 | 37 | return ( 38 | 39 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 40 | 41 | 42 | {children} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/hooks/Tranbox.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { DEFAULT_TRANBOX_SETTING } from "../config"; 3 | import { useSetting } from "./Setting"; 4 | 5 | export function useTranbox() { 6 | const { setting, updateSetting } = useSetting(); 7 | const tranboxSetting = setting?.tranboxSetting || DEFAULT_TRANBOX_SETTING; 8 | 9 | const updateTranbox = useCallback( 10 | async (obj) => { 11 | Object.assign(tranboxSetting, obj); 12 | await updateSetting({ tranboxSetting }); 13 | }, 14 | [tranboxSetting, updateSetting] 15 | ); 16 | 17 | return { tranboxSetting, updateTranbox }; 18 | } 19 | -------------------------------------------------------------------------------- /src/hooks/Translate.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useState } from "react"; 3 | import { tryDetectLang } from "../libs"; 4 | import { apiTranslate } from "../apis"; 5 | import { DEFAULT_TRANS_APIS } from "../config"; 6 | import { kissLog } from "../libs/log"; 7 | 8 | /** 9 | * 翻译hook 10 | * @param {*} q 11 | * @param {*} rule 12 | * @param {*} setting 13 | * @returns 14 | */ 15 | export function useTranslate(q, rule, setting) { 16 | const [text, setText] = useState(""); 17 | const [loading, setLoading] = useState(false); 18 | const [sameLang, setSamelang] = useState(false); 19 | 20 | const { translator, fromLang, toLang, detectRemote, skipLangs = [] } = rule; 21 | 22 | useEffect(() => { 23 | (async () => { 24 | try { 25 | setLoading(true); 26 | 27 | if (!q.replace(/\[(\d+)\]/g, "").trim()) { 28 | setText(q); 29 | setSamelang(false); 30 | return; 31 | } 32 | 33 | const deLang = await tryDetectLang( 34 | q, 35 | detectRemote === "true", 36 | setting.langDetector 37 | ); 38 | if (deLang && (toLang.includes(deLang) || skipLangs.includes(deLang))) { 39 | setSamelang(true); 40 | } else { 41 | const [trText, isSame] = await apiTranslate({ 42 | translator, 43 | text: q, 44 | fromLang, 45 | toLang, 46 | apiSetting: { 47 | ...DEFAULT_TRANS_APIS[translator], 48 | ...(setting.transApis[translator] || {}), 49 | }, 50 | }); 51 | setText(trText); 52 | setSamelang(isSame); 53 | } 54 | } catch (err) { 55 | kissLog(err, "translate"); 56 | } finally { 57 | setLoading(false); 58 | } 59 | })(); 60 | }, [q, translator, fromLang, toLang, detectRemote, skipLangs, setting]); 61 | 62 | return { text, sameLang, loading }; 63 | } 64 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import CircularProgress from "@mui/material/CircularProgress"; 4 | import Divider from "@mui/material/Divider"; 5 | import ReactMarkdown from "react-markdown"; 6 | import Paper from "@mui/material/Paper"; 7 | import Stack from "@mui/material/Stack"; 8 | import Button from "@mui/material/Button"; 9 | import Link from "@mui/material/Link"; 10 | import { useFetch } from "./hooks/Fetch"; 11 | import { I18N, URL_RAW_PREFIX } from "./config"; 12 | 13 | function App() { 14 | const [lang, setLang] = useState("zh"); 15 | const [data, loading, error] = useFetch( 16 | `${URL_RAW_PREFIX}/${I18N?.["about_md"]?.[lang]}` 17 | ); 18 | return ( 19 | 20 | 21 | 29 | 30 | 31 | {`KISS Translator v${process.env.REACT_APP_VERSION}`} 34 | 35 | 36 | 37 | Install/Update Userscript for Tampermonkey/Violentmonkey 38 | 39 | 40 | Install/Update Userscript for iOS Safari 41 | 42 | Open Options Page 43 | 44 | 45 | {loading ? ( 46 |
47 | 48 |
49 | ) : ( 50 | 51 | )} 52 |
53 | ); 54 | } 55 | 56 | const root = ReactDOM.createRoot(document.getElementById("root")); 57 | root.render( 58 | 59 | 60 | 61 | ); 62 | -------------------------------------------------------------------------------- /src/libs/auth.js: -------------------------------------------------------------------------------- 1 | import { getMsauth, setMsauth } from "./storage"; 2 | import { URL_MICROSOFT_AUTH } from "../config"; 3 | import { fetchHandle } from "./fetch"; 4 | import { kissLog } from "./log"; 5 | 6 | const parseMSToken = (token) => { 7 | try { 8 | return JSON.parse(atob(token.split(".")[1])).exp; 9 | } catch (err) { 10 | kissLog(err, "parseMSToken"); 11 | } 12 | return 0; 13 | }; 14 | 15 | /** 16 | * 闭包缓存token,减少对storage查询 17 | * @returns 18 | */ 19 | const _msAuth = () => { 20 | let { token, exp } = {}; 21 | 22 | return async () => { 23 | // 查询内存缓存 24 | const now = Date.now(); 25 | if (token && exp * 1000 > now + 1000) { 26 | return [token, exp]; 27 | } 28 | 29 | // 查询storage缓存 30 | const res = await getMsauth(); 31 | token = res?.token; 32 | exp = res?.exp; 33 | if (token && exp * 1000 > now + 1000) { 34 | return [token, exp]; 35 | } 36 | 37 | // 缓存没有或失效,查询接口 38 | token = await fetchHandle({ input: URL_MICROSOFT_AUTH }); 39 | exp = parseMSToken(token); 40 | await setMsauth({ token, exp }); 41 | return [token, exp]; 42 | }; 43 | }; 44 | 45 | export const msAuth = _msAuth(); 46 | -------------------------------------------------------------------------------- /src/libs/blacklist.js: -------------------------------------------------------------------------------- 1 | import { isMatch } from "./utils"; 2 | 3 | /** 4 | * 检查是否在黑名单中 5 | * @param {*} href 6 | * @param {*} param1 7 | * @returns 8 | */ 9 | export const isInBlacklist = (href, { blacklist }) => 10 | blacklist.split(/\n|,/).some((url) => isMatch(href, url.trim())); 11 | -------------------------------------------------------------------------------- /src/libs/browser.js: -------------------------------------------------------------------------------- 1 | // import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config"; 2 | 3 | /** 4 | * 浏览器兼容插件,另可用于判断是插件模式还是网页模式,方便开发 5 | * @returns 6 | */ 7 | function _browser() { 8 | try { 9 | return require("webextension-polyfill"); 10 | } catch (err) { 11 | // kissLog(err, "browser"); 12 | } 13 | } 14 | 15 | export const browser = _browser(); 16 | 17 | export const isBg = () => globalThis?.ContextType === "BACKGROUND"; 18 | -------------------------------------------------------------------------------- /src/libs/client.js: -------------------------------------------------------------------------------- 1 | import { CLIENT_EXTS, CLIENT_USERSCRIPT, CLIENT_WEB } from "../config"; 2 | 3 | export const client = process.env.REACT_APP_CLIENT; 4 | export const isExt = CLIENT_EXTS.includes(client); 5 | export const isGm = client === CLIENT_USERSCRIPT; 6 | export const isWeb = client === CLIENT_WEB; 7 | -------------------------------------------------------------------------------- /src/libs/fetch.js: -------------------------------------------------------------------------------- 1 | import { isExt, isGm } from "./client"; 2 | import { sendBgMsg } from "./msg"; 3 | import { taskPool } from "./pool"; 4 | import { 5 | MSG_FETCH, 6 | MSG_GET_HTTPCACHE, 7 | CACHE_NAME, 8 | DEFAULT_FETCH_INTERVAL, 9 | DEFAULT_FETCH_LIMIT, 10 | } from "../config"; 11 | import { isBg } from "./browser"; 12 | import { genTransReq } from "../apis/trans"; 13 | import { kissLog } from "./log"; 14 | import { blobToBase64 } from "./utils"; 15 | 16 | const TIMEOUT = 5000; 17 | 18 | /** 19 | * 构造缓存 request 20 | * @param {*} input 21 | * @param {*} init 22 | * @returns 23 | */ 24 | const newCacheReq = async (input, init) => { 25 | let request = new Request(input, init); 26 | if (request.method !== "GET") { 27 | const body = await request.text(); 28 | const cacheUrl = new URL(request.url); 29 | cacheUrl.pathname += body; 30 | request = new Request(cacheUrl.toString(), { method: "GET" }); 31 | } 32 | 33 | return request; 34 | }; 35 | 36 | /** 37 | * 油猴脚本的请求封装 38 | * @param {*} input 39 | * @param {*} init 40 | * @returns 41 | */ 42 | export const fetchGM = async (input, { method = "GET", headers, body } = {}) => 43 | new Promise((resolve, reject) => { 44 | GM.xmlHttpRequest({ 45 | method, 46 | url: input, 47 | headers, 48 | data: body, 49 | // withCredentials: true, 50 | timeout: TIMEOUT, 51 | onload: ({ response, responseHeaders, status, statusText }) => { 52 | const headers = {}; 53 | responseHeaders.split("\n").forEach((line) => { 54 | const [name, value] = line.split(":").map((item) => item.trim()); 55 | if (name && value) { 56 | headers[name] = value; 57 | } 58 | }); 59 | resolve({ 60 | body: response, 61 | headers, 62 | status, 63 | statusText, 64 | }); 65 | }, 66 | onerror: reject, 67 | }); 68 | }); 69 | 70 | /** 71 | * 发起请求 72 | * @param {*} param0 73 | * @returns 74 | */ 75 | export const fetchPatcher = async (input, init, transOpts, apiSetting) => { 76 | if (transOpts?.translator) { 77 | [input, init] = await genTransReq(transOpts, apiSetting); 78 | } 79 | 80 | if (!input) { 81 | throw new Error("url is empty"); 82 | } 83 | 84 | if (isGm) { 85 | let info; 86 | if (window.KISS_GM) { 87 | info = await window.KISS_GM.getInfo(); 88 | } else { 89 | info = GM.info; 90 | } 91 | 92 | // Tampermonkey --> .connects 93 | // Violentmonkey --> .connect 94 | const connects = info?.script?.connects || info?.script?.connect || []; 95 | const url = new URL(input); 96 | const isSafe = connects.find((item) => url.hostname.endsWith(item)); 97 | 98 | if (isSafe) { 99 | const { body, headers, status, statusText } = window.KISS_GM 100 | ? await window.KISS_GM.fetch(input, init) 101 | : await fetchGM(input, init); 102 | 103 | return new Response(body, { 104 | headers: new Headers(headers), 105 | status, 106 | statusText, 107 | }); 108 | } 109 | } 110 | 111 | if (AbortSignal?.timeout) { 112 | Object.assign(init, { signal: AbortSignal.timeout(TIMEOUT) }); 113 | } 114 | 115 | return fetch(input, init); 116 | }; 117 | 118 | /** 119 | * 解析 response 120 | * @param {*} res 121 | * @returns 122 | */ 123 | const parseResponse = async (res) => { 124 | if (!res) { 125 | return null; 126 | } 127 | 128 | const contentType = res.headers.get("Content-Type"); 129 | if (contentType?.includes("json")) { 130 | return await res.json(); 131 | } else if (contentType?.includes("audio")) { 132 | const blob = await res.blob(); 133 | return await blobToBase64(blob); 134 | } 135 | return await res.text(); 136 | }; 137 | 138 | /** 139 | * 查询 caches 140 | * @param {*} input 141 | * @param {*} param1 142 | * @returns 143 | */ 144 | export const getHttpCache = async (input, { method, headers, body }) => { 145 | try { 146 | const req = await newCacheReq(input, { method, headers, body }); 147 | const cache = await caches.open(CACHE_NAME); 148 | const res = await cache.match(req); 149 | return parseResponse(res); 150 | } catch (err) { 151 | kissLog(err, "get cache"); 152 | } 153 | return null; 154 | }; 155 | 156 | /** 157 | * 插入 caches 158 | * @param {*} input 159 | * @param {*} param1 160 | * @param {*} res 161 | */ 162 | export const putHttpCache = async (input, { method, headers, body }, res) => { 163 | try { 164 | const req = await newCacheReq(input, { method, headers, body }); 165 | const cache = await caches.open(CACHE_NAME); 166 | await cache.put(req, res); 167 | } catch (err) { 168 | kissLog(err, "put cache"); 169 | } 170 | }; 171 | 172 | /** 173 | * 处理请求 174 | * @param {*} param0 175 | * @returns 176 | */ 177 | export const fetchHandle = async ({ 178 | input, 179 | useCache, 180 | transOpts, 181 | apiSetting, 182 | ...init 183 | }) => { 184 | // 发送请求 185 | const res = await fetchPatcher(input, init, transOpts, apiSetting); 186 | if (!res) { 187 | throw new Error("Unknow error"); 188 | } else if (!res.ok) { 189 | const msg = { 190 | url: res.url, 191 | status: res.status, 192 | }; 193 | if (res.headers.get("Content-Type")?.includes("json")) { 194 | msg.response = await res.json(); 195 | } 196 | throw new Error(JSON.stringify(msg)); 197 | } 198 | 199 | // 插入缓存 200 | if (useCache) { 201 | await putHttpCache(input, init, res.clone()); 202 | } 203 | 204 | return parseResponse(res); 205 | }; 206 | 207 | /** 208 | * fetch 兼容性封装 209 | * @param {*} args 210 | * @returns 211 | */ 212 | export const fetchPolyfill = (args) => { 213 | // 插件 214 | if (isExt && !isBg()) { 215 | return sendBgMsg(MSG_FETCH, args); 216 | } 217 | 218 | // 油猴/网页/BackgroundPage 219 | return fetchHandle(args); 220 | }; 221 | 222 | /** 223 | * getHttpCache 兼容性封装 224 | * @param {*} input 225 | * @param {*} init 226 | * @returns 227 | */ 228 | export const getHttpCachePolyfill = (input, init) => { 229 | // 插件 230 | if (isExt && !isBg()) { 231 | return sendBgMsg(MSG_GET_HTTPCACHE, { input, init }); 232 | } 233 | 234 | // 油猴/网页/BackgroundPage 235 | return getHttpCache(input, init); 236 | }; 237 | 238 | /** 239 | * 请求池实例 240 | */ 241 | export const fetchPool = taskPool( 242 | fetchPolyfill, 243 | null, 244 | DEFAULT_FETCH_INTERVAL, 245 | DEFAULT_FETCH_LIMIT 246 | ); 247 | 248 | /** 249 | * 数据请求 250 | * @param {*} input 251 | * @param {*} param1 252 | * @returns 253 | */ 254 | export const fetchData = async (input, { useCache, usePool, ...args } = {}) => { 255 | if (!input?.trim()) { 256 | throw new Error("URL is empty"); 257 | } 258 | 259 | // 查询缓存 260 | if (useCache) { 261 | const cache = await getHttpCachePolyfill(input, args); 262 | if (cache) { 263 | return cache; 264 | } 265 | } 266 | 267 | // 通过任务池发送请求 268 | if (usePool) { 269 | return fetchPool.push({ input, useCache, ...args }); 270 | } 271 | 272 | // 直接请求 273 | return fetchPolyfill({ input, useCache, ...args }); 274 | }; 275 | 276 | /** 277 | * 更新 fetch pool 参数 278 | * @param {*} interval 279 | * @param {*} limit 280 | */ 281 | export const updateFetchPool = (interval, limit) => { 282 | fetchPool.update(interval, limit); 283 | }; 284 | 285 | /** 286 | * 清空任务池 287 | */ 288 | export const clearFetchPool = () => { 289 | fetchPool.clear(); 290 | }; 291 | -------------------------------------------------------------------------------- /src/libs/gm.js: -------------------------------------------------------------------------------- 1 | import { fetchGM } from "./fetch"; 2 | import { genEventName } from "./utils"; 3 | 4 | const MSG_GM_xmlHttpRequest = "xmlHttpRequest"; 5 | const MSG_GM_setValue = "setValue"; 6 | const MSG_GM_getValue = "getValue"; 7 | const MSG_GM_deleteValue = "deleteValue"; 8 | const MSG_GM_info = "info"; 9 | 10 | /** 11 | * 注入页面的脚本,请求并接受GM接口信息 12 | * @param {*} param0 13 | */ 14 | export const injectScript = (ping) => { 15 | window.APP_INFO = { 16 | name: process.env.REACT_APP_NAME, 17 | version: process.env.REACT_APP_VERSION, 18 | eventName: ping, 19 | }; 20 | }; 21 | 22 | /** 23 | * 适配GM脚本 24 | */ 25 | export const adaptScript = (ping) => { 26 | const promiseGM = (action, args, timeout = 5000) => 27 | new Promise((resolve, reject) => { 28 | const pong = genEventName(); 29 | const handleEvent = (e) => { 30 | window.removeEventListener(pong, handleEvent); 31 | const { data, error } = e.detail; 32 | if (error) { 33 | reject(new Error(error)); 34 | } else { 35 | resolve(data); 36 | } 37 | }; 38 | 39 | window.addEventListener(pong, handleEvent); 40 | window.dispatchEvent( 41 | new CustomEvent(ping, { detail: { action, args, pong } }) 42 | ); 43 | 44 | setTimeout(() => { 45 | window.removeEventListener(pong, handleEvent); 46 | reject(new Error("timeout")); 47 | }, timeout); 48 | }); 49 | 50 | window.KISS_GM = { 51 | fetch: (input, init) => promiseGM(MSG_GM_xmlHttpRequest, { input, init }), 52 | setValue: (key, val) => promiseGM(MSG_GM_setValue, { key, val }), 53 | getValue: (key) => promiseGM(MSG_GM_getValue, { key }), 54 | deleteValue: (key) => promiseGM(MSG_GM_deleteValue, { key }), 55 | getInfo: async () => { 56 | if (!window.GM_info) { 57 | window.GM_info = await promiseGM(MSG_GM_info); 58 | } 59 | return window.GM_info; 60 | }, 61 | }; 62 | }; 63 | 64 | /** 65 | * 监听并回应页面对GM接口的请求 66 | * @param {*} param0 67 | */ 68 | export const handlePing = async (e) => { 69 | const { action, args, pong } = e.detail; 70 | let res; 71 | try { 72 | switch (action) { 73 | case MSG_GM_xmlHttpRequest: 74 | const { input, init } = args; 75 | res = await fetchGM(input, init); 76 | break; 77 | case MSG_GM_setValue: 78 | const { key, val } = args; 79 | await GM.setValue(key, val); 80 | res = val; 81 | break; 82 | case MSG_GM_getValue: 83 | res = await GM.getValue(args.key); 84 | break; 85 | case MSG_GM_deleteValue: 86 | await GM.deleteValue(args.key); 87 | res = "ok"; 88 | break; 89 | case MSG_GM_info: 90 | res = GM.info; 91 | break; 92 | default: 93 | throw new Error(`message action is unavailable: ${action}`); 94 | } 95 | 96 | window.dispatchEvent(new CustomEvent(pong, { detail: { data: res } })); 97 | } catch (err) { 98 | window.dispatchEvent( 99 | new CustomEvent(pong, { detail: { error: err.message } }) 100 | ); 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/libs/iframe.js: -------------------------------------------------------------------------------- 1 | export const isIframe = window.self !== window.top; 2 | 3 | export const sendIframeMsg = (action, args) => { 4 | document.querySelectorAll("iframe").forEach((iframe) => { 5 | iframe.contentWindow.postMessage({ action, args }, "*"); 6 | }); 7 | }; 8 | 9 | export const sendParentMsg = (action, args) => { 10 | window.parent.postMessage({ action, args }, "*"); 11 | }; 12 | -------------------------------------------------------------------------------- /src/libs/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | CACHE_NAME, 3 | OPT_TRANS_GOOGLE, 4 | OPT_TRANS_MICROSOFT, 5 | OPT_TRANS_BAIDU, 6 | OPT_TRANS_TENCENT, 7 | } from "../config"; 8 | import { browser } from "./browser"; 9 | import { 10 | apiGoogleLangdetect, 11 | apiMicrosoftLangdetect, 12 | apiBaiduLangdetect, 13 | apiTencentLangdetect, 14 | } from "../apis"; 15 | import { kissLog } from "./log"; 16 | 17 | const langdetectMap = { 18 | [OPT_TRANS_GOOGLE]: apiGoogleLangdetect, 19 | [OPT_TRANS_MICROSOFT]: apiMicrosoftLangdetect, 20 | [OPT_TRANS_BAIDU]: apiBaiduLangdetect, 21 | [OPT_TRANS_TENCENT]: apiTencentLangdetect, 22 | }; 23 | 24 | /** 25 | * 清除缓存数据 26 | */ 27 | export const tryClearCaches = async () => { 28 | try { 29 | caches.delete(CACHE_NAME); 30 | } catch (err) { 31 | kissLog(err, "clean caches"); 32 | } 33 | }; 34 | 35 | /** 36 | * 语言识别 37 | * @param {*} q 38 | * @returns 39 | */ 40 | export const tryDetectLang = async ( 41 | q, 42 | useRemote = false, 43 | langDetector = OPT_TRANS_MICROSOFT 44 | ) => { 45 | let lang = ""; 46 | 47 | if (useRemote) { 48 | try { 49 | lang = await langdetectMap[langDetector](q); 50 | } catch (err) { 51 | kissLog(err, "detect lang remote"); 52 | } 53 | } 54 | 55 | if (!lang) { 56 | try { 57 | const res = await browser?.i18n?.detectLanguage(q); 58 | lang = res?.languages?.[0]?.language; 59 | } catch (err) { 60 | kissLog(err, "detect lang local"); 61 | } 62 | } 63 | 64 | return lang; 65 | }; 66 | -------------------------------------------------------------------------------- /src/libs/injector.js: -------------------------------------------------------------------------------- 1 | // Function to inject inline JavaScript code 2 | export const injectInlineJs = (code) => { 3 | const el = document.createElement("script"); 4 | el.setAttribute("data-source", "KISS-Calendar injectInlineJs"); 5 | el.setAttribute("type", "text/javascript"); 6 | el.textContent = code; 7 | document.body?.appendChild(el); 8 | }; 9 | 10 | // Function to inject external JavaScript file 11 | export const injectExternalJs = (src) => { 12 | const el = document.createElement("script"); 13 | el.setAttribute("data-source", "KISS-Calendar injectExternalJs"); 14 | el.setAttribute("type", "text/javascript"); 15 | el.setAttribute("src", src); 16 | document.body?.appendChild(el); 17 | }; 18 | 19 | // Function to inject internal CSS code 20 | export const injectInternalCss = (styles) => { 21 | const el = document.createElement("style"); 22 | el.setAttribute("data-source", "KISS-Calendar injectInternalCss"); 23 | el.textContent = styles; 24 | document.head?.appendChild(el); 25 | }; 26 | 27 | // Function to inject external CSS file 28 | export const injectExternalCss = (href) => { 29 | const el = document.createElement("link"); 30 | el.setAttribute("data-source", "KISS-Calendar injectExternalCss"); 31 | el.setAttribute("rel", "stylesheet"); 32 | el.setAttribute("type", "text/css"); 33 | el.setAttribute("href", href); 34 | document.head?.appendChild(el); 35 | }; 36 | -------------------------------------------------------------------------------- /src/libs/inputTranslate.js: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_INPUT_RULE, 3 | DEFAULT_TRANS_APIS, 4 | DEFAULT_INPUT_SHORTCUT, 5 | OPT_LANGS_LIST, 6 | } from "../config"; 7 | import { genEventName, removeEndchar, matchInputStr, sleep } from "./utils"; 8 | import { stepShortcutRegister } from "./shortcut"; 9 | import { apiTranslate } from "../apis"; 10 | import { loadingSvg } from "./svg"; 11 | import { kissLog } from "./log"; 12 | 13 | function isInputNode(node) { 14 | return node.nodeName === "INPUT" || node.nodeName === "TEXTAREA"; 15 | } 16 | 17 | function isEditAbleNode(node) { 18 | return node.hasAttribute("contenteditable"); 19 | } 20 | 21 | function selectContent(node) { 22 | node.focus(); 23 | const range = document.createRange(); 24 | range.selectNodeContents(node); 25 | 26 | const selection = window.getSelection(); 27 | selection.removeAllRanges(); 28 | selection.addRange(range); 29 | } 30 | 31 | function pasteContentEvent(node, text) { 32 | node.focus(); 33 | const data = new DataTransfer(); 34 | data.setData("text/plain", text); 35 | 36 | const event = new ClipboardEvent("paste", { clipboardData: data }); 37 | document.dispatchEvent(event); 38 | data.clearData(); 39 | } 40 | 41 | function pasteContentCommand(node, text) { 42 | node.focus(); 43 | document.execCommand("insertText", false, text); 44 | } 45 | 46 | function collapseToEnd(node) { 47 | node.focus(); 48 | const selection = window.getSelection(); 49 | selection.collapseToEnd(); 50 | } 51 | 52 | function getNodeText(node) { 53 | if (isInputNode(node)) { 54 | return node.value; 55 | } 56 | return node.innerText || node.textContent || ""; 57 | } 58 | 59 | function addLoading(node, loadingId) { 60 | const div = document.createElement("div"); 61 | div.id = loadingId; 62 | div.innerHTML = loadingSvg; 63 | div.style.cssText = ` 64 | width: ${node.offsetWidth}px; 65 | height: ${node.offsetHeight}px; 66 | line-height: ${node.offsetHeight}px; 67 | position: absolute; 68 | text-align: center; 69 | left: ${node.offsetLeft}px; 70 | top: ${node.offsetTop}px; 71 | z-index: 2147483647; 72 | `; 73 | node.offsetParent?.appendChild(div); 74 | } 75 | 76 | function removeLoading(node, loadingId) { 77 | const div = node.offsetParent.querySelector(`#${loadingId}`); 78 | if (div) { 79 | div.remove(); 80 | } 81 | } 82 | 83 | /** 84 | * 输入框翻译 85 | */ 86 | export default function inputTranslate({ 87 | inputRule: { 88 | transOpen, 89 | triggerShortcut, 90 | translator, 91 | fromLang, 92 | toLang, 93 | triggerCount, 94 | triggerTime, 95 | transSign, 96 | } = DEFAULT_INPUT_RULE, 97 | transApis, 98 | }) { 99 | if (!transOpen) { 100 | return; 101 | } 102 | 103 | const apiSetting = transApis?.[translator] || DEFAULT_TRANS_APIS[translator]; 104 | if (triggerShortcut.length === 0) { 105 | triggerShortcut = DEFAULT_INPUT_SHORTCUT; 106 | triggerCount = 1; 107 | } 108 | 109 | stepShortcutRegister( 110 | triggerShortcut, 111 | async () => { 112 | let node = document.activeElement; 113 | 114 | if (!node) { 115 | return; 116 | } 117 | 118 | while (node.shadowRoot) { 119 | node = node.shadowRoot.activeElement; 120 | } 121 | 122 | if (!isInputNode(node) && !isEditAbleNode(node)) { 123 | return; 124 | } 125 | 126 | let initText = getNodeText(node); 127 | if (triggerShortcut.length === 1 && triggerShortcut[0].length === 1) { 128 | // todo: remove multiple char 129 | initText = removeEndchar(initText, triggerShortcut[0], triggerCount); 130 | } 131 | if (!initText.trim()) { 132 | return; 133 | } 134 | 135 | let text = initText; 136 | if (transSign) { 137 | const res = matchInputStr(text, transSign); 138 | if (res) { 139 | let lang = res[1]; 140 | if (lang === "zh" || lang === "cn") { 141 | lang = "zh-CN"; 142 | } else if (lang === "tw" || lang === "hk") { 143 | lang = "zh-TW"; 144 | } 145 | if (lang && OPT_LANGS_LIST.includes(lang)) { 146 | toLang = lang; 147 | } 148 | text = res[2]; 149 | } 150 | } 151 | 152 | // console.log("input -->", text); 153 | 154 | const loadingId = "kiss-" + genEventName(); 155 | try { 156 | addLoading(node, loadingId); 157 | 158 | const [trText, isSame] = await apiTranslate({ 159 | translator, 160 | text, 161 | fromLang, 162 | toLang, 163 | apiSetting, 164 | }); 165 | if (!trText || isSame) { 166 | return; 167 | } 168 | 169 | if (isInputNode(node)) { 170 | node.value = trText; 171 | node.dispatchEvent( 172 | new Event("input", { bubbles: true, cancelable: true }) 173 | ); 174 | return; 175 | } 176 | 177 | selectContent(node); 178 | await sleep(200); 179 | 180 | pasteContentEvent(node, trText); 181 | await sleep(200); 182 | 183 | // todo: use includes? 184 | if (getNodeText(node).startsWith(initText)) { 185 | pasteContentCommand(node, trText); 186 | await sleep(100); 187 | } else { 188 | collapseToEnd(node); 189 | } 190 | } catch (err) { 191 | kissLog(err, "translate input"); 192 | } finally { 193 | removeLoading(node, loadingId); 194 | } 195 | }, 196 | triggerCount, 197 | triggerTime 198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /src/libs/interpreter.js: -------------------------------------------------------------------------------- 1 | import Sval from "sval"; 2 | 3 | const interpreter = new Sval({ 4 | // ECMA Version of the code 5 | // 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 6 | // or 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 7 | // or "latest" 8 | ecmaVer: "latest", 9 | // Code source type 10 | // "script" or "module" 11 | sourceType: "script", 12 | // Whether the code runs in a sandbox 13 | sandBox: true, 14 | }); 15 | 16 | export default interpreter; 17 | -------------------------------------------------------------------------------- /src/libs/log.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 日志函数 3 | * @param {*} msg 4 | * @param {*} type 5 | */ 6 | export const kissLog = (msg, type) => { 7 | let prefix = `[KISS-Translator]`; 8 | if (type) { 9 | prefix += `[${type}]`; 10 | } 11 | console.log(`${prefix} ${msg}`); 12 | }; 13 | -------------------------------------------------------------------------------- /src/libs/mobile.js: -------------------------------------------------------------------------------- 1 | export const isMobile = "ontouchstart" in document.documentElement; 2 | -------------------------------------------------------------------------------- /src/libs/msg.js: -------------------------------------------------------------------------------- 1 | import { browser } from "./browser"; 2 | 3 | /** 4 | * 获取当前tab信息 5 | * @returns 6 | */ 7 | export const getCurTab = async () => { 8 | const [tab] = await browser.tabs.query({ 9 | active: true, 10 | lastFocusedWindow: true, 11 | }); 12 | return tab; 13 | }; 14 | 15 | export const getCurTabId = async () => { 16 | const tab = await getCurTab(); 17 | return tab.id; 18 | }; 19 | 20 | /** 21 | * 发送消息给background 22 | * @param {*} action 23 | * @param {*} args 24 | * @returns 25 | */ 26 | export const sendBgMsg = (action, args) => 27 | browser.runtime.sendMessage({ action, args }); 28 | 29 | /** 30 | * 发送消息给当前页面 31 | * @param {*} action 32 | * @param {*} args 33 | * @returns 34 | */ 35 | export const sendTabMsg = async (action, args) => { 36 | const tabId = await getCurTabId(); 37 | return browser.tabs.sendMessage(tabId, { action, args }); 38 | }; 39 | -------------------------------------------------------------------------------- /src/libs/pool.js: -------------------------------------------------------------------------------- 1 | import { kissLog } from "./log"; 2 | 3 | /** 4 | * 任务池 5 | * @param {*} fn 6 | * @param {*} preFn 7 | * @param {*} _interval 8 | * @param {*} _limit 9 | * @returns 10 | */ 11 | export const taskPool = ( 12 | fn, 13 | preFn, 14 | _interval = 100, 15 | _limit = 100, 16 | _retryInteral = 1000 17 | ) => { 18 | const pool = []; 19 | const maxRetry = 2; // 最大重试次数 20 | let maxCount = _limit; // 最大数量 21 | let curCount = 0; // 当前数量 22 | let interval = _interval; // 间隔时间 23 | let timer = null; 24 | 25 | const run = async () => { 26 | // console.log("timer", timer); 27 | timer && clearTimeout(timer); 28 | timer = setTimeout(run, interval); 29 | 30 | if (curCount < maxCount) { 31 | const item = pool.shift(); 32 | if (item) { 33 | curCount++; 34 | const { args, resolve, reject, retry } = item; 35 | try { 36 | const preArgs = preFn ? await preFn(item.args) : {}; 37 | const res = await fn({ ...args, ...preArgs }); 38 | resolve(res); 39 | } catch (err) { 40 | kissLog(err, "task"); 41 | if (retry < maxRetry) { 42 | const retryTimer = setTimeout(() => { 43 | clearTimeout(retryTimer); 44 | pool.push({ args, resolve, reject, retry: retry + 1 }); 45 | }, _retryInteral); 46 | } else { 47 | reject(err); 48 | } 49 | } finally { 50 | curCount--; 51 | } 52 | } 53 | } 54 | }; 55 | 56 | return { 57 | push: async (args) => { 58 | if (!timer) { 59 | run(); 60 | } 61 | return new Promise((resolve, reject) => { 62 | pool.push({ args, resolve, reject, retry: 0 }); 63 | }); 64 | }, 65 | update: (_interval = 100, _limit = 100) => { 66 | if (_interval >= 0 && _interval <= 5000 && _interval !== interval) { 67 | interval = _interval; 68 | } 69 | if (_limit >= 1 && _limit <= 100 && _limit !== maxCount) { 70 | maxCount = _limit; 71 | } 72 | }, 73 | clear: () => { 74 | pool.length = 0; 75 | curCount = 0; 76 | timer && clearTimeout(timer); 77 | timer = null; 78 | }, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /src/libs/rules.js: -------------------------------------------------------------------------------- 1 | import { matchValue, type, isMatch } from "./utils"; 2 | import { 3 | GLOBAL_KEY, 4 | REMAIN_KEY, 5 | OPT_TRANS_ALL, 6 | OPT_STYLE_ALL, 7 | OPT_LANGS_FROM, 8 | OPT_LANGS_TO, 9 | OPT_TIMING_ALL, 10 | GLOBLA_RULE, 11 | } from "../config"; 12 | import { loadOrFetchSubRules } from "./subRules"; 13 | import { getRulesWithDefault, setRules } from "./storage"; 14 | import { trySyncRules } from "./sync"; 15 | import { FIXER_ALL } from "./webfix"; 16 | import { kissLog } from "./log"; 17 | 18 | /** 19 | * 根据href匹配规则 20 | * @param {*} rules 21 | * @param {string} href 22 | * @returns 23 | */ 24 | export const matchRule = async ( 25 | href, 26 | { injectRules, subrulesList, owSubrule } 27 | ) => { 28 | const rules = await getRulesWithDefault(); 29 | if (injectRules) { 30 | try { 31 | const selectedSub = subrulesList.find((item) => item.selected); 32 | if (selectedSub?.url) { 33 | const mixRule = {}; 34 | Object.entries(owSubrule) 35 | .filter(([key, val]) => { 36 | if ( 37 | owSubrule.textStyle === REMAIN_KEY && 38 | (key === "bgColor" || key === "textDiyStyle") 39 | ) { 40 | return false; 41 | } 42 | return val !== REMAIN_KEY; 43 | }) 44 | .forEach(([key, val]) => { 45 | mixRule[key] = val; 46 | }); 47 | 48 | let subRules = await loadOrFetchSubRules(selectedSub.url); 49 | subRules = subRules.map((item) => ({ ...item, ...mixRule })); 50 | rules.splice(-1, 0, ...subRules); 51 | } 52 | } catch (err) { 53 | kissLog(err, "load injectRules"); 54 | } 55 | } 56 | 57 | const rule = rules.find((r) => 58 | r.pattern.split(",").some((p) => isMatch(href, p.trim())) 59 | ); 60 | const globalRule = { 61 | ...GLOBLA_RULE, 62 | ...(rules.find((r) => r.pattern === GLOBAL_KEY) || {}), 63 | }; 64 | if (!rule) { 65 | return globalRule; 66 | } 67 | 68 | [ 69 | "selector", 70 | "keepSelector", 71 | "terms", 72 | "selectStyle", 73 | "parentStyle", 74 | "injectJs", 75 | "injectCss", 76 | "fixerSelector", 77 | "transStartHook", 78 | "transEndHook", 79 | "transRemoveHook", 80 | ].forEach((key) => { 81 | if (!rule[key]?.trim()) { 82 | rule[key] = globalRule[key]; 83 | } 84 | }); 85 | 86 | [ 87 | "translator", 88 | "fromLang", 89 | "toLang", 90 | "transOpen", 91 | "transOnly", 92 | "transTiming", 93 | "transTag", 94 | "transTitle", 95 | "detectRemote", 96 | "fixerFunc", 97 | ].forEach((key) => { 98 | if (rule[key] === undefined || rule[key] === GLOBAL_KEY) { 99 | rule[key] = globalRule[key]; 100 | } 101 | }); 102 | 103 | if (!rule.skipLangs || rule.skipLangs.length === 0) { 104 | rule.skipLangs = globalRule.skipLangs; 105 | } 106 | if (rule.textStyle === GLOBAL_KEY) { 107 | rule.textStyle = globalRule.textStyle; 108 | rule.bgColor = globalRule.bgColor; 109 | rule.textDiyStyle = globalRule.textDiyStyle; 110 | } else { 111 | rule.bgColor = rule.bgColor?.trim() || globalRule.bgColor; 112 | rule.textDiyStyle = rule.textDiyStyle?.trim() || globalRule.textDiyStyle; 113 | } 114 | 115 | return rule; 116 | }; 117 | 118 | /** 119 | * 检查过滤rules 120 | * @param {*} rules 121 | * @returns 122 | */ 123 | export const checkRules = (rules) => { 124 | if (type(rules) === "string") { 125 | rules = JSON.parse(rules); 126 | } 127 | if (type(rules) !== "array") { 128 | throw new Error("data error"); 129 | } 130 | 131 | const fromLangs = OPT_LANGS_FROM.map((item) => item[0]); 132 | const toLangs = OPT_LANGS_TO.map((item) => item[0]); 133 | const patternSet = new Set(); 134 | rules = rules 135 | .filter((rule) => type(rule) === "object") 136 | .filter(({ pattern }) => { 137 | if (type(pattern) !== "string" || patternSet.has(pattern.trim())) { 138 | return false; 139 | } 140 | patternSet.add(pattern.trim()); 141 | return true; 142 | }) 143 | .map( 144 | ({ 145 | pattern, 146 | selector, 147 | keepSelector, 148 | terms, 149 | selectStyle, 150 | parentStyle, 151 | injectJs, 152 | injectCss, 153 | translator, 154 | fromLang, 155 | toLang, 156 | textStyle, 157 | transOpen, 158 | bgColor, 159 | textDiyStyle, 160 | transOnly, 161 | transTiming, 162 | transTag, 163 | transTitle, 164 | detectRemote, 165 | skipLangs, 166 | fixerSelector, 167 | fixerFunc, 168 | transStartHook, 169 | transEndHook, 170 | transRemoveHook, 171 | }) => ({ 172 | pattern: pattern.trim(), 173 | selector: type(selector) === "string" ? selector : "", 174 | keepSelector: type(keepSelector) === "string" ? keepSelector : "", 175 | terms: type(terms) === "string" ? terms : "", 176 | selectStyle: type(selectStyle) === "string" ? selectStyle : "", 177 | parentStyle: type(parentStyle) === "string" ? parentStyle : "", 178 | injectJs: type(injectJs) === "string" ? injectJs : "", 179 | injectCss: type(injectCss) === "string" ? injectCss : "", 180 | bgColor: type(bgColor) === "string" ? bgColor : "", 181 | textDiyStyle: type(textDiyStyle) === "string" ? textDiyStyle : "", 182 | translator: matchValue([GLOBAL_KEY, ...OPT_TRANS_ALL], translator), 183 | fromLang: matchValue([GLOBAL_KEY, ...fromLangs], fromLang), 184 | toLang: matchValue([GLOBAL_KEY, ...toLangs], toLang), 185 | textStyle: matchValue([GLOBAL_KEY, ...OPT_STYLE_ALL], textStyle), 186 | transOpen: matchValue([GLOBAL_KEY, "true", "false"], transOpen), 187 | transOnly: matchValue([GLOBAL_KEY, "true", "false"], transOnly), 188 | transTiming: matchValue([GLOBAL_KEY, ...OPT_TIMING_ALL], transTiming), 189 | transTag: matchValue([GLOBAL_KEY, "span", "font"], transTag), 190 | transTitle: matchValue([GLOBAL_KEY, "true", "false"], transTitle), 191 | detectRemote: matchValue([GLOBAL_KEY, "true", "false"], detectRemote), 192 | skipLangs: type(skipLangs) === "array" ? skipLangs : [], 193 | fixerSelector: type(fixerSelector) === "string" ? fixerSelector : "", 194 | transStartHook: type(transStartHook) === "string" ? transStartHook : "", 195 | transEndHook: type(transEndHook) === "string" ? transEndHook : "", 196 | transRemoveHook: 197 | type(transRemoveHook) === "string" ? transRemoveHook : "", 198 | fixerFunc: matchValue([GLOBAL_KEY, ...FIXER_ALL], fixerFunc), 199 | }) 200 | ); 201 | 202 | return rules; 203 | }; 204 | 205 | /** 206 | * 保存或更新rule 207 | * @param {*} newRule 208 | */ 209 | export const saveRule = async (newRule) => { 210 | const rules = await getRulesWithDefault(); 211 | const rule = rules.find((item) => isMatch(newRule.pattern, item.pattern)); 212 | if (rule && rule.pattern !== GLOBAL_KEY) { 213 | Object.assign(rule, { ...newRule, pattern: rule.pattern }); 214 | } else { 215 | rules.unshift(newRule); 216 | } 217 | await setRules(rules); 218 | trySyncRules(); 219 | }; 220 | -------------------------------------------------------------------------------- /src/libs/shortcut.js: -------------------------------------------------------------------------------- 1 | import { isSameSet } from "./utils"; 2 | 3 | /** 4 | * 键盘快捷键监听 5 | * @param {*} fn 6 | * @param {*} target 7 | * @param {*} timeout 8 | * @returns 9 | */ 10 | export const shortcutListener = (fn, target = document, timeout = 3000) => { 11 | const allkeys = new Set(); 12 | const curkeys = new Set(); 13 | let timer = null; 14 | 15 | const handleKeydown = (e) => { 16 | timer && clearTimeout(timer); 17 | timer = setTimeout(() => { 18 | allkeys.clear(); 19 | curkeys.clear(); 20 | clearTimeout(timer); 21 | timer = null; 22 | }, timeout); 23 | 24 | if (e.code) { 25 | allkeys.add(e.code); 26 | curkeys.add(e.code); 27 | fn([...curkeys], [...allkeys]); 28 | } 29 | }; 30 | 31 | const handleKeyup = (e) => { 32 | curkeys.delete(e.code); 33 | if (curkeys.size === 0) { 34 | fn([...curkeys], [...allkeys]); 35 | allkeys.clear(); 36 | } 37 | }; 38 | 39 | target.addEventListener("keydown", handleKeydown, true); 40 | target.addEventListener("keyup", handleKeyup, true); 41 | return () => { 42 | if (timer) { 43 | clearTimeout(timer); 44 | timer = null; 45 | } 46 | target.removeEventListener("keydown", handleKeydown); 47 | target.removeEventListener("keyup", handleKeyup); 48 | }; 49 | }; 50 | 51 | /** 52 | * 注册键盘快捷键 53 | * @param {*} targetKeys 54 | * @param {*} fn 55 | * @param {*} target 56 | * @returns 57 | */ 58 | export const shortcutRegister = (targetKeys = [], fn, target = document) => { 59 | return shortcutListener((curkeys) => { 60 | if ( 61 | targetKeys.length > 0 && 62 | isSameSet(new Set(targetKeys), new Set(curkeys)) 63 | ) { 64 | fn(); 65 | } 66 | }, target); 67 | }; 68 | 69 | /** 70 | * 注册连续快捷键 71 | * @param {*} targetKeys 72 | * @param {*} fn 73 | * @param {*} step 74 | * @param {*} timeout 75 | * @param {*} target 76 | * @returns 77 | */ 78 | export const stepShortcutRegister = ( 79 | targetKeys = [], 80 | fn, 81 | step = 3, 82 | timeout = 500, 83 | target = document 84 | ) => { 85 | let count = 0; 86 | let pre = Date.now(); 87 | let timer; 88 | return shortcutListener((curkeys, allkeys) => { 89 | timer && clearTimeout(timer); 90 | timer = setTimeout(() => { 91 | clearTimeout(timer); 92 | count = 0; 93 | }, timeout); 94 | 95 | if (targetKeys.length > 0 && curkeys.length === 0) { 96 | const now = Date.now(); 97 | if ( 98 | (count === 0 || now - pre < timeout) && 99 | isSameSet(new Set(targetKeys), new Set(allkeys)) 100 | ) { 101 | count++; 102 | if (count === step) { 103 | count = 0; 104 | fn(); 105 | } 106 | } else { 107 | count = 0; 108 | } 109 | pre = now; 110 | } 111 | }, target); 112 | }; 113 | -------------------------------------------------------------------------------- /src/libs/storage.js: -------------------------------------------------------------------------------- 1 | import { 2 | STOKEY_SETTING, 3 | STOKEY_RULES, 4 | STOKEY_WORDS, 5 | STOKEY_FAB, 6 | STOKEY_SYNC, 7 | STOKEY_MSAUTH, 8 | STOKEY_BDAUTH, 9 | STOKEY_RULESCACHE_PREFIX, 10 | DEFAULT_SETTING, 11 | DEFAULT_RULES, 12 | DEFAULT_SYNC, 13 | BUILTIN_RULES, 14 | } from "../config"; 15 | import { isExt, isGm } from "./client"; 16 | import { browser } from "./browser"; 17 | import { kissLog } from "./log"; 18 | 19 | async function set(key, val) { 20 | if (isExt) { 21 | await browser.storage.local.set({ [key]: val }); 22 | } else if (isGm) { 23 | await (window.KISS_GM || GM).setValue(key, val); 24 | } else { 25 | window.localStorage.setItem(key, val); 26 | } 27 | } 28 | 29 | async function get(key) { 30 | if (isExt) { 31 | const val = await browser.storage.local.get([key]); 32 | return val[key]; 33 | } else if (isGm) { 34 | const val = await (window.KISS_GM || GM).getValue(key); 35 | return val; 36 | } 37 | return window.localStorage.getItem(key); 38 | } 39 | 40 | async function del(key) { 41 | if (isExt) { 42 | await browser.storage.local.remove([key]); 43 | } else if (isGm) { 44 | await (window.KISS_GM || GM).deleteValue(key); 45 | } else { 46 | window.localStorage.removeItem(key); 47 | } 48 | } 49 | 50 | async function setObj(key, obj) { 51 | await set(key, JSON.stringify(obj)); 52 | } 53 | 54 | async function trySetObj(key, obj) { 55 | if (!(await get(key))) { 56 | await setObj(key, obj); 57 | } 58 | } 59 | 60 | async function getObj(key) { 61 | const val = await get(key); 62 | return val && JSON.parse(val); 63 | } 64 | 65 | async function putObj(key, obj) { 66 | const cur = (await getObj(key)) ?? {}; 67 | await setObj(key, { ...cur, ...obj }); 68 | } 69 | 70 | /** 71 | * 对storage的封装 72 | */ 73 | export const storage = { 74 | get, 75 | set, 76 | del, 77 | setObj, 78 | trySetObj, 79 | getObj, 80 | putObj, 81 | // onChanged, 82 | }; 83 | 84 | /** 85 | * 设置信息 86 | */ 87 | export const getSetting = () => getObj(STOKEY_SETTING); 88 | export const getSettingWithDefault = async () => ({ 89 | ...DEFAULT_SETTING, 90 | ...((await getSetting()) || {}), 91 | }); 92 | export const setSetting = (val) => setObj(STOKEY_SETTING, val); 93 | export const updateSetting = (obj) => putObj(STOKEY_SETTING, obj); 94 | 95 | /** 96 | * 规则列表 97 | */ 98 | export const getRules = () => getObj(STOKEY_RULES); 99 | export const getRulesWithDefault = async () => 100 | (await getRules()) || DEFAULT_RULES; 101 | export const setRules = (val) => setObj(STOKEY_RULES, val); 102 | 103 | /** 104 | * 词汇列表 105 | */ 106 | export const getWords = () => getObj(STOKEY_WORDS); 107 | export const getWordsWithDefault = async () => (await getWords()) || {}; 108 | export const setWords = (val) => setObj(STOKEY_WORDS, val); 109 | 110 | /** 111 | * 订阅规则 112 | */ 113 | export const getSubRules = (url) => getObj(STOKEY_RULESCACHE_PREFIX + url); 114 | export const getSubRulesWithDefault = async () => (await getSubRules()) || []; 115 | export const delSubRules = (url) => del(STOKEY_RULESCACHE_PREFIX + url); 116 | export const setSubRules = (url, val) => 117 | setObj(STOKEY_RULESCACHE_PREFIX + url, val); 118 | 119 | /** 120 | * fab位置 121 | */ 122 | export const getFab = () => getObj(STOKEY_FAB); 123 | export const getFabWithDefault = async () => (await getFab()) || {}; 124 | export const setFab = (obj) => setObj(STOKEY_FAB, obj); 125 | export const updateFab = (obj) => putObj(STOKEY_FAB, obj); 126 | 127 | /** 128 | * 数据同步 129 | */ 130 | export const getSync = () => getObj(STOKEY_SYNC); 131 | export const getSyncWithDefault = async () => (await getSync()) || DEFAULT_SYNC; 132 | export const updateSync = (obj) => putObj(STOKEY_SYNC, obj); 133 | 134 | /** 135 | * ms auth 136 | */ 137 | export const getMsauth = () => getObj(STOKEY_MSAUTH); 138 | export const setMsauth = (val) => setObj(STOKEY_MSAUTH, val); 139 | 140 | /** 141 | * baidu auth 142 | */ 143 | export const getBdauth = () => getObj(STOKEY_BDAUTH); 144 | export const setBdauth = (val) => setObj(STOKEY_BDAUTH, val); 145 | 146 | /** 147 | * 存入默认数据 148 | */ 149 | export const tryInitDefaultData = async () => { 150 | try { 151 | await trySetObj(STOKEY_SETTING, DEFAULT_SETTING); 152 | await trySetObj(STOKEY_RULES, DEFAULT_RULES); 153 | await trySetObj(STOKEY_SYNC, DEFAULT_SYNC); 154 | await trySetObj( 155 | `${STOKEY_RULESCACHE_PREFIX}${process.env.REACT_APP_RULESURL}`, 156 | BUILTIN_RULES 157 | ); 158 | } catch (err) { 159 | kissLog(err, "init default"); 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /src/libs/subRules.js: -------------------------------------------------------------------------------- 1 | import { GLOBAL_KEY } from "../config"; 2 | import { 3 | getSyncWithDefault, 4 | updateSync, 5 | setSubRules, 6 | getSubRules, 7 | } from "./storage"; 8 | import { apiFetch } from "../apis"; 9 | import { checkRules } from "./rules"; 10 | import { isAllchar } from "./utils"; 11 | import { kissLog } from "./log"; 12 | 13 | /** 14 | * 更新缓存同步时间 15 | * @param {*} url 16 | */ 17 | const updateSyncDataCache = async (url) => { 18 | const { dataCaches = {} } = await getSyncWithDefault(); 19 | dataCaches[url] = Date.now(); 20 | await updateSync({ dataCaches }); 21 | }; 22 | 23 | /** 24 | * 同步订阅规则 25 | * @param {*} url 26 | * @returns 27 | */ 28 | export const syncSubRules = async (url) => { 29 | const res = await apiFetch(url); 30 | const rules = checkRules(res).filter( 31 | ({ pattern }) => !isAllchar(pattern, GLOBAL_KEY) 32 | ); 33 | if (rules.length > 0) { 34 | await setSubRules(url, rules); 35 | } 36 | return rules; 37 | }; 38 | 39 | /** 40 | * 同步所有订阅规则 41 | * @param {*} url 42 | * @returns 43 | */ 44 | export const syncAllSubRules = async (subrulesList) => { 45 | for (const subrules of subrulesList) { 46 | try { 47 | await syncSubRules(subrules.url); 48 | await updateSyncDataCache(subrules.url); 49 | } catch (err) { 50 | kissLog(err, `sync subrule error: ${subrules.url}`); 51 | } 52 | } 53 | }; 54 | 55 | /** 56 | * 根据时间同步所有订阅规则 57 | * @param {*} url 58 | * @returns 59 | */ 60 | export const trySyncAllSubRules = async ({ subrulesList }) => { 61 | try { 62 | const { subRulesSyncAt } = await getSyncWithDefault(); 63 | const now = Date.now(); 64 | const interval = 24 * 60 * 60 * 1000; // 间隔一天 65 | if (now - subRulesSyncAt > interval) { 66 | // 同步订阅规则 67 | await syncAllSubRules(subrulesList); 68 | await updateSync({ subRulesSyncAt: now }); 69 | } 70 | } catch (err) { 71 | kissLog(err, "try sync all subrules"); 72 | } 73 | }; 74 | 75 | /** 76 | * 从缓存或远程加载订阅规则 77 | * @param {*} url 78 | * @returns 79 | */ 80 | export const loadOrFetchSubRules = async (url) => { 81 | let rules = await getSubRules(url); 82 | if (!rules || rules.length === 0) { 83 | rules = await syncSubRules(url); 84 | await updateSyncDataCache(url); 85 | } 86 | return rules || []; 87 | }; 88 | -------------------------------------------------------------------------------- /src/libs/svg.js: -------------------------------------------------------------------------------- 1 | export const loadingSvg = ` 2 | 3 | 4 | 12 | 13 | 14 | 22 | 23 | 24 | 32 | 33 | 34 | `; 35 | -------------------------------------------------------------------------------- /src/libs/sync.js: -------------------------------------------------------------------------------- 1 | import { 2 | APP_LCNAME, 3 | KV_SETTING_KEY, 4 | KV_RULES_KEY, 5 | KV_WORDS_KEY, 6 | KV_RULES_SHARE_KEY, 7 | KV_SALT_SHARE, 8 | OPT_SYNCTYPE_WEBDAV, 9 | } from "../config"; 10 | import { 11 | getSyncWithDefault, 12 | updateSync, 13 | getSettingWithDefault, 14 | getRulesWithDefault, 15 | getWordsWithDefault, 16 | setSetting, 17 | setRules, 18 | setWords, 19 | } from "./storage"; 20 | import { apiSyncData } from "../apis"; 21 | import { sha256, removeEndchar } from "./utils"; 22 | import { createClient, getPatcher } from "webdav"; 23 | import { fetchPatcher } from "./fetch"; 24 | import { kissLog } from "./log"; 25 | 26 | getPatcher().patch("request", (opts) => { 27 | return fetchPatcher(opts.url, { 28 | method: opts.method, 29 | headers: opts.headers, 30 | body: opts.data, 31 | }); 32 | }); 33 | 34 | const syncByWebdav = async (data, { syncUrl, syncUser, syncKey }) => { 35 | const client = createClient(syncUrl, { 36 | username: syncUser, 37 | password: syncKey, 38 | }); 39 | const pathname = `/${APP_LCNAME}`; 40 | const filename = `/${APP_LCNAME}/${data.key}`; 41 | 42 | if ((await client.exists(pathname)) === false) { 43 | await client.createDirectory(pathname); 44 | } 45 | 46 | const isExist = await client.exists(filename); 47 | if (isExist) { 48 | const cont = await client.getFileContents(filename, { format: "text" }); 49 | const webData = JSON.parse(cont); 50 | if (webData.updateAt >= data.updateAt) { 51 | return webData; 52 | } 53 | } 54 | 55 | await client.putFileContents(filename, JSON.stringify(data, null, 2)); 56 | return data; 57 | }; 58 | 59 | const syncByWorker = async (data, { syncUrl, syncKey }) => { 60 | syncUrl = removeEndchar(syncUrl, "/"); 61 | return await apiSyncData(`${syncUrl}/sync`, syncKey, data); 62 | }; 63 | 64 | const syncData = async (key, valueFn) => { 65 | const { 66 | syncType, 67 | syncUrl, 68 | syncUser, 69 | syncKey, 70 | syncMeta = {}, 71 | } = await getSyncWithDefault(); 72 | if (!syncUrl || !syncKey || (syncType === OPT_SYNCTYPE_WEBDAV && !syncUser)) { 73 | return; 74 | } 75 | 76 | let { updateAt = 0, syncAt = 0 } = syncMeta[key] || {}; 77 | syncAt === 0 && (updateAt = 0); 78 | 79 | const value = await valueFn(); 80 | const data = { 81 | key, 82 | value: JSON.stringify(value), 83 | updateAt, 84 | }; 85 | const args = { 86 | syncUrl, 87 | syncUser, 88 | syncKey, 89 | }; 90 | 91 | const res = 92 | syncType === OPT_SYNCTYPE_WEBDAV 93 | ? await syncByWebdav(data, args) 94 | : await syncByWorker(data, args); 95 | 96 | syncMeta[key] = { 97 | updateAt: res.updateAt, 98 | syncAt: Date.now(), 99 | }; 100 | await updateSync({ syncMeta }); 101 | 102 | return { value: JSON.parse(res.value), isNew: res.updateAt > updateAt }; 103 | }; 104 | 105 | /** 106 | * 同步设置 107 | * @returns 108 | */ 109 | const syncSetting = async () => { 110 | const res = await syncData(KV_SETTING_KEY, getSettingWithDefault); 111 | if (res?.isNew) { 112 | await setSetting(res.value); 113 | } 114 | }; 115 | 116 | export const trySyncSetting = async () => { 117 | try { 118 | await syncSetting(); 119 | } catch (err) { 120 | kissLog(err, "sync setting"); 121 | } 122 | }; 123 | 124 | /** 125 | * 同步规则 126 | * @returns 127 | */ 128 | const syncRules = async () => { 129 | const res = await syncData(KV_RULES_KEY, getRulesWithDefault); 130 | if (res?.isNew) { 131 | await setRules(res.value); 132 | } 133 | }; 134 | 135 | export const trySyncRules = async () => { 136 | try { 137 | await syncRules(); 138 | } catch (err) { 139 | kissLog(err, "sync user rules"); 140 | } 141 | }; 142 | 143 | /** 144 | * 同步词汇 145 | * @returns 146 | */ 147 | const syncWords = async () => { 148 | const res = await syncData(KV_WORDS_KEY, getWordsWithDefault); 149 | if (res?.isNew) { 150 | await setWords(res.value); 151 | } 152 | }; 153 | 154 | export const trySyncWords = async () => { 155 | try { 156 | await syncWords(); 157 | } catch (err) { 158 | kissLog(err, "sync fav words"); 159 | } 160 | }; 161 | 162 | /** 163 | * 同步分享规则 164 | * @param {*} param0 165 | * @returns 166 | */ 167 | export const syncShareRules = async ({ rules, syncUrl, syncKey }) => { 168 | const data = { 169 | key: KV_RULES_SHARE_KEY, 170 | value: JSON.stringify(rules, null, 2), 171 | updateAt: Date.now(), 172 | }; 173 | const args = { 174 | syncUrl, 175 | syncKey, 176 | }; 177 | await syncByWorker(data, args); 178 | const psk = await sha256(syncKey, KV_SALT_SHARE); 179 | const shareUrl = `${syncUrl}/rules?psk=${psk}`; 180 | return shareUrl; 181 | }; 182 | 183 | /** 184 | * 同步个人设置和规则 185 | * @returns 186 | */ 187 | export const syncSettingAndRules = async () => { 188 | await syncSetting(); 189 | await syncRules(); 190 | await syncWords(); 191 | }; 192 | 193 | export const trySyncSettingAndRules = async () => { 194 | await trySyncSetting(); 195 | await trySyncRules(); 196 | await trySyncWords(); 197 | }; 198 | -------------------------------------------------------------------------------- /src/libs/touch.js: -------------------------------------------------------------------------------- 1 | export function touchTapListener(fn, touchsLength) { 2 | const handleTouchend = (e) => { 3 | if (e.touches.length === touchsLength) { 4 | fn(); 5 | } 6 | }; 7 | 8 | document.addEventListener("touchstart", handleTouchend); 9 | return () => { 10 | document.removeEventListener("touchstart", handleTouchend); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/libs/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 限制数字大小 3 | * @param {*} num 4 | * @param {*} min 5 | * @param {*} max 6 | * @returns 7 | */ 8 | export const limitNumber = (num, min = 0, max = 100) => { 9 | const number = parseInt(num); 10 | if (Number.isNaN(number) || number < min) { 11 | return min; 12 | } else if (number > max) { 13 | return max; 14 | } 15 | return number; 16 | }; 17 | 18 | export const limitFloat = (num, min = 0, max = 100) => { 19 | const number = parseFloat(num); 20 | if (Number.isNaN(number) || number < min) { 21 | return min; 22 | } else if (number > max) { 23 | return max; 24 | } 25 | return number; 26 | }; 27 | 28 | /** 29 | * 匹配是否为数组中的值 30 | * @param {*} arr 31 | * @param {*} val 32 | * @returns 33 | */ 34 | export const matchValue = (arr, val) => { 35 | if (arr.length === 0 || arr.includes(val)) { 36 | return val; 37 | } 38 | return arr[0]; 39 | }; 40 | 41 | /** 42 | * 等待 43 | * @param {*} delay 44 | * @returns 45 | */ 46 | export const sleep = (delay) => 47 | new Promise((resolve) => { 48 | const timer = setTimeout(() => { 49 | clearTimeout(timer); 50 | resolve(); 51 | }, delay); 52 | }); 53 | 54 | /** 55 | * 防抖函数 56 | * @param {*} func 57 | * @param {*} delay 58 | * @returns 59 | */ 60 | export const debounce = (func, delay = 200) => { 61 | let timer = null; 62 | return (...args) => { 63 | timer && clearTimeout(timer); 64 | timer = setTimeout(() => { 65 | func(...args); 66 | clearTimeout(timer); 67 | timer = null; 68 | }, delay); 69 | }; 70 | }; 71 | 72 | /** 73 | * 节流函数 74 | * @param {*} func 75 | * @param {*} delay 76 | * @returns 77 | */ 78 | export const throttle = (func, delay = 200) => { 79 | let timer = null; 80 | let cache = null; 81 | return (...args) => { 82 | if (!timer) { 83 | func(...args); 84 | cache = null; 85 | timer = setTimeout(() => { 86 | if (cache) { 87 | func(...cache); 88 | cache = null; 89 | } 90 | clearTimeout(timer); 91 | timer = null; 92 | }, delay); 93 | } else { 94 | cache = args; 95 | } 96 | }; 97 | }; 98 | 99 | /** 100 | * 判断字符串全是某个字符 101 | * @param {*} s 102 | * @param {*} c 103 | * @param {*} i 104 | * @returns 105 | */ 106 | export const isAllchar = (s, c, i = 0) => { 107 | while (i < s.length) { 108 | if (s[i] !== c) { 109 | return false; 110 | } 111 | i++; 112 | } 113 | return true; 114 | }; 115 | 116 | /** 117 | * 字符串通配符(*)匹配 118 | * @param {*} s 119 | * @param {*} p 120 | * @returns 121 | */ 122 | export const isMatch = (s, p) => { 123 | if (s.length === 0 || p.length === 0) { 124 | return false; 125 | } 126 | 127 | p = "*" + p + "*"; 128 | 129 | let [sIndex, pIndex] = [0, 0]; 130 | let [sRecord, pRecord] = [-1, -1]; 131 | while (sIndex < s.length && pRecord < p.length) { 132 | if (p[pIndex] === "*") { 133 | pIndex++; 134 | [sRecord, pRecord] = [sIndex, pIndex]; 135 | } else if (s[sIndex] === p[pIndex]) { 136 | sIndex++; 137 | pIndex++; 138 | } else if (sRecord + 1 < s.length) { 139 | sRecord++; 140 | [sIndex, pIndex] = [sRecord, pRecord]; 141 | } else { 142 | return false; 143 | } 144 | } 145 | 146 | if (p.length === pIndex) { 147 | return true; 148 | } 149 | 150 | return isAllchar(p, "*", pIndex); 151 | }; 152 | 153 | /** 154 | * 类型检查 155 | * @param {*} o 156 | * @returns 157 | */ 158 | export const type = (o) => { 159 | const s = Object.prototype.toString.call(o); 160 | return s.match(/\[object (.*?)\]/)[1].toLowerCase(); 161 | }; 162 | 163 | /** 164 | * sha256 165 | * @param {*} text 166 | * @returns 167 | */ 168 | export const sha256 = async (text, salt) => { 169 | const data = new TextEncoder().encode(text + salt); 170 | const digest = await crypto.subtle.digest({ name: "SHA-256" }, data); 171 | return [...new Uint8Array(digest)] 172 | .map((b) => b.toString(16).padStart(2, "0")) 173 | .join(""); 174 | }; 175 | 176 | /** 177 | * 生成随机事件名称 178 | * @returns 179 | */ 180 | export const genEventName = () => btoa(Math.random()).slice(3, 11); 181 | 182 | /** 183 | * 判断两个 Set 是否相同 184 | * @param {*} a 185 | * @param {*} b 186 | * @returns 187 | */ 188 | export const isSameSet = (a, b) => { 189 | const s = new Set([...a, ...b]); 190 | return s.size === a.size && s.size === b.size; 191 | }; 192 | 193 | /** 194 | * 去掉字符串末尾某个字符 195 | * @param {*} s 196 | * @param {*} c 197 | * @param {*} count 198 | * @returns 199 | */ 200 | export const removeEndchar = (s, c, count = 1) => { 201 | let i = s.length; 202 | while (i > s.length - count && s[i - 1] === c) { 203 | i--; 204 | } 205 | return s.slice(0, i); 206 | }; 207 | 208 | /** 209 | * 匹配字符串及语言标识 210 | * @param {*} str 211 | * @param {*} sign 212 | * @returns 213 | */ 214 | export const matchInputStr = (str, sign) => { 215 | switch (sign) { 216 | case "//": 217 | return str.match(/\/\/([\w-]+)\s+([^]+)/); 218 | case "\\": 219 | return str.match(/\\([\w-]+)\s+([^]+)/); 220 | case "\\\\": 221 | return str.match(/\\\\([\w-]+)\s+([^]+)/); 222 | case ">": 223 | return str.match(/>([\w-]+)\s+([^]+)/); 224 | case ">>": 225 | return str.match(/>>([\w-]+)\s+([^]+)/); 226 | default: 227 | } 228 | return str.match(/\/([\w-]+)\s+([^]+)/); 229 | }; 230 | 231 | /** 232 | * 判断是否英文单词 233 | * @param {*} str 234 | * @returns 235 | */ 236 | export const isValidWord = (str) => { 237 | const regex = /^[a-zA-Z-]+$/; 238 | return regex.test(str); 239 | }; 240 | 241 | /** 242 | * blob转为base64 243 | * @param {*} blob 244 | * @returns 245 | */ 246 | export const blobToBase64 = (blob) => { 247 | return new Promise((resolve) => { 248 | const reader = new FileReader(); 249 | reader.onloadend = () => resolve(reader.result); 250 | reader.readAsDataURL(blob); 251 | }); 252 | }; 253 | -------------------------------------------------------------------------------- /src/libs/webfix.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 修复程序类型 3 | */ 4 | export const FIXER_NONE = "-"; 5 | export const FIXER_BR = "br"; 6 | export const FIXER_BN = "bn"; 7 | export const FIXER_BR_DIV = "brToDiv"; 8 | export const FIXER_BN_DIV = "bnToDiv"; 9 | 10 | export const FIXER_ALL = [ 11 | FIXER_NONE, 12 | FIXER_BR, 13 | FIXER_BN, 14 | FIXER_BR_DIV, 15 | FIXER_BN_DIV, 16 | ]; 17 | 18 | /** 19 | * 修复过的标记 20 | */ 21 | const fixedSign = "kiss-fixed"; 22 | 23 | /** 24 | * 采用 `br` 换行网站的修复函数 25 | * 目标是将 `br` 替换成 `p` 26 | * @param {*} node 27 | * @returns 28 | */ 29 | function brFixer(node, tag = "p") { 30 | if (node.hasAttribute(fixedSign)) { 31 | return; 32 | } 33 | node.setAttribute(fixedSign, "true"); 34 | 35 | const gapTags = ["BR", "WBR"]; 36 | const newlineTags = [ 37 | "DIV", 38 | "UL", 39 | "OL", 40 | "LI", 41 | "H1", 42 | "H2", 43 | "H3", 44 | "H4", 45 | "H5", 46 | "H6", 47 | "P", 48 | "HR", 49 | "PRE", 50 | "TABLE", 51 | "BLOCKQUOTE", 52 | ]; 53 | 54 | let html = ""; 55 | node.childNodes.forEach(function (child, index) { 56 | if (index === 0) { 57 | html += `<${tag} class="kiss-p">`; 58 | } 59 | 60 | if (gapTags.indexOf(child.nodeName) !== -1) { 61 | html += `<${tag} class="kiss-p">`; 62 | } else if (newlineTags.indexOf(child.nodeName) !== -1) { 63 | html += `${child.outerHTML}<${tag} class="kiss-p">`; 64 | } else if (child.outerHTML) { 65 | html += child.outerHTML; 66 | } else if (child.textContent) { 67 | html += child.textContent; 68 | } 69 | 70 | if (index === node.childNodes.length - 1) { 71 | html += ``; 72 | } 73 | }); 74 | node.innerHTML = html; 75 | } 76 | 77 | function brDivFixer(node) { 78 | return brFixer(node, "div"); 79 | } 80 | 81 | /** 82 | * 目标是将 `\n` 替换成 `p` 83 | * @param {*} node 84 | * @returns 85 | */ 86 | function bnFixer(node, tag = "p") { 87 | if (node.hasAttribute(fixedSign)) { 88 | return; 89 | } 90 | node.setAttribute(fixedSign, "true"); 91 | node.innerHTML = node.innerHTML 92 | .split("\n") 93 | .map((item) => `<${tag} class="kiss-p">${item || " "}`) 94 | .join(""); 95 | } 96 | 97 | function bnDivFixer(node) { 98 | return bnFixer(node, "div"); 99 | } 100 | 101 | /** 102 | * 查找、监听节点,并执行修复函数 103 | * @param {*} selector 104 | * @param {*} fixer 105 | * @param {*} rootSelector 106 | */ 107 | function run(selector, fixer, rootSelector) { 108 | const mutaObserver = new MutationObserver(function (mutations) { 109 | mutations.forEach(function (mutation) { 110 | mutation.addedNodes.forEach(function (addNode) { 111 | if (addNode && addNode.querySelectorAll) { 112 | addNode.querySelectorAll(selector).forEach(function (node) { 113 | fixer(node); 114 | }); 115 | } 116 | }); 117 | }); 118 | }); 119 | 120 | let rootNodes = [document]; 121 | if (rootSelector) { 122 | rootNodes = document.querySelectorAll(rootSelector); 123 | } 124 | 125 | rootNodes.forEach(function (rootNode) { 126 | rootNode.querySelectorAll(selector).forEach(function (node) { 127 | fixer(node); 128 | }); 129 | mutaObserver.observe(rootNode, { 130 | childList: true, 131 | subtree: true, 132 | }); 133 | }); 134 | } 135 | 136 | /** 137 | * 修复程序映射 138 | */ 139 | const fixerMap = { 140 | [FIXER_BR]: brFixer, 141 | [FIXER_BN]: bnFixer, 142 | [FIXER_BR_DIV]: brDivFixer, 143 | [FIXER_BN_DIV]: bnDivFixer, 144 | }; 145 | 146 | /** 147 | * 执行fixer 148 | * @param {*} param0 149 | */ 150 | export function runFixer(selector, fixer = "-", rootSelector) { 151 | try { 152 | if (Object.keys(fixerMap).includes(fixer)) { 153 | run(selector, fixerMap[fixer], rootSelector); 154 | } 155 | } catch (err) { 156 | console.error(`[kiss-webfix run]: ${err.message}`); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import Options from "./views/Options"; 4 | 5 | const root = ReactDOM.createRoot(document.getElementById("root")); 6 | root.render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/popup.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { SettingProvider } from "./hooks/Setting"; 4 | import ThemeProvider from "./hooks/Theme"; 5 | import Popup from "./views/Popup"; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById("root")); 8 | root.render( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/rules.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { BUILTIN_RULES } from "./config/rules"; 4 | 5 | (() => { 6 | // rules 7 | try { 8 | const data = JSON.stringify(BUILTIN_RULES, null, 2); 9 | const file = path.resolve( 10 | __dirname, 11 | "../build/web/kiss-translator-rules.json" 12 | ); 13 | fs.writeFileSync(file, data); 14 | console.info(`Built-in rules generated: ${file}`); 15 | } catch (err) { 16 | console.error(err); 17 | } 18 | 19 | // version 20 | try { 21 | var pjson = require("../package.json"); 22 | const file = path.resolve(__dirname, "../build/web/version.txt"); 23 | fs.writeFileSync(file, pjson.version); 24 | console.info(`Version file generated: ${file}`); 25 | } catch (err) { 26 | console.error(err); 27 | } 28 | })(); 29 | -------------------------------------------------------------------------------- /src/userscript.js: -------------------------------------------------------------------------------- 1 | import { run } from "./common"; 2 | 3 | run(true); 4 | -------------------------------------------------------------------------------- /src/views/Action/Draggable.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { limitNumber } from "../../libs/utils"; 3 | import { isMobile } from "../../libs/mobile"; 4 | import { updateFab } from "../../libs/storage"; 5 | import { debounce } from "../../libs/utils"; 6 | import Paper from "@mui/material/Paper"; 7 | 8 | const getEdgePosition = ({ 9 | x: left, 10 | y: top, 11 | width, 12 | height, 13 | windowWidth, 14 | windowHeight, 15 | hover, 16 | }) => { 17 | const right = windowWidth - left - width; 18 | const bottom = windowHeight - top - height; 19 | const min = Math.min(left, top, right, bottom); 20 | switch (min) { 21 | case right: 22 | left = hover ? windowWidth - width : windowWidth - width / 2; 23 | break; 24 | case left: 25 | left = hover ? 0 : -width / 2; 26 | break; 27 | case bottom: 28 | top = hover ? windowHeight - height : windowHeight - height / 2; 29 | break; 30 | default: 31 | top = hover ? 0 : -height / 2; 32 | } 33 | return { x: left, y: top }; 34 | }; 35 | 36 | function DraggableWrapper({ children, usePaper, ...props }) { 37 | if (usePaper) { 38 | return ( 39 | 40 | {children} 41 | 42 | ); 43 | } 44 | return
{children}
; 45 | } 46 | 47 | export default function Draggable({ 48 | windowSize: { w: windowWidth, h: windowHeight }, 49 | width, 50 | height, 51 | left, 52 | top, 53 | show, 54 | snapEdge, 55 | onStart, 56 | onMove, 57 | handler, 58 | children, 59 | usePaper, 60 | }) { 61 | const [hover, setHover] = useState(false); 62 | const [origin, setOrigin] = useState(null); 63 | const [position, setPosition] = useState({ x: left, y: top }); 64 | const setFabPosition = useMemo(() => debounce(updateFab, 500), []); 65 | 66 | const handlePointerDown = (e) => { 67 | !isMobile && e.target.setPointerCapture(e.pointerId); 68 | onStart && onStart(); 69 | const { x, y } = position; 70 | const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; 71 | setOrigin({ x, y, clientX, clientY }); 72 | }; 73 | 74 | const handlePointerMove = (e) => { 75 | onMove && onMove(); 76 | const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; 77 | if (origin) { 78 | const dx = clientX - origin.clientX; 79 | const dy = clientY - origin.clientY; 80 | let x = origin.x + dx; 81 | let y = origin.y + dy; 82 | x = limitNumber(x, -width / 2, windowWidth - width / 2); 83 | y = limitNumber(y, 0, windowHeight - height / 2); 84 | setPosition({ x, y }); 85 | } 86 | }; 87 | 88 | const handlePointerUp = (e) => { 89 | e.stopPropagation(); 90 | setOrigin(null); 91 | }; 92 | 93 | const handleClick = (e) => { 94 | e.stopPropagation(); 95 | }; 96 | 97 | const handleMouseEnter = (e) => { 98 | e.stopPropagation(); 99 | setHover(true); 100 | }; 101 | 102 | const handleMouseLeave = (e) => { 103 | e.stopPropagation(); 104 | setHover(false); 105 | }; 106 | 107 | useEffect(() => { 108 | if (!snapEdge || !!origin) { 109 | return; 110 | } 111 | 112 | setPosition((pre) => { 113 | const edgePosition = getEdgePosition({ 114 | ...pre, 115 | width, 116 | height, 117 | windowWidth, 118 | windowHeight, 119 | hover, 120 | }); 121 | setFabPosition(edgePosition); 122 | return edgePosition; 123 | }); 124 | }, [ 125 | origin, 126 | hover, 127 | width, 128 | height, 129 | windowWidth, 130 | windowHeight, 131 | snapEdge, 132 | setFabPosition, 133 | ]); 134 | 135 | const opacity = useMemo(() => { 136 | if (snapEdge) { 137 | return hover || origin ? 1 : 0.2; 138 | } 139 | return origin ? 0.8 : 1; 140 | }, [origin, snapEdge, hover]); 141 | 142 | const touchProps = isMobile 143 | ? { 144 | onTouchStart: handlePointerDown, 145 | onTouchMove: handlePointerMove, 146 | onTouchEnd: handlePointerUp, 147 | } 148 | : { 149 | onPointerDown: handlePointerDown, 150 | onPointerMove: handlePointerMove, 151 | onPointerUp: handlePointerUp, 152 | }; 153 | 154 | return ( 155 | 169 |
175 | {handler} 176 |
177 |
{children}
178 |
179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /src/views/Action/index.js: -------------------------------------------------------------------------------- 1 | import Fab from "@mui/material/Fab"; 2 | import TranslateIcon from "@mui/icons-material/Translate"; 3 | import ThemeProvider from "../../hooks/Theme"; 4 | import Draggable from "./Draggable"; 5 | import { useEffect, useState, useMemo, useCallback } from "react"; 6 | import { SettingProvider } from "../../hooks/Setting"; 7 | import Popup from "../Popup"; 8 | import { debounce } from "../../libs/utils"; 9 | import { isGm } from "../../libs/client"; 10 | import Header from "../Popup/Header"; 11 | import Box from "@mui/material/Box"; 12 | import Divider from "@mui/material/Divider"; 13 | import { 14 | DEFAULT_SHORTCUTS, 15 | OPT_SHORTCUT_TRANSLATE, 16 | OPT_SHORTCUT_STYLE, 17 | OPT_SHORTCUT_POPUP, 18 | OPT_SHORTCUT_SETTING, 19 | MSG_TRANS_TOGGLE, 20 | MSG_TRANS_TOGGLE_STYLE, 21 | } from "../../config"; 22 | import { shortcutRegister } from "../../libs/shortcut"; 23 | import { sendIframeMsg } from "../../libs/iframe"; 24 | import { kissLog } from "../../libs/log"; 25 | import { getI18n } from "../../hooks/I18n"; 26 | 27 | export default function Action({ translator, fab }) { 28 | const fabWidth = 40; 29 | const [showPopup, setShowPopup] = useState(false); 30 | const [windowSize, setWindowSize] = useState({ 31 | w: window.innerWidth, 32 | h: window.innerHeight, 33 | }); 34 | const [moved, setMoved] = useState(false); 35 | 36 | const handleWindowResize = useMemo( 37 | () => 38 | debounce(() => { 39 | setWindowSize({ 40 | w: window.innerWidth, 41 | h: window.innerHeight, 42 | }); 43 | }), 44 | [] 45 | ); 46 | 47 | const handleWindowClick = (e) => { 48 | setShowPopup(false); 49 | }; 50 | 51 | const handleStart = useCallback(() => { 52 | setMoved(false); 53 | }, []); 54 | 55 | const handleMove = useCallback(() => { 56 | setMoved(true); 57 | }, []); 58 | 59 | useEffect(() => { 60 | if (!isGm) { 61 | return; 62 | } 63 | 64 | // 注册快捷键 65 | const shortcuts = translator.setting.shortcuts || DEFAULT_SHORTCUTS; 66 | const clearShortcuts = [ 67 | shortcutRegister(shortcuts[OPT_SHORTCUT_TRANSLATE], () => { 68 | translator.toggle(); 69 | sendIframeMsg(MSG_TRANS_TOGGLE); 70 | setShowPopup(false); 71 | }), 72 | shortcutRegister(shortcuts[OPT_SHORTCUT_STYLE], () => { 73 | translator.toggleStyle(); 74 | sendIframeMsg(MSG_TRANS_TOGGLE_STYLE); 75 | setShowPopup(false); 76 | }), 77 | shortcutRegister(shortcuts[OPT_SHORTCUT_POPUP], () => { 78 | setShowPopup((pre) => !pre); 79 | }), 80 | shortcutRegister(shortcuts[OPT_SHORTCUT_SETTING], () => { 81 | window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank"); 82 | }), 83 | ]; 84 | 85 | return () => { 86 | clearShortcuts.forEach((fn) => { 87 | fn(); 88 | }); 89 | }; 90 | }, [translator]); 91 | 92 | useEffect(() => { 93 | if (!isGm) { 94 | return; 95 | } 96 | 97 | // 注册菜单 98 | try { 99 | const menuCommandIds = []; 100 | const { contextMenuType, uiLang } = translator.setting; 101 | contextMenuType !== 0 && 102 | menuCommandIds.push( 103 | GM.registerMenuCommand( 104 | getI18n(uiLang, "translate_switch"), 105 | (event) => { 106 | translator.toggle(); 107 | sendIframeMsg(MSG_TRANS_TOGGLE); 108 | setShowPopup(false); 109 | }, 110 | "Q" 111 | ), 112 | GM.registerMenuCommand( 113 | getI18n(uiLang, "toggle_style"), 114 | (event) => { 115 | translator.toggleStyle(); 116 | sendIframeMsg(MSG_TRANS_TOGGLE_STYLE); 117 | setShowPopup(false); 118 | }, 119 | "C" 120 | ), 121 | GM.registerMenuCommand( 122 | getI18n(uiLang, "open_menu"), 123 | (event) => { 124 | setShowPopup((pre) => !pre); 125 | }, 126 | "K" 127 | ), 128 | GM.registerMenuCommand( 129 | getI18n(uiLang, "open_setting"), 130 | (event) => { 131 | window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank"); 132 | }, 133 | "O" 134 | ) 135 | ); 136 | 137 | return () => { 138 | menuCommandIds.forEach((id) => { 139 | GM.unregisterMenuCommand(id); 140 | }); 141 | }; 142 | } catch (err) { 143 | kissLog(err, "registerMenuCommand"); 144 | } 145 | }, [translator]); 146 | 147 | useEffect(() => { 148 | window.addEventListener("resize", handleWindowResize); 149 | return () => { 150 | window.removeEventListener("resize", handleWindowResize); 151 | }; 152 | }, [handleWindowResize]); 153 | 154 | useEffect(() => { 155 | window.addEventListener("click", handleWindowClick); 156 | 157 | return () => { 158 | window.removeEventListener("click", handleWindowClick); 159 | }; 160 | }, []); 161 | 162 | const popProps = useMemo(() => { 163 | const width = Math.min(windowSize.w, 300); 164 | const height = Math.min(windowSize.h, 442); 165 | const left = (windowSize.w - width) / 2; 166 | const top = (windowSize.h - height) / 2; 167 | return { 168 | windowSize, 169 | width, 170 | height, 171 | left, 172 | top, 173 | }; 174 | }, [windowSize]); 175 | 176 | const fabProps = { 177 | windowSize, 178 | width: fabWidth, 179 | height: fabWidth, 180 | left: fab.x ?? -fabWidth, 181 | top: fab.y ?? windowSize.h / 2, 182 | }; 183 | 184 | return ( 185 | 186 | 187 | 196 |
197 | 198 | 199 | } 200 | > 201 | {showPopup && ( 202 | 203 | )} 204 | 205 | { 217 | if (!moved) { 218 | setShowPopup((pre) => !pre); 219 | } 220 | }} 221 | > 222 | 228 | 229 | } 230 | /> 231 | 232 | 233 | ); 234 | } 235 | -------------------------------------------------------------------------------- /src/views/Content/LoadingIcon.js: -------------------------------------------------------------------------------- 1 | import { loadingSvg } from "../../libs/svg"; 2 | 3 | export default function LoadingIcon() { 4 | return ( 5 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/views/Content/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useMemo } from "react"; 2 | import LoadingIcon from "./LoadingIcon"; 3 | import { 4 | OPT_STYLE_LINE, 5 | OPT_STYLE_DOTLINE, 6 | OPT_STYLE_DASHLINE, 7 | OPT_STYLE_WAVYLINE, 8 | OPT_STYLE_FUZZY, 9 | OPT_STYLE_HIGHLIGHT, 10 | OPT_STYLE_BLOCKQUOTE, 11 | OPT_STYLE_DIY, 12 | DEFAULT_COLOR, 13 | MSG_TRANS_CURRULE, 14 | } from "../../config"; 15 | import { useTranslate } from "../../hooks/Translate"; 16 | import { styled, css } from "@mui/material/styles"; 17 | import { APP_LCNAME } from "../../config"; 18 | import interpreter from "../../libs/interpreter"; 19 | 20 | const LINE_STYLES = { 21 | [OPT_STYLE_LINE]: "solid", 22 | [OPT_STYLE_DOTLINE]: "dotted", 23 | [OPT_STYLE_DASHLINE]: "dashed", 24 | [OPT_STYLE_WAVYLINE]: "wavy", 25 | }; 26 | 27 | const StyledSpan = styled("span")` 28 | ${({ textStyle, textDiyStyle, bgColor }) => { 29 | switch (textStyle) { 30 | case OPT_STYLE_LINE: // 下划线 31 | case OPT_STYLE_DOTLINE: // 点状线 32 | case OPT_STYLE_DASHLINE: // 虚线 33 | case OPT_STYLE_WAVYLINE: // 波浪线 34 | return css` 35 | opacity: 0.6; 36 | -webkit-opacity: 0.6; 37 | text-decoration-line: underline; 38 | text-decoration-style: ${LINE_STYLES[textStyle]}; 39 | text-decoration-color: ${bgColor}; 40 | text-decoration-thickness: 2px; 41 | text-underline-offset: 0.3em; 42 | -webkit-text-decoration-line: underline; 43 | -webkit-text-decoration-style: ${LINE_STYLES[textStyle]}; 44 | -webkit-text-decoration-color: ${bgColor}; 45 | -webkit-text-decoration-thickness: 2px; 46 | -webkit-text-underline-offset: 0.3em; 47 | &:hover { 48 | opacity: 1; 49 | -webkit-opacity: 1; 50 | } 51 | `; 52 | case OPT_STYLE_FUZZY: // 模糊 53 | return css` 54 | filter: blur(0.2em); 55 | -webkit-filter: blur(0.2em); 56 | &:hover { 57 | filter: none; 58 | -webkit-filter: none; 59 | } 60 | `; 61 | case OPT_STYLE_HIGHLIGHT: // 高亮 62 | return css` 63 | color: #fff; 64 | background-color: ${bgColor || DEFAULT_COLOR}; 65 | `; 66 | case OPT_STYLE_BLOCKQUOTE: // 引用 67 | return css` 68 | opacity: 0.6; 69 | -webkit-opacity: 0.6; 70 | display: block; 71 | padding: 0 0.75em; 72 | border-left: 0.25em solid ${bgColor || DEFAULT_COLOR}; 73 | &:hover { 74 | opacity: 1; 75 | -webkit-opacity: 1; 76 | } 77 | `; 78 | case OPT_STYLE_DIY: // 自定义 79 | return textDiyStyle; 80 | default: 81 | return ``; 82 | } 83 | }} 84 | `; 85 | 86 | export default function Content({ q, keeps, translator, $el }) { 87 | const [rule, setRule] = useState(translator.rule); 88 | const { text, sameLang, loading } = useTranslate(q, rule, translator.setting); 89 | const { 90 | transOpen, 91 | textStyle, 92 | bgColor, 93 | textDiyStyle, 94 | transOnly, 95 | transTag, 96 | transEndHook, 97 | } = rule; 98 | 99 | const { newlineLength } = translator.setting; 100 | 101 | const handleKissEvent = (e) => { 102 | const { action, args } = e.detail; 103 | switch (action) { 104 | case MSG_TRANS_CURRULE: 105 | setRule(args); 106 | break; 107 | default: 108 | } 109 | }; 110 | 111 | useEffect(() => { 112 | window.addEventListener(translator.eventName, handleKissEvent); 113 | return () => { 114 | window.removeEventListener(translator.eventName, handleKissEvent); 115 | }; 116 | }, [translator.eventName]); 117 | 118 | useEffect(() => { 119 | // 运行钩子函数 120 | if (text && transEndHook?.trim()) { 121 | interpreter.run(`exports.transEndHook = ${transEndHook}`); 122 | interpreter.exports.transEndHook($el, q, text, keeps); 123 | } 124 | }, [$el, q, text, keeps, transEndHook]); 125 | 126 | const gap = useMemo(() => { 127 | if (transOnly === "true") { 128 | return ""; 129 | } 130 | return q.length >= newlineLength ?
: " "; 131 | }, [q, transOnly, newlineLength]); 132 | 133 | const styles = useMemo( 134 | () => ({ 135 | textStyle, 136 | textDiyStyle, 137 | bgColor, 138 | as: transTag, 139 | }), 140 | [textStyle, textDiyStyle, bgColor, transTag] 141 | ); 142 | 143 | if (loading) { 144 | return ( 145 | <> 146 | {gap} 147 | 148 | 149 | ); 150 | } 151 | 152 | if (!text || sameLang) { 153 | return; 154 | } 155 | 156 | if ( 157 | transOnly === "true" && 158 | transOpen === "true" && 159 | $el.querySelector(APP_LCNAME) 160 | ) { 161 | Array.from($el.childNodes).forEach((el) => { 162 | if (el.localName !== APP_LCNAME) { 163 | el.remove(); 164 | } 165 | }); 166 | } 167 | 168 | if (keeps.length > 0) { 169 | return ( 170 | <> 171 | {gap} 172 | keeps[parseInt(p)]), 176 | }} 177 | /> 178 | 179 | ); 180 | } 181 | 182 | return ( 183 | <> 184 | {gap} 185 | {text} 186 | 187 | ); 188 | } 189 | -------------------------------------------------------------------------------- /src/views/Options/About.js: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box"; 2 | import CircularProgress from "@mui/material/CircularProgress"; 3 | import ReactMarkdown from "react-markdown"; 4 | import { useI18n, useI18nMd } from "../../hooks/I18n"; 5 | 6 | export default function About() { 7 | const i18n = useI18n(); 8 | const [data, loading, error] = useI18nMd("about_md"); 9 | return ( 10 | 11 | {loading ? ( 12 |
13 | 14 |
15 | ) : ( 16 | 17 | )} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/views/Options/DarkModeButton.js: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton"; 2 | import { useDarkMode } from "../../hooks/ColorMode"; 3 | import LightModeIcon from "@mui/icons-material/LightMode"; 4 | import DarkModeIcon from "@mui/icons-material/DarkMode"; 5 | 6 | export default function DarkModeButton() { 7 | const { darkMode, toggleDarkMode } = useDarkMode(); 8 | return ( 9 | 10 | {darkMode ? : } 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/views/Options/DownloadButton.js: -------------------------------------------------------------------------------- 1 | import FileDownloadIcon from "@mui/icons-material/FileDownload"; 2 | import LoadingButton from "@mui/lab/LoadingButton"; 3 | import { useState } from "react"; 4 | import { kissLog } from "../../libs/log"; 5 | 6 | export default function DownloadButton({ handleData, text, fileName }) { 7 | const [loading, setLoading] = useState(false); 8 | const handleClick = async (e) => { 9 | e.preventDefault(); 10 | try { 11 | setLoading(true); 12 | const data = await handleData(); 13 | const url = window.URL.createObjectURL(new Blob([data])); 14 | const link = document.createElement("a"); 15 | link.href = url; 16 | link.setAttribute("download", fileName || `${Date.now()}.json`); 17 | document.body.appendChild(link); 18 | link.click(); 19 | link.remove(); 20 | } catch (err) { 21 | kissLog(err, "download"); 22 | } finally { 23 | setLoading(false); 24 | } 25 | }; 26 | return ( 27 | } 33 | > 34 | {text} 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/views/Options/FavWords.js: -------------------------------------------------------------------------------- 1 | import Stack from "@mui/material/Stack"; 2 | import { useState } from "react"; 3 | import Typography from "@mui/material/Typography"; 4 | import Accordion from "@mui/material/Accordion"; 5 | import AccordionSummary from "@mui/material/AccordionSummary"; 6 | import AccordionDetails from "@mui/material/AccordionDetails"; 7 | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; 8 | import CircularProgress from "@mui/material/CircularProgress"; 9 | import { useI18n } from "../../hooks/I18n"; 10 | import Box from "@mui/material/Box"; 11 | import { useFavWords } from "../../hooks/FavWords"; 12 | import DictCont from "../Selection/DictCont"; 13 | import SugCont from "../Selection/SugCont"; 14 | import DownloadButton from "./DownloadButton"; 15 | import UploadButton from "./UploadButton"; 16 | import Button from "@mui/material/Button"; 17 | import ClearAllIcon from "@mui/icons-material/ClearAll"; 18 | import { isValidWord } from "../../libs/utils"; 19 | import { kissLog } from "../../libs/log"; 20 | import { apiTranslate } from "../../apis"; 21 | import { OPT_TRANS_BAIDU, PHONIC_MAP } from "../../config"; 22 | 23 | function FavAccordion({ word, index }) { 24 | const [expanded, setExpanded] = useState(false); 25 | 26 | const handleChange = (e) => { 27 | setExpanded((pre) => !pre); 28 | }; 29 | 30 | return ( 31 | 32 | }> 33 | {/* {`[${new Date( 34 | createdAt 35 | ).toLocaleString()}] ${word}`} */} 36 | {`${index + 1}. ${word}`} 37 | 38 | 39 | {expanded && ( 40 | 41 | 42 | 43 | 44 | )} 45 | 46 | 47 | ); 48 | } 49 | 50 | export default function FavWords() { 51 | const i18n = useI18n(); 52 | const { loading, favWords, mergeWords, clearWords } = useFavWords(); 53 | const favList = Object.entries(favWords).sort((a, b) => 54 | a[0].localeCompare(b[0]) 55 | ); 56 | const downloadList = favList.map(([word]) => word); 57 | 58 | const handleImport = async (data) => { 59 | try { 60 | const newWords = data 61 | .split("\n") 62 | .map((line) => line.split(",")[0].trim()) 63 | .filter(isValidWord); 64 | await mergeWords(newWords); 65 | } catch (err) { 66 | kissLog(err, "import rules"); 67 | } 68 | }; 69 | 70 | const handleTranslation = async () => { 71 | const tranList = []; 72 | for (const text of downloadList) { 73 | try { 74 | const dictRes = await apiTranslate({ 75 | text, 76 | translator: OPT_TRANS_BAIDU, 77 | fromLang: "en", 78 | toLang: "zh-CN", 79 | }); 80 | if (dictRes[2]?.type === 1) { 81 | tranList.push(JSON.parse(dictRes[2].result)); 82 | } 83 | } catch (err) { 84 | // skip 85 | } 86 | } 87 | 88 | return tranList 89 | .map((dictResult) => 90 | [ 91 | `## ${dictResult.src}`, 92 | dictResult.voice 93 | ?.map(Object.entries) 94 | .map((item) => item[0]) 95 | .map(([key, val]) => `${PHONIC_MAP[key]?.[0] || key} ${val}`) 96 | .join(" "), 97 | dictResult.content[0].mean 98 | .map(({ pre, cont }) => { 99 | return ` - ${pre ? `[${pre}] ` : ""}${Object.keys(cont).join( 100 | "; " 101 | )}`; 102 | }) 103 | .join("\n"), 104 | ].join("\n\n") 105 | ) 106 | .join("\n\n"); 107 | }; 108 | 109 | return ( 110 | 111 | 112 | 119 | 125 | downloadList.join("\n")} 127 | text={i18n("export")} 128 | fileName={`kiss-words_${Date.now()}.txt`} 129 | /> 130 | 135 | 145 | 146 | 147 | 148 | {loading ? ( 149 | 150 | ) : ( 151 | favList.map(([word, { createdAt }], index) => ( 152 | 158 | )) 159 | )} 160 | 161 | 162 | 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/views/Options/Header.js: -------------------------------------------------------------------------------- 1 | import AppBar from "@mui/material/AppBar"; 2 | import IconButton from "@mui/material/IconButton"; 3 | import MenuIcon from "@mui/icons-material/Menu"; 4 | import Toolbar from "@mui/material/Toolbar"; 5 | import Box from "@mui/material/Box"; 6 | import Link from "@mui/material/Link"; 7 | import { useI18n } from "../../hooks/I18n"; 8 | import DarkModeButton from "./DarkModeButton"; 9 | import Typography from "@mui/material/Typography"; 10 | 11 | function Header(props) { 12 | const i18n = useI18n(); 13 | const { onDrawerToggle } = props; 14 | 15 | return ( 16 | 23 | 24 | 25 | 31 | 32 | 33 | 34 | 35 | {`${i18n("app_name")} v${process.env.REACT_APP_VERSION}`} 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | export default Header; 49 | -------------------------------------------------------------------------------- /src/views/Options/HelpButton.js: -------------------------------------------------------------------------------- 1 | import Button from "@mui/material/Button"; 2 | import { useI18n } from "../../hooks/I18n"; 3 | import HelpIcon from "@mui/icons-material/Help"; 4 | 5 | export default function HelpButton({ url }) { 6 | const i18n = useI18n(); 7 | return ( 8 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/views/Options/InputSetting.js: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box"; 2 | import Stack from "@mui/material/Stack"; 3 | import TextField from "@mui/material/TextField"; 4 | import MenuItem from "@mui/material/MenuItem"; 5 | import { useI18n } from "../../hooks/I18n"; 6 | import { 7 | OPT_TRANS_ALL, 8 | OPT_LANGS_FROM, 9 | OPT_LANGS_TO, 10 | OPT_INPUT_TRANS_SIGNS, 11 | } from "../../config"; 12 | import ShortcutInput from "./ShortcutInput"; 13 | import FormControlLabel from "@mui/material/FormControlLabel"; 14 | import Switch from "@mui/material/Switch"; 15 | import { useInputRule } from "../../hooks/InputRule"; 16 | import { useCallback } from "react"; 17 | import Grid from "@mui/material/Grid"; 18 | import { limitNumber } from "../../libs/utils"; 19 | 20 | export default function InputSetting() { 21 | const i18n = useI18n(); 22 | const { inputRule, updateInputRule } = useInputRule(); 23 | 24 | const handleChange = (e) => { 25 | e.preventDefault(); 26 | let { name, value } = e.target; 27 | switch (name) { 28 | case "triggerTime": 29 | value = limitNumber(value, 10, 1000); 30 | break; 31 | default: 32 | } 33 | updateInputRule({ 34 | [name]: value, 35 | }); 36 | }; 37 | 38 | const handleShortcutInput = useCallback( 39 | (val) => { 40 | updateInputRule({ triggerShortcut: val }); 41 | }, 42 | [updateInputRule] 43 | ); 44 | 45 | const { 46 | transOpen, 47 | translator, 48 | fromLang, 49 | toLang, 50 | triggerShortcut, 51 | triggerCount, 52 | triggerTime, 53 | transSign, 54 | } = inputRule; 55 | 56 | return ( 57 | 58 | 59 | { 66 | updateInputRule({ transOpen: !transOpen }); 67 | }} 68 | /> 69 | } 70 | label={i18n("use_input_box_translation")} 71 | /> 72 | 73 | 81 | {OPT_TRANS_ALL.map((item) => ( 82 | 83 | {item} 84 | 85 | ))} 86 | 87 | 88 | 96 | {OPT_LANGS_FROM.map(([lang, name]) => ( 97 | 98 | {name} 99 | 100 | ))} 101 | 102 | 103 | 111 | {OPT_LANGS_TO.map(([lang, name]) => ( 112 | 113 | {name} 114 | 115 | ))} 116 | 117 | 118 | 127 | {i18n("style_none")} 128 | {OPT_INPUT_TRANS_SIGNS.map((item) => ( 129 | 130 | {item} 131 | 132 | ))} 133 | 134 | 135 | 136 | 137 | 138 | 144 | 145 | 146 | 155 | {[1, 2, 3, 4, 5].map((val) => ( 156 | 157 | {val} 158 | 159 | ))} 160 | 161 | 162 | 163 | 172 | 173 | 174 | 175 | 176 | 177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /src/views/Options/Layout.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Outlet, useLocation } from "react-router-dom"; 3 | import useMediaQuery from "@mui/material/useMediaQuery"; 4 | import CssBaseline from "@mui/material/CssBaseline"; 5 | import Box from "@mui/material/Box"; 6 | import Navigator from "./Navigator"; 7 | import Header from "./Header"; 8 | import { useTheme } from "@mui/material/styles"; 9 | 10 | export default function Layout() { 11 | const navWidth = 256; 12 | const location = useLocation(); 13 | const theme = useTheme(); 14 | const [open, setOpen] = useState(false); 15 | const isSm = useMediaQuery(theme.breakpoints.up("sm")); 16 | 17 | const handleDrawerToggle = () => { 18 | setOpen(!open); 19 | }; 20 | 21 | useEffect(() => { 22 | setOpen(false); 23 | }, [location]); 24 | 25 | return ( 26 | 27 | 28 |
29 | 30 | 31 | 35 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/views/Options/Navigator.js: -------------------------------------------------------------------------------- 1 | import Drawer from "@mui/material/Drawer"; 2 | import List from "@mui/material/List"; 3 | import ListItemButton from "@mui/material/ListItemButton"; 4 | import ListItemIcon from "@mui/material/ListItemIcon"; 5 | import ListItemText from "@mui/material/ListItemText"; 6 | import Toolbar from "@mui/material/Toolbar"; 7 | import { NavLink, useMatch } from "react-router-dom"; 8 | import SettingsIcon from "@mui/icons-material/Settings"; 9 | import InfoIcon from "@mui/icons-material/Info"; 10 | import DesignServicesIcon from "@mui/icons-material/DesignServices"; 11 | import { useI18n } from "../../hooks/I18n"; 12 | import SyncIcon from "@mui/icons-material/Sync"; 13 | import ApiIcon from "@mui/icons-material/Api"; 14 | import InputIcon from "@mui/icons-material/Input"; 15 | import SelectAllIcon from "@mui/icons-material/SelectAll"; 16 | import EventNoteIcon from "@mui/icons-material/EventNote"; 17 | 18 | function LinkItem({ label, url, icon }) { 19 | const match = useMatch(url); 20 | return ( 21 | 22 | {icon} 23 | {label} 24 | 25 | ); 26 | } 27 | 28 | export default function Navigator(props) { 29 | const i18n = useI18n(); 30 | const memus = [ 31 | { 32 | id: "basic_setting", 33 | label: i18n("basic_setting"), 34 | url: "/", 35 | icon: , 36 | }, 37 | { 38 | id: "rules_setting", 39 | label: i18n("rules_setting"), 40 | url: "/rules", 41 | icon: , 42 | }, 43 | { 44 | id: "input_translate", 45 | label: i18n("input_translate"), 46 | url: "/input", 47 | icon: , 48 | }, 49 | { 50 | id: "selection_translate", 51 | label: i18n("selection_translate"), 52 | url: "/tranbox", 53 | icon: , 54 | }, 55 | { 56 | id: "apis_setting", 57 | label: i18n("apis_setting"), 58 | url: "/apis", 59 | icon: , 60 | }, 61 | { 62 | id: "sync", 63 | label: i18n("sync_setting"), 64 | url: "/sync", 65 | icon: , 66 | }, 67 | { 68 | id: "words", 69 | label: i18n("favorite_words"), 70 | url: "/words", 71 | icon: , 72 | }, 73 | { id: "about", label: i18n("about"), url: "/about", icon: }, 74 | ]; 75 | return ( 76 | 77 | 78 | 79 | {memus.map(({ id, label, url, icon }) => ( 80 | 81 | ))} 82 | 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/views/Options/OwSubRule.js: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box"; 2 | import Stack from "@mui/material/Stack"; 3 | import TextField from "@mui/material/TextField"; 4 | import { 5 | GLOBAL_KEY, 6 | REMAIN_KEY, 7 | OPT_LANGS_FROM, 8 | OPT_LANGS_TO, 9 | OPT_TRANS_ALL, 10 | OPT_STYLE_ALL, 11 | OPT_STYLE_DIY, 12 | OPT_STYLE_USE_COLOR, 13 | } from "../../config"; 14 | import { useI18n } from "../../hooks/I18n"; 15 | import MenuItem from "@mui/material/MenuItem"; 16 | import Grid from "@mui/material/Grid"; 17 | import { useOwSubRule } from "../../hooks/SubRules"; 18 | 19 | export default function OwSubRule() { 20 | const i18n = useI18n(); 21 | const { owSubrule, updateOwSubrule } = useOwSubRule(); 22 | 23 | const handleChange = (e) => { 24 | e.preventDefault(); 25 | const { name, value } = e.target; 26 | updateOwSubrule({ [name]: value }); 27 | }; 28 | 29 | const { 30 | translator, 31 | fromLang, 32 | toLang, 33 | textStyle, 34 | transOpen, 35 | bgColor, 36 | textDiyStyle, 37 | } = owSubrule; 38 | 39 | const RemainItem = ( 40 | 41 | {i18n("remain_unchanged")} 42 | 43 | ); 44 | 45 | const GlobalItem = ( 46 | 47 | {GLOBAL_KEY} 48 | 49 | ); 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | 65 | {RemainItem} 66 | {GlobalItem} 67 | {i18n("default_enabled")} 68 | {i18n("default_disabled")} 69 | 70 | 71 | 72 | 81 | {RemainItem} 82 | {GlobalItem} 83 | {OPT_TRANS_ALL.map((item) => ( 84 | 85 | {item} 86 | 87 | ))} 88 | 89 | 90 | 91 | 100 | {RemainItem} 101 | {GlobalItem} 102 | {OPT_LANGS_FROM.map(([lang, name]) => ( 103 | 104 | {name} 105 | 106 | ))} 107 | 108 | 109 | 110 | 119 | {RemainItem} 120 | {GlobalItem} 121 | {OPT_LANGS_TO.map(([lang, name]) => ( 122 | 123 | {name} 124 | 125 | ))} 126 | 127 | 128 | 129 | 138 | {RemainItem} 139 | {GlobalItem} 140 | {OPT_STYLE_ALL.map((item) => ( 141 | 142 | {i18n(item)} 143 | 144 | ))} 145 | 146 | 147 | {OPT_STYLE_USE_COLOR.includes(textStyle) && ( 148 | 149 | 157 | 158 | )} 159 | 160 | 161 | 162 | {textStyle === OPT_STYLE_DIY && ( 163 | 172 | )} 173 | 174 | ); 175 | } 176 | -------------------------------------------------------------------------------- /src/views/Options/ShortcutInput.js: -------------------------------------------------------------------------------- 1 | import Stack from "@mui/material/Stack"; 2 | import TextField from "@mui/material/TextField"; 3 | import IconButton from "@mui/material/IconButton"; 4 | import EditIcon from "@mui/icons-material/Edit"; 5 | import { useEffect, useState, useRef } from "react"; 6 | import { shortcutListener } from "../../libs/shortcut"; 7 | 8 | export default function ShortcutInput({ value, onChange, label, helperText }) { 9 | const [disabled, setDisabled] = useState(true); 10 | const inputRef = useRef(null); 11 | 12 | useEffect(() => { 13 | if (disabled) { 14 | return; 15 | } 16 | 17 | inputRef.current.focus(); 18 | onChange([]); 19 | 20 | const clearShortcut = shortcutListener((curkeys, allkeys) => { 21 | onChange(allkeys); 22 | if (curkeys.length === 0) { 23 | setDisabled(true); 24 | } 25 | }, inputRef.current); 26 | 27 | return () => { 28 | clearShortcut(); 29 | }; 30 | }, [disabled, onChange]); 31 | 32 | return ( 33 | 34 | (item === " " ? "Space" : item)).join(" + ")} 39 | fullWidth 40 | inputRef={inputRef} 41 | disabled={disabled} 42 | onBlur={() => { 43 | setDisabled(true); 44 | }} 45 | helperText={helperText} 46 | /> 47 | { 49 | setDisabled(false); 50 | }} 51 | > 52 | {} 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/views/Options/SyncSetting.js: -------------------------------------------------------------------------------- 1 | import Box from "@mui/material/Box"; 2 | import Stack from "@mui/material/Stack"; 3 | import TextField from "@mui/material/TextField"; 4 | import { useI18n } from "../../hooks/I18n"; 5 | import { useSync } from "../../hooks/Sync"; 6 | import Alert from "@mui/material/Alert"; 7 | import Link from "@mui/material/Link"; 8 | import MenuItem from "@mui/material/MenuItem"; 9 | import LoadingButton from "@mui/lab/LoadingButton"; 10 | import { 11 | URL_KISS_WORKER, 12 | OPT_SYNCTYPE_ALL, 13 | OPT_SYNCTYPE_WORKER, 14 | OPT_SYNCTYPE_WEBDAV, 15 | } from "../../config"; 16 | import { useState } from "react"; 17 | import { syncSettingAndRules } from "../../libs/sync"; 18 | import { useAlert } from "../../hooks/Alert"; 19 | import SyncIcon from "@mui/icons-material/Sync"; 20 | import { useSetting } from "../../hooks/Setting"; 21 | import { kissLog } from "../../libs/log"; 22 | 23 | export default function SyncSetting() { 24 | const i18n = useI18n(); 25 | const { sync, updateSync } = useSync(); 26 | const alert = useAlert(); 27 | const [loading, setLoading] = useState(false); 28 | const { reloadSetting } = useSetting(); 29 | 30 | const handleChange = async (e) => { 31 | e.preventDefault(); 32 | const { name, value } = e.target; 33 | await updateSync({ 34 | [name]: value, 35 | }); 36 | }; 37 | 38 | const handleSyncTest = async (e) => { 39 | e.preventDefault(); 40 | try { 41 | setLoading(true); 42 | await syncSettingAndRules(); 43 | await reloadSetting(); 44 | alert.success(i18n("sync_success")); 45 | } catch (err) { 46 | kissLog(err, "sync all"); 47 | alert.error(i18n("sync_failed")); 48 | } finally { 49 | setLoading(false); 50 | } 51 | }; 52 | 53 | if (!sync) { 54 | return; 55 | } 56 | 57 | const { 58 | syncType = OPT_SYNCTYPE_WORKER, 59 | syncUrl = "", 60 | syncUser = "", 61 | syncKey = "", 62 | } = sync; 63 | 64 | return ( 65 | 66 | 67 | {i18n("sync_warn")} 68 | 69 | 77 | {OPT_SYNCTYPE_ALL.map((item) => ( 78 | 79 | {item} 80 | 81 | ))} 82 | 83 | 84 | 93 | {i18n("about_sync_api")} 94 | 95 | ) 96 | } 97 | /> 98 | 99 | {syncType === OPT_SYNCTYPE_WEBDAV && ( 100 | 107 | )} 108 | 109 | 117 | 118 | 125 | } 131 | loading={loading} 132 | > 133 | {i18n("sync_now")} 134 | 135 | 136 | 137 | 138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /src/views/Options/UploadButton.js: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import FileUploadIcon from "@mui/icons-material/FileUpload"; 3 | import { useI18n } from "../../hooks/I18n"; 4 | import Button from "@mui/material/Button"; 5 | 6 | export default function UploadButton({ 7 | handleImport, 8 | text, 9 | fileType = "json", 10 | fileExts = [".json"], 11 | }) { 12 | const i18n = useI18n(); 13 | const inputRef = useRef(null); 14 | const handleClick = () => { 15 | if (inputRef.current) { 16 | inputRef.current.click(); 17 | inputRef.current.value = null; 18 | } 19 | }; 20 | const onChange = (e) => { 21 | const file = e.target.files[0]; 22 | if (!file) { 23 | return; 24 | } 25 | 26 | if (!file.type.includes(fileType)) { 27 | alert(i18n("error_wrong_file_type")); 28 | return; 29 | } 30 | 31 | const reader = new FileReader(); 32 | reader.onload = async (e) => { 33 | handleImport(e.target.result); 34 | }; 35 | reader.readAsText(file); 36 | }; 37 | 38 | return ( 39 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/views/Options/index.js: -------------------------------------------------------------------------------- 1 | import { Routes, Route, HashRouter } from "react-router-dom"; 2 | import About from "./About"; 3 | import Rules from "./Rules"; 4 | import Setting from "./Setting"; 5 | import Layout from "./Layout"; 6 | import SyncSetting from "./SyncSetting"; 7 | import { SettingProvider } from "../../hooks/Setting"; 8 | import ThemeProvider from "../../hooks/Theme"; 9 | import { useEffect, useState } from "react"; 10 | import { isGm } from "../../libs/client"; 11 | import { sleep } from "../../libs/utils"; 12 | import CircularProgress from "@mui/material/CircularProgress"; 13 | import { trySyncSettingAndRules } from "../../libs/sync"; 14 | import { AlertProvider } from "../../hooks/Alert"; 15 | import Link from "@mui/material/Link"; 16 | import Divider from "@mui/material/Divider"; 17 | import Stack from "@mui/material/Stack"; 18 | import { adaptScript } from "../../libs/gm"; 19 | import Alert from "@mui/material/Alert"; 20 | import Apis from "./Apis"; 21 | import InputSetting from "./InputSetting"; 22 | import Tranbox from "./Tranbox"; 23 | import FavWords from "./FavWords"; 24 | 25 | export default function Options() { 26 | const [error, setError] = useState(""); 27 | const [ready, setReady] = useState(false); 28 | 29 | useEffect(() => { 30 | (async () => { 31 | if (isGm) { 32 | // 等待GM注入 33 | let i = 0; 34 | for (;;) { 35 | if (window?.APP_INFO?.name === process.env.REACT_APP_NAME) { 36 | const { version, eventName } = window.APP_INFO; 37 | 38 | // 检查版本是否一致 39 | if (version !== process.env.REACT_APP_VERSION) { 40 | setError( 41 | `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})。` 42 | ); 43 | return; 44 | } 45 | 46 | if (eventName) { 47 | // 注入GM接口 48 | adaptScript(eventName); 49 | } 50 | 51 | break; 52 | } 53 | 54 | if (++i > 8) { 55 | setError( 56 | "Time out. Please confirm whether to install or enable KISS Translator GreaseMonkey script? 连接超时,请检查是否安装或启用简约翻译油猴脚本。" 57 | ); 58 | return; 59 | } 60 | 61 | await sleep(1000); 62 | } 63 | } 64 | 65 | // 同步数据 66 | await trySyncSettingAndRules(); 67 | setReady(true); 68 | })(); 69 | }, []); 70 | 71 | if (error) { 72 | return ( 73 |
74 | 75 | {`KISS Translator v${process.env.REACT_APP_VERSION}`} 78 | 79 | {error} 80 | 81 | 82 | Install/Update Userscript for Tampermonkey/Violentmonkey 83 | 84 | 85 | Install/Update Userscript for iOS Safari 86 | 87 | 88 |
89 | ); 90 | } 91 | 92 | if (!ready) { 93 | return ( 94 |
95 | 96 | {`KISS Translator v${process.env.REACT_APP_VERSION}`} 99 | 100 | 101 |
102 | ); 103 | } 104 | 105 | return ( 106 | 107 | 108 | 109 | 110 | 111 | }> 112 | } /> 113 | } /> 114 | } /> 115 | } /> 116 | } /> 117 | } /> 118 | } /> 119 | } /> 120 | 121 | 122 | 123 | 124 | 125 | 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /src/views/Popup/Header.js: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton"; 2 | import CloseIcon from "@mui/icons-material/Close"; 3 | import HomeIcon from "@mui/icons-material/Home"; 4 | import Stack from "@mui/material/Stack"; 5 | import DarkModeButton from "../Options/DarkModeButton"; 6 | import Typography from "@mui/material/Typography"; 7 | 8 | export default function Header({ setShowPopup }) { 9 | const handleHomepage = () => { 10 | window.open(process.env.REACT_APP_HOMEPAGE, "_blank"); 11 | }; 12 | 13 | return ( 14 | 20 | 21 | 22 | 23 | 24 | 32 | {`${process.env.REACT_APP_NAME} v${process.env.REACT_APP_VERSION}`} 33 | 34 | 35 | 36 | {setShowPopup ? ( 37 | { 39 | setShowPopup(false); 40 | }} 41 | > 42 | 43 | 44 | ) : ( 45 | 46 | )} 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/views/Selection/AudioBtn.js: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton"; 2 | import VolumeUpIcon from "@mui/icons-material/VolumeUp"; 3 | import { useTextAudio } from "../../hooks/Audio"; 4 | 5 | export default function AudioBtn({ text, lan = "uk" }) { 6 | const { error, ready, playing, onPlay } = useTextAudio(text, lan); 7 | 8 | if (error || !ready) { 9 | return ( 10 | 11 | 12 | 13 | ); 14 | } 15 | 16 | if (playing) { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/views/Selection/CopyBtn.js: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton"; 2 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 3 | import LibraryAddCheckIcon from "@mui/icons-material/LibraryAddCheck"; 4 | import { useState } from "react"; 5 | 6 | export default function CopyBtn({ text }) { 7 | const [copied, setCopied] = useState(false); 8 | const handleClick = async (e) => { 9 | e.stopPropagation(); 10 | await navigator.clipboard.writeText(text); 11 | setCopied(true); 12 | const timer = setTimeout(() => { 13 | clearTimeout(timer); 14 | setCopied(false); 15 | }, 500); 16 | }; 17 | return ( 18 | 28 | {copied ? ( 29 | 30 | ) : ( 31 | 32 | )} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/views/Selection/DictCont.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Stack from "@mui/material/Stack"; 3 | import FavBtn from "./FavBtn"; 4 | import Typography from "@mui/material/Typography"; 5 | import AudioBtn from "./AudioBtn"; 6 | import CircularProgress from "@mui/material/CircularProgress"; 7 | import Alert from "@mui/material/Alert"; 8 | import { OPT_TRANS_BAIDU, PHONIC_MAP } from "../../config"; 9 | import { apiTranslate } from "../../apis"; 10 | import { isValidWord } from "../../libs/utils"; 11 | import CopyBtn from "./CopyBtn"; 12 | 13 | export default function DictCont({ text }) { 14 | const [loading, setLoading] = useState(true); 15 | const [error, setError] = useState(""); 16 | const [dictResult, setDictResult] = useState(null); 17 | 18 | useEffect(() => { 19 | (async () => { 20 | try { 21 | setLoading(true); 22 | setError(""); 23 | setDictResult(null); 24 | 25 | if (!isValidWord(text)) { 26 | return; 27 | } 28 | 29 | const dictRes = await apiTranslate({ 30 | text, 31 | translator: OPT_TRANS_BAIDU, 32 | fromLang: "en", 33 | toLang: "zh-CN", 34 | }); 35 | 36 | if (dictRes[2]?.type === 1) { 37 | setDictResult(JSON.parse(dictRes[2].result)); 38 | } 39 | } catch (err) { 40 | setError(err.message); 41 | } finally { 42 | setLoading(false); 43 | } 44 | })(); 45 | }, [text]); 46 | 47 | if (error) { 48 | return {error}; 49 | } 50 | 51 | if (loading) { 52 | return ; 53 | } 54 | 55 | if (!text || !dictResult) { 56 | return; 57 | } 58 | 59 | const copyText = [ 60 | dictResult.src, 61 | dictResult.voice 62 | ?.map(Object.entries) 63 | .map((item) => item[0]) 64 | .map(([key, val]) => `${PHONIC_MAP[key]?.[0] || key} ${val}`) 65 | .join(" "), 66 | dictResult.content[0].mean 67 | .map(({ pre, cont }) => { 68 | return `${pre ? `[${pre}] ` : ""}${Object.keys(cont).join("; ")}`; 69 | }) 70 | .join("\n"), 71 | ].join("\n"); 72 | 73 | return ( 74 | 75 | 76 | 77 | {dictResult.src} 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | {dictResult.voice 88 | ?.map(Object.entries) 89 | .map((item) => item[0]) 90 | .map(([key, val]) => ( 91 | 96 | {`${ 97 | PHONIC_MAP[key]?.[0] || key 98 | } ${val}`} 99 | 100 | 101 | ))} 102 | 103 | 104 | 105 | {dictResult.content[0].mean.map(({ pre, cont }, idx) => ( 106 | 107 | {pre && `[${pre}] `} 108 | {Object.keys(cont).join("; ")} 109 | 110 | ))} 111 | 112 | 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/views/Selection/DraggableResizable.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Paper from "@mui/material/Paper"; 3 | import Box from "@mui/material/Box"; 4 | import { isMobile } from "../../libs/mobile"; 5 | 6 | function Pointer({ 7 | direction, 8 | size, 9 | setSize, 10 | position, 11 | setPosition, 12 | children, 13 | minSize, 14 | maxSize, 15 | ...props 16 | }) { 17 | const [origin, setOrigin] = useState(null); 18 | 19 | function handlePointerDown(e) { 20 | !isMobile && e.target.setPointerCapture(e.pointerId); 21 | const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; 22 | setOrigin({ 23 | x: position.x, 24 | y: position.y, 25 | w: size.w, 26 | h: size.h, 27 | clientX, 28 | clientY, 29 | }); 30 | } 31 | 32 | function handlePointerMove(e) { 33 | const { clientX, clientY } = isMobile ? e.targetTouches[0] : e; 34 | if (origin) { 35 | const dx = clientX - origin.clientX; 36 | const dy = clientY - origin.clientY; 37 | let x = position.x; 38 | let y = position.y; 39 | let w = size.w; 40 | let h = size.h; 41 | 42 | switch (direction) { 43 | case "Header": 44 | x = origin.x + dx; 45 | y = origin.y + dy; 46 | break; 47 | case "TopLeft": 48 | x = origin.x + dx; 49 | y = origin.y + dy; 50 | w = origin.w - dx; 51 | h = origin.h - dy; 52 | break; 53 | case "Top": 54 | y = origin.y + dy; 55 | h = origin.h - dy; 56 | break; 57 | case "TopRight": 58 | y = origin.y + dy; 59 | w = origin.w + dx; 60 | h = origin.h - dy; 61 | break; 62 | case "Left": 63 | x = origin.x + dx; 64 | w = origin.w - dx; 65 | break; 66 | case "Right": 67 | w = origin.w + dx; 68 | break; 69 | case "BottomLeft": 70 | x = origin.x + dx; 71 | w = origin.w - dx; 72 | h = origin.h + dy; 73 | break; 74 | case "Bottom": 75 | h = origin.h + dy; 76 | break; 77 | case "BottomRight": 78 | w = origin.w + dx; 79 | h = origin.h + dy; 80 | break; 81 | default: 82 | } 83 | 84 | if (w < minSize.w) { 85 | w = minSize.w; 86 | x = position.x; 87 | } 88 | if (w > maxSize.w) { 89 | w = maxSize.w; 90 | x = position.x; 91 | } 92 | if (h < minSize.h) { 93 | h = minSize.h; 94 | y = position.y; 95 | } 96 | if (h > maxSize.h) { 97 | h = maxSize.h; 98 | y = position.y; 99 | } 100 | 101 | setPosition({ x, y }); 102 | setSize({ w, h }); 103 | } 104 | } 105 | 106 | function handlePointerUp(e) { 107 | e.stopPropagation(); 108 | setOrigin(null); 109 | } 110 | 111 | const touchProps = isMobile 112 | ? { 113 | onTouchStart: handlePointerDown, 114 | onTouchMove: handlePointerMove, 115 | onTouchEnd: handlePointerUp, 116 | } 117 | : { 118 | onPointerDown: handlePointerDown, 119 | onPointerMove: handlePointerMove, 120 | onPointerUp: handlePointerUp, 121 | }; 122 | 123 | return ( 124 |
125 | {children} 126 |
127 | ); 128 | } 129 | 130 | export default function DraggableResizable({ 131 | header, 132 | children, 133 | position = { 134 | x: 0, 135 | y: 0, 136 | }, 137 | size = { 138 | w: 600, 139 | h: 400, 140 | }, 141 | minSize = { 142 | w: 300, 143 | h: 200, 144 | }, 145 | maxSize = { 146 | w: 1200, 147 | h: 1200, 148 | }, 149 | setSize, 150 | setPosition, 151 | onChangeSize, 152 | onChangePosition, 153 | ...props 154 | }) { 155 | const lineWidth = 4; 156 | const opts = { 157 | size, 158 | setSize, 159 | position, 160 | setPosition, 161 | minSize, 162 | maxSize, 163 | }; 164 | 165 | return ( 166 | 180 | 188 | 197 | 205 | 214 | 215 | 221 | {header} 222 | 223 | 231 | {children} 232 | 233 | 234 | 243 | 251 | 260 | 268 | 269 | ); 270 | } 271 | -------------------------------------------------------------------------------- /src/views/Selection/FavBtn.js: -------------------------------------------------------------------------------- 1 | import IconButton from "@mui/material/IconButton"; 2 | import FavoriteIcon from "@mui/icons-material/Favorite"; 3 | import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder"; 4 | import { useState } from "react"; 5 | import { useFavWords } from "../../hooks/FavWords"; 6 | import { kissLog } from "../../libs/log"; 7 | 8 | export default function FavBtn({ word }) { 9 | const { favWords, toggleFav } = useFavWords(); 10 | const [loading, setLoading] = useState(false); 11 | 12 | const handleClick = async () => { 13 | try { 14 | setLoading(true); 15 | await toggleFav(word); 16 | } catch (err) { 17 | kissLog(err, "set fav"); 18 | } finally { 19 | setLoading(false); 20 | } 21 | }; 22 | 23 | return ( 24 | 25 | {favWords[word] ? ( 26 | 27 | ) : ( 28 | 29 | )} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/views/Selection/SugCont.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Typography from "@mui/material/Typography"; 3 | import { apiBaiduSuggest } from "../../apis"; 4 | import Stack from "@mui/material/Stack"; 5 | 6 | export default function SugCont({ text }) { 7 | const [sugs, setSugs] = useState([]); 8 | 9 | useEffect(() => { 10 | (async () => { 11 | try { 12 | setSugs(await apiBaiduSuggest(text)); 13 | } catch (err) { 14 | // skip 15 | } 16 | })(); 17 | }, [text]); 18 | 19 | if (sugs.length === 0) { 20 | return; 21 | } 22 | 23 | return ( 24 | 25 | {sugs.map(({ k, v }) => ( 26 | 27 | {k} 28 | 29 | {v} 30 | 31 | 32 | ))} 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/views/Selection/TranBtn.js: -------------------------------------------------------------------------------- 1 | import { isMobile } from "../../libs/mobile"; 2 | import { limitNumber } from "../../libs/utils"; 3 | 4 | export default function TranBtn({ 5 | onTrigger, 6 | btnEvent, 7 | position, 8 | btnOffsetX, 9 | btnOffsetY, 10 | }) { 11 | const left = limitNumber(position.x + btnOffsetX, 0, window.innerWidth - 32); 12 | const top = limitNumber(position.y + btnOffsetY, 0, window.innerHeight - 32); 13 | 14 | return ( 15 |
27 | 34 | 39 | 44 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/views/Selection/TranCont.js: -------------------------------------------------------------------------------- 1 | import TextField from "@mui/material/TextField"; 2 | import Box from "@mui/material/Box"; 3 | import CircularProgress from "@mui/material/CircularProgress"; 4 | import Stack from "@mui/material/Stack"; 5 | import { useI18n } from "../../hooks/I18n"; 6 | import { DEFAULT_TRANS_APIS } from "../../config"; 7 | import { useEffect, useState } from "react"; 8 | import { apiTranslate } from "../../apis"; 9 | import CopyBtn from "./CopyBtn"; 10 | import Typography from "@mui/material/Typography"; 11 | import Alert from "@mui/material/Alert"; 12 | import { tryDetectLang } from "../../libs"; 13 | 14 | export default function TranCont({ 15 | text, 16 | translator, 17 | fromLang, 18 | toLang, 19 | toLang2 = "en", 20 | transApis, 21 | simpleStyle, 22 | langDetector, 23 | }) { 24 | const i18n = useI18n(); 25 | const [trText, setTrText] = useState(""); 26 | const [loading, setLoading] = useState(true); 27 | const [error, setError] = useState(""); 28 | 29 | useEffect(() => { 30 | (async () => { 31 | try { 32 | setLoading(true); 33 | setTrText(""); 34 | setError(""); 35 | 36 | let to = toLang; 37 | if (toLang !== toLang2 && toLang2 !== "none") { 38 | const detectLang = await tryDetectLang(text, true, langDetector); 39 | if (detectLang === toLang) { 40 | to = toLang2; 41 | } 42 | } 43 | 44 | const apiSetting = 45 | transApis[translator] || DEFAULT_TRANS_APIS[translator]; 46 | const tranRes = await apiTranslate({ 47 | text, 48 | translator, 49 | fromLang, 50 | toLang: to, 51 | apiSetting, 52 | }); 53 | setTrText(tranRes[0]); 54 | } catch (err) { 55 | setError(err.message); 56 | } finally { 57 | setLoading(false); 58 | } 59 | })(); 60 | }, [text, translator, fromLang, toLang, toLang2, transApis, langDetector]); 61 | 62 | if (simpleStyle) { 63 | return ( 64 | 65 | {error ? ( 66 | {error} 67 | ) : loading ? ( 68 | 69 | ) : ( 70 | {trText} 71 | )} 72 | 73 | ); 74 | } 75 | 76 | return ( 77 | 78 | : null, 88 | endAdornment: ( 89 | 97 | 98 | 99 | ), 100 | }} 101 | /> 102 | 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /src/views/Selection/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useMemo } from "react"; 2 | import TranBtn from "./TranBtn"; 3 | import TranBox from "./TranBox"; 4 | import { shortcutRegister } from "../../libs/shortcut"; 5 | import { sleep, limitNumber } from "../../libs/utils"; 6 | import { isGm, isExt } from "../../libs/client"; 7 | import { 8 | MSG_OPEN_TRANBOX, 9 | DEFAULT_TRANBOX_SHORTCUT, 10 | OPT_TRANBOX_TRIGGER_CLICK, 11 | OPT_TRANBOX_TRIGGER_HOVER, 12 | OPT_TRANBOX_TRIGGER_SELECT, 13 | OPT_DICT_BAIDU, 14 | } from "../../config"; 15 | import { isMobile } from "../../libs/mobile"; 16 | import { kissLog } from "../../libs/log"; 17 | import { useLangMap } from "../../hooks/I18n"; 18 | 19 | export default function Slection({ 20 | contextMenuType, 21 | tranboxSetting, 22 | transApis, 23 | uiLang, 24 | langDetector, 25 | }) { 26 | const { 27 | hideTranBtn = false, 28 | simpleStyle: initSimpleStyle = false, 29 | hideClickAway: initHideClickAway = false, 30 | followSelection: initFollowMouse = false, 31 | tranboxShortcut = DEFAULT_TRANBOX_SHORTCUT, 32 | triggerMode = OPT_TRANBOX_TRIGGER_CLICK, 33 | extStyles, 34 | btnOffsetX, 35 | btnOffsetY, 36 | boxOffsetX = 0, 37 | boxOffsetY = 10, 38 | enDict = OPT_DICT_BAIDU, 39 | } = tranboxSetting; 40 | 41 | const boxWidth = 42 | isMobile || initSimpleStyle 43 | ? 300 44 | : limitNumber(window.innerWidth, 300, 600); 45 | const boxHeight = 46 | isMobile || initSimpleStyle 47 | ? 200 48 | : limitNumber(window.innerHeight, 200, 400); 49 | 50 | const langMap = useLangMap(uiLang); 51 | const [showBox, setShowBox] = useState(false); 52 | const [showBtn, setShowBtn] = useState(false); 53 | const [selectedText, setSelText] = useState(""); 54 | const [text, setText] = useState(""); 55 | const [position, setPosition] = useState({ x: 0, y: 0 }); 56 | const [boxSize, setBoxSize] = useState({ 57 | w: boxWidth, 58 | h: boxHeight, 59 | }); 60 | const [boxPosition, setBoxPosition] = useState({ 61 | x: (window.innerWidth - boxWidth) / 2, 62 | y: (window.innerHeight - boxHeight) / 2, 63 | }); 64 | const [simpleStyle, setSimpleStyle] = useState(initSimpleStyle); 65 | const [hideClickAway, setHideClickAway] = useState(initHideClickAway); 66 | const [followSelection, setFollowSelection] = useState(initFollowMouse); 67 | 68 | const handleTrigger = useCallback( 69 | (text) => { 70 | setShowBtn(false); 71 | setText(text || selectedText); 72 | setShowBox(true); 73 | }, 74 | [selectedText] 75 | ); 76 | 77 | const handleTranbox = useCallback(() => { 78 | setShowBtn(false); 79 | 80 | const selection = window.getSelection(); 81 | const selectedText = selection?.toString()?.trim() || ""; 82 | if (!selectedText) { 83 | setShowBox((pre) => !pre); 84 | return; 85 | } 86 | 87 | const rect = selection?.getRangeAt(0)?.getBoundingClientRect(); 88 | if (rect && followSelection) { 89 | const x = (rect.left + rect.right) / 2 + boxOffsetX; 90 | const y = rect.bottom + boxOffsetY; 91 | setBoxPosition({ 92 | x: limitNumber(x, 0, window.innerWidth - 300), 93 | y: limitNumber(y, 0, window.innerHeight - 200), 94 | }); 95 | } 96 | 97 | setSelText(selectedText); 98 | setText(selectedText); 99 | setShowBox(true); 100 | }, [followSelection, boxOffsetX, boxOffsetY]); 101 | 102 | const btnEvent = useMemo(() => { 103 | if (isMobile) { 104 | return "onTouchEnd"; 105 | } else if (triggerMode === OPT_TRANBOX_TRIGGER_HOVER) { 106 | return "onMouseOver"; 107 | } 108 | return "onMouseUp"; 109 | }, [triggerMode]); 110 | 111 | useEffect(() => { 112 | async function handleMouseup(e) { 113 | e.stopPropagation(); 114 | await sleep(200); 115 | 116 | const selection = window.getSelection(); 117 | const selectedText = selection?.toString()?.trim() || ""; 118 | setSelText(selectedText); 119 | if (!selectedText) { 120 | setShowBtn(false); 121 | return; 122 | } 123 | 124 | const rect = selection?.getRangeAt(0)?.getBoundingClientRect(); 125 | if (rect && followSelection) { 126 | const x = (rect.left + rect.right) / 2 + boxOffsetX; 127 | const y = rect.bottom + boxOffsetY; 128 | setBoxPosition({ 129 | x: limitNumber(x, 0, window.innerWidth - 300), 130 | y: limitNumber(y, 0, window.innerHeight - 200), 131 | }); 132 | } 133 | 134 | if (triggerMode === OPT_TRANBOX_TRIGGER_SELECT) { 135 | handleTrigger(selectedText); 136 | return; 137 | } 138 | 139 | const { clientX, clientY } = isMobile ? e.changedTouches[0] : e; 140 | setShowBtn(!hideTranBtn); 141 | setPosition({ x: clientX, y: clientY }); 142 | } 143 | 144 | // todo: mobile support 145 | // window.addEventListener("mouseup", handleMouseup); 146 | window.addEventListener(isMobile ? "touchend" : "mouseup", handleMouseup); 147 | return () => { 148 | window.removeEventListener( 149 | isMobile ? "touchend" : "mouseup", 150 | handleMouseup 151 | ); 152 | }; 153 | }, [ 154 | hideTranBtn, 155 | triggerMode, 156 | followSelection, 157 | boxOffsetX, 158 | boxOffsetY, 159 | handleTrigger, 160 | ]); 161 | 162 | useEffect(() => { 163 | if (isExt) { 164 | return; 165 | } 166 | const clearShortcut = shortcutRegister(tranboxShortcut, handleTranbox); 167 | return () => { 168 | clearShortcut(); 169 | }; 170 | }, [tranboxShortcut, handleTranbox]); 171 | 172 | useEffect(() => { 173 | window.addEventListener(MSG_OPEN_TRANBOX, handleTranbox); 174 | return () => { 175 | window.removeEventListener(MSG_OPEN_TRANBOX, handleTranbox); 176 | }; 177 | }, [handleTranbox]); 178 | 179 | useEffect(() => { 180 | if (!isGm) { 181 | return; 182 | } 183 | 184 | // 注册菜单 185 | try { 186 | const menuCommandIds = []; 187 | contextMenuType !== 0 && 188 | menuCommandIds.push( 189 | GM.registerMenuCommand( 190 | langMap("translate_selected_text"), 191 | (event) => { 192 | handleTranbox(); 193 | }, 194 | "S" 195 | ) 196 | ); 197 | 198 | return () => { 199 | menuCommandIds.forEach((id) => { 200 | GM.unregisterMenuCommand(id); 201 | }); 202 | }; 203 | } catch (err) { 204 | kissLog(err, "registerMenuCommand"); 205 | } 206 | }, [handleTranbox, contextMenuType, langMap]); 207 | 208 | useEffect(() => { 209 | if (hideClickAway) { 210 | const handleHideBox = () => { 211 | setShowBox(false); 212 | }; 213 | window.addEventListener("click", handleHideBox); 214 | return () => { 215 | window.removeEventListener("click", handleHideBox); 216 | }; 217 | } 218 | }, [hideClickAway]); 219 | 220 | return ( 221 | <> 222 | {showBox && ( 223 | 243 | )} 244 | 245 | {showBtn && ( 246 | { 252 | e.stopPropagation(); 253 | handleTrigger(); 254 | }} 255 | /> 256 | )} 257 | 258 | ); 259 | } 260 | --------------------------------------------------------------------------------