├── .babelrc ├── .browserslistrc ├── .commitlintrc.json ├── .env ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── .node-version ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── screenshot.png └── store.png ├── babel-plugin-macros.config.js ├── package.json ├── postcss.config.js ├── scripts ├── build.sh ├── env.js └── webserver.js ├── src ├── assets │ └── img │ │ ├── icon-128.png │ │ ├── icon-34.png │ │ └── logo.svg ├── common │ ├── api.ts │ ├── constant.ts │ ├── logger.ts │ ├── ocr-client.ts │ ├── rangy.ts │ └── types.ts ├── components │ ├── Icon.tsx │ ├── IconButton.tsx │ └── svg │ │ ├── ArrowRight.tsx │ │ ├── ClipboardCopy.tsx │ │ ├── Close.tsx │ │ ├── CursorClick.tsx │ │ ├── LoadingCircle.tsx │ │ └── Refresh.tsx ├── global.d.ts ├── manifest.json ├── pages │ ├── Background │ │ ├── common │ │ │ ├── server.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ └── index.ts │ ├── Content │ │ ├── common │ │ │ ├── client.ts │ │ │ ├── polyfill.ts │ │ │ ├── server.ts │ │ │ ├── translation-stack.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── components │ │ │ ├── App │ │ │ │ └── index.tsx │ │ │ ├── OCRTool │ │ │ │ └── index.tsx │ │ │ ├── TranslationItem │ │ │ │ └── index.tsx │ │ │ └── TranslationList │ │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── providers │ │ │ ├── config.tsx │ │ │ └── translate-jobs.tsx │ │ └── styles │ │ │ └── index.scss │ ├── Options │ │ ├── Options.tsx │ │ ├── components │ │ │ └── OptionSection.tsx │ │ ├── index.html │ │ └── index.tsx │ └── Popup │ │ ├── Popup.css │ │ ├── Popup.jsx │ │ ├── index.css │ │ ├── index.html │ │ └── index.jsx ├── sass.d.ts └── twin.d.ts ├── tailwind.config.js ├── tsconfig.eslint.json ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "entry", 7 | "corejs": "3.8.2" 8 | } 9 | ], 10 | "@babel/preset-typescript", 11 | [ 12 | "@babel/preset-react", 13 | { "runtime": "automatic", "importSource": "@emotion/react" } 14 | ] 15 | ], 16 | "plugins": [ 17 | "@babel/plugin-proposal-class-properties", 18 | "babel-plugin-transform-inline-environment-variables", 19 | "babel-plugin-macros", 20 | ["@emotion/babel-plugin", { 21 | "sourceMap": true, 22 | "autoLabel": "dev-only", 23 | "labelFormat": "[local]", 24 | "cssPropOptimization": true 25 | }], 26 | "react-hot-loader/babel" 27 | ] 28 | } -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 10 Chrome versions 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-angular"], 3 | "rules": { 4 | "subject-case": [ 5 | 1, 6 | "always", 7 | [ 8 | "sentence-case", 9 | "start-case", 10 | "pascal-case", 11 | "upper-case", 12 | "lower-case", 13 | "camel-case" 14 | ] 15 | ], 16 | "type-enum": [ 17 | 2, 18 | "always", 19 | [ 20 | "build", 21 | "chore", 22 | "ci", 23 | "docs", 24 | "feat", 25 | "fix", 26 | "perf", 27 | "refactor", 28 | "revert", 29 | "release", 30 | "style", 31 | "test", 32 | "sample" 33 | ] 34 | ] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | USE_MOCK_TRANSLATE=false -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build 2 | /release 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:react/recommended', 8 | 'plugin:react-hooks/recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | project: './tsconfig.eslint.json', 15 | sourceType: 'module', 16 | }, 17 | plugins: ['@typescript-eslint'], 18 | globals: { 19 | chrome: 'readonly', 20 | }, 21 | rules: { 22 | semi: ['error', 'never', { beforeStatementContinuationChars: 'never' }], 23 | '@typescript-eslint/ban-ts-comment': 0, 24 | '@typescript-eslint/no-var-requires': 0, 25 | 'react/prop-types': 0, 26 | }, 27 | settings: { 28 | react: { 29 | version: 'detect', 30 | }, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: geekdada 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | node-version: [16] 15 | steps: 16 | - name: Fetch repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | 24 | - name: Cache node modules 25 | uses: actions/cache@v2 26 | with: 27 | path: node_modules 28 | key: ${{ runner.OS }}-build-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.OS }}-build-${{ env.cache-name }}- 31 | ${{ runner.OS }}-build- 32 | ${{ runner.OS }}- 33 | 34 | - name: yarn install, build, bundle 35 | run: | 36 | yarn install 37 | yarn release 38 | 39 | - uses: ncipollo/release-action@v1 40 | with: 41 | artifacts: 'release/*.zip' 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | draft: true 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | /release 12 | 13 | # misc 14 | .DS_Store 15 | .env.development 16 | .env.test 17 | .env.production 18 | .idea 19 | .vscode 20 | 21 | # secrets 22 | secrets.*.js 23 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | jsxBracketSameLine: true, 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.4.2](https://github.com/geekdada/deepl-chrome-extension/compare/v0.4.1...v0.4.2) (2023-02-25) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * pipeline ([6e08697](https://github.com/geekdada/deepl-chrome-extension/commit/6e086978705479ee7e741e11378f0e0f5241bbf5)) 7 | 8 | 9 | 10 | ## [0.4.1](https://github.com/geekdada/deepl-chrome-extension/compare/v0.4.0...v0.4.1) (2023-02-25) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * DeepL API authorization ([38486d0](https://github.com/geekdada/deepl-chrome-extension/commit/38486d0acec2e75a77c23c794999a97a6a753251)) 16 | 17 | 18 | 19 | # [0.4.0](https://github.com/geekdada/deepl-chrome-extension/compare/v0.3.1...v0.4.0) (2021-07-03) 20 | 21 | 22 | ### Features 23 | 24 | * add support for DeepL free ([beac66c](https://github.com/geekdada/deepl-chrome-extension/commit/beac66c78250240fd911dad65458bbe029bf44d7)) 25 | * change name ([c47f4f2](https://github.com/geekdada/deepl-chrome-extension/commit/c47f4f2726533da75a16d288cf39602d07bee567)) 26 | * support multiple tencent ocr regions ([2bd674c](https://github.com/geekdada/deepl-chrome-extension/commit/2bd674c3857b89439b5c4eaccc2f61ff33b8a3cc)) 27 | * support new deepl languages ([72d66da](https://github.com/geekdada/deepl-chrome-extension/commit/72d66dabc211f453398e866afa9b397432a32e3e)) 28 | 29 | 30 | 31 | ## [0.3.1](https://github.com/geekdada/deepl-chrome-extension/compare/v0.3.0...v0.3.1) (2021-02-14) 32 | 33 | 34 | 35 | # [0.3.0](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0...v0.3.0) (2021-02-14) 36 | 37 | 38 | ### Features 39 | 40 | * remove support for a translator ([3eee95a](https://github.com/geekdada/deepl-chrome-extension/commit/3eee95aafb6543052a492734b806dba1907cb701)) 41 | 42 | 43 | 44 | # [0.2.0](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-5...v0.2.0) (2021-01-22) 45 | 46 | 47 | ### Features 48 | 49 | * remove window.alert ([1a5048c](https://github.com/geekdada/deepl-chrome-extension/commit/1a5048c2d45dc321afc0bb9cfec3dd9bf470f675)) 50 | 51 | 52 | 53 | # [0.2.0-5](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-4...v0.2.0-5) (2021-01-22) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * use chinese ([b3b6188](https://github.com/geekdada/deepl-chrome-extension/commit/b3b61880af68cae845f4c83f12f23c503b49f3b2)) 59 | 60 | 61 | ### Features 62 | 63 | * add config for hover button ([477de29](https://github.com/geekdada/deepl-chrome-extension/commit/477de29b86033267db3acb95f042bdbbc1425786)) 64 | * add context menus ([ccf87a5](https://github.com/geekdada/deepl-chrome-extension/commit/ccf87a512664d45c236e0017ccd613ef39d8352e)) 65 | * keyboard shortcuts ([c26cf83](https://github.com/geekdada/deepl-chrome-extension/commit/c26cf83fc92225367cf676e736e773d66f7ed075)) 66 | 67 | 68 | 69 | # [0.2.0-4](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-3...v0.2.0-4) (2021-01-21) 70 | 71 | 72 | ### Features 73 | 74 | * ocr text for translating ([72b58ed](https://github.com/geekdada/deepl-chrome-extension/commit/72b58ede93a68032d2b4979e46ae32f35803a9ea)) 75 | * turn off ocr tool when the selection is too small ([0e13070](https://github.com/geekdada/deepl-chrome-extension/commit/0e130708c7c87d1e3e6929509e65abbccff3db8b)) 76 | 77 | 78 | 79 | # [0.2.0-3](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-2...v0.2.0-3) (2021-01-20) 80 | 81 | 82 | ### Features 83 | 84 | * change target language directly in webpage ([fdb574b](https://github.com/geekdada/deepl-chrome-extension/commit/fdb574b8210ce6914f64f2a8cfecdde13cdf7ad6)) 85 | * retry button ([2b287d3](https://github.com/geekdada/deepl-chrome-extension/commit/2b287d3fec67c01f25a8a8d7b57c3c0e02962b29)) 86 | 87 | 88 | 89 | # [0.2.0-2](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-1...v0.2.0-2) (2021-01-18) 90 | 91 | 92 | ### Bug Fixes 93 | 94 | * conflict with emotion 10 application ([699948e](https://github.com/geekdada/deepl-chrome-extension/commit/699948e1c1f93f43b4e939b4b4652213d1973423)) 95 | * style typo ([4e46590](https://github.com/geekdada/deepl-chrome-extension/commit/4e4659034f871b25ed7a64d24147eec04c542b6e)) 96 | 97 | 98 | 99 | # [0.2.0-1](https://github.com/geekdada/deepl-chrome-extension/compare/v0.2.0-0...v0.2.0-1) (2021-01-18) 100 | 101 | 102 | 103 | # [0.2.0-0](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.6...v0.2.0-0) (2021-01-17) 104 | 105 | 106 | ### Features 107 | 108 | * add polyfill for old browsers ([0e38a77](https://github.com/geekdada/deepl-chrome-extension/commit/0e38a77ea2fe224ae5086d2930ce9d17cc89530a)) 109 | * optimize string selection ([7ea8ac7](https://github.com/geekdada/deepl-chrome-extension/commit/7ea8ac71fb88146e46b6608bc5eeacae659762ac)) 110 | 111 | 112 | 113 | ## [0.1.6](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.5...v0.1.6) (2021-01-10) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * app positioning ([7bbb68b](https://github.com/geekdada/deepl-chrome-extension/commit/7bbb68bc475234679277c400476e3ee0c3440cf9)) 119 | 120 | 121 | 122 | ## [0.1.5](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.4...v0.1.5) (2021-01-08) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * close button becomes white on some occasions ([b5c22eb](https://github.com/geekdada/deepl-chrome-extension/commit/b5c22ebb2520c35cb3ba79fe22dbe052470ed0d4)) 128 | * remove tabs permission ([5ad6bd5](https://github.com/geekdada/deepl-chrome-extension/commit/5ad6bd55e34e508373fb99f42d77f7f7e4b51d1b)) 129 | 130 | 131 | 132 | ## [0.1.4](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.3...v0.1.4) (2021-01-06) 133 | 134 | 135 | 136 | ## [0.1.3](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.2...v0.1.3) (2021-01-06) 137 | 138 | 139 | ### Bug Fixes 140 | 141 | * scroll bug ([f9204ba](https://github.com/geekdada/deepl-chrome-extension/commit/f9204ba32b8f20f7388d7501e885cf7fabad49b8)) 142 | 143 | 144 | ### Features 145 | 146 | * add link to dashboard ([8c48313](https://github.com/geekdada/deepl-chrome-extension/commit/8c48313f7e9d2f378398d35f8a7887645cb54de7)) 147 | 148 | 149 | 150 | ## [0.1.2](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.1...v0.1.2) (2021-01-05) 151 | 152 | 153 | ### Bug Fixes 154 | 155 | * style issue ([becf98e](https://github.com/geekdada/deepl-chrome-extension/commit/becf98eb50bbc1f6db0a7117203ca15fead89fe5)) 156 | 157 | 158 | ### Features 159 | 160 | * add notification ([ca78404](https://github.com/geekdada/deepl-chrome-extension/commit/ca784049d4e0a70d8ee598988df690e51bdf1f8b)) 161 | 162 | 163 | 164 | ## [0.1.1](https://github.com/geekdada/deepl-chrome-extension/compare/v0.1.0...v0.1.1) (2021-01-05) 165 | 166 | 167 | ### Bug Fixes 168 | 169 | * setting value becomes undefined during the first time use ([2cfbf26](https://github.com/geekdada/deepl-chrome-extension/commit/2cfbf26e2b836821ae339af51a1c18dc2ee7e469)) 170 | 171 | 172 | 173 | # [0.1.0](https://github.com/geekdada/deepl-chrome-extension/compare/9aa9d4392aedb375fe540fe9f64a0a8732b1c8cc...v0.1.0) (2021-01-05) 174 | 175 | 176 | ### Bug Fixes 177 | 178 | * should not fire icon within app ([1d0c484](https://github.com/geekdada/deepl-chrome-extension/commit/1d0c484b2fd3d21b29575230aa22b35660d44e8f)) 179 | * show err object ([92c1aef](https://github.com/geekdada/deepl-chrome-extension/commit/92c1aefd54140c19f73cd309918e1c34672b47ae)) 180 | 181 | 182 | ### Features 183 | 184 | * add close button to App ([e43c057](https://github.com/geekdada/deepl-chrome-extension/commit/e43c057ad46564c535ccae3c3da9532497ac009f)) 185 | * add copy button ([b9ff17e](https://github.com/geekdada/deepl-chrome-extension/commit/b9ff17e676e09cf37abb67da2be10033eec72156)) 186 | * add feedback ([3cd3ad3](https://github.com/geekdada/deepl-chrome-extension/commit/3cd3ad3de43b3bf70a98f73d6f79b56fae3dcbd6)) 187 | * add notification ([ee01fd7](https://github.com/geekdada/deepl-chrome-extension/commit/ee01fd7b4cdcf11e94b2357a601240205bb69f53)) 188 | * collapse the original ([ea7a335](https://github.com/geekdada/deepl-chrome-extension/commit/ea7a33573345618021b0da67ddd8edae9c6adb72)) 189 | * custom logger ([d30f3d5](https://github.com/geekdada/deepl-chrome-extension/commit/d30f3d51f25501496fba67bbd1a733e415e1f196)) 190 | * options page ([9aa9d43](https://github.com/geekdada/deepl-chrome-extension/commit/9aa9d4392aedb375fe540fe9f64a0a8732b1c8cc)) 191 | * paragraph margin ([09abfd0](https://github.com/geekdada/deepl-chrome-extension/commit/09abfd0f169f6f5c24be92bf44e82336f96ceec7)) 192 | * reset app position ([9632247](https://github.com/geekdada/deepl-chrome-extension/commit/9632247e23bf44c1165649f2a84fa6bbae7a8f1d)) 193 | * select to translate ([942d35f](https://github.com/geekdada/deepl-chrome-extension/commit/942d35f5db43271aaf57a0e1e44e49c0bf4f35b9)) 194 | * tranlsation list ([c8a80f4](https://github.com/geekdada/deepl-chrome-extension/commit/c8a80f4f2641f41a481f6d32931c8546d0a94b62)) 195 | 196 | 197 | 198 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Roy Li 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeepL Translate for Chrome 2 | 3 | Chrome DeepL 翻译插件 (需自己购买 DeepL API 计划)。 4 | 5 | ## 安装 6 | 7 | ### Chrome Web Store 8 | 9 | [](https://chrome.google.com/webstore/detail/a-translator/bpcbgnkijachkbknbhjmijehipcphndd) 10 | 11 | ### 离线安装插件(Edge, 360 浏览器、猎豹浏览器等) 12 | 13 | - 点击 [这里](https://github.com/geekdada/a-translator-chrome-extension/releases/latest/download/extension.zip) 下载最新版本的插件; 14 | - 解压文件; 15 | - 参考 [教程](https://www.notion.so/geekdada/6b5d9c86e9654681b30df86ca242876c) 安装; 16 | 17 | ## 配置 18 | 19 | 请在插件配置页面填入 DeepL 的 API Token。 20 | 21 | ## 使用 22 | 23 | 在网页内选择内容即可点击图标翻译。 24 | 25 |  26 | 27 | 更详尽的使用文档请前往 [这里](https://www.notion.so/geekdada/Chrome-95ba478ceca745f8b98b4a1d06c0f8dd) 28 | 29 | ## License 30 | 31 | [MIT](./LICENSE) 32 | -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekdada/deepl-chrome-extension/7b55c8739b06721e9b56a08fed1c796e947dc291/assets/screenshot.png -------------------------------------------------------------------------------- /assets/store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekdada/deepl-chrome-extension/7b55c8739b06721e9b56a08fed1c796e947dc291/assets/store.png -------------------------------------------------------------------------------- /babel-plugin-macros.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | twin: { 3 | preset: 'emotion', 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deepl-chrome-extension", 3 | "version": "0.4.2", 4 | "description": "DeepL translate for Chrome", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/geekdada/deepl-chrome-extension" 9 | }, 10 | "scripts": { 11 | "start": "cross-env NODE_ENV=development node scripts/webserver.js", 12 | "build": "cross-env NODE_ENV=production webpack", 13 | "release": "run-s build && sh scripts/build.sh", 14 | "prettier": "prettier --write '**/*.{js,jsx,ts,tsx,css,html}'", 15 | "lint": "eslint . --ext .ts,.tsx,.js,.jsx", 16 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 17 | "pub": "np --no-publish --no-release-draft", 18 | "version": "npm run changelog && git add ." 19 | }, 20 | "dependencies": { 21 | "@babel/core": "^7.12.10", 22 | "@babel/plugin-proposal-class-properties": "^7.12.1", 23 | "@babel/preset-env": "^7.12.11", 24 | "@babel/preset-react": "^7.12.10", 25 | "@babel/preset-typescript": "^7.12.7", 26 | "@commitlint/cli": "^11.0.0", 27 | "@commitlint/config-angular": "^11.0.0", 28 | "@emotion/babel-plugin": "^11.1.2", 29 | "@emotion/cache": "^11.1.3", 30 | "@emotion/css": "^11.1.3", 31 | "@emotion/react": "^11.1.4", 32 | "@emotion/server": "^11.0.0", 33 | "@emotion/styled": "^11.0.0", 34 | "@hot-loader/react-dom": "^17.0.1", 35 | "@mui/material": "^5.9.3", 36 | "@tailwindcss/forms": "^0.2.1", 37 | "@types/chrome": "0.0.193", 38 | "@types/crypto-js": "^4.0.1", 39 | "@types/fs-extra": "^9.0.6", 40 | "@types/lodash-es": "^4.17.4", 41 | "@types/node": "^16", 42 | "@types/pino": "^6.3.4", 43 | "@types/rangy": "^0.0.33", 44 | "@types/react": "^17.0.0", 45 | "@types/react-collapse": "^5.0.0", 46 | "@types/react-dom": "^17.0.0", 47 | "@types/react-resizable": "^1.7.2", 48 | "@types/uuid": "^8.3.0", 49 | "@typescript-eslint/eslint-plugin": "^4.5.0", 50 | "@typescript-eslint/parser": "^4.5.0", 51 | "autoprefixer": "^10.1.0", 52 | "axios": "^0.21.1", 53 | "babel-loader": "^8.2.2", 54 | "babel-plugin-macros": "^3.0.1", 55 | "babel-plugin-transform-inline-environment-variables": "^0.4.3", 56 | "chrome-call": "^4.0.1", 57 | "clean-webpack-plugin": "^3.0.0", 58 | "connect.io": "^3.1.3", 59 | "conventional-changelog-cli": "^2.1.1", 60 | "copy-webpack-plugin": "^6.4.0", 61 | "core-js": "3", 62 | "cross-env": "^7.0.3", 63 | "crypto-js": "^4.0.0", 64 | "css-loader": "^5.0.1", 65 | "dotenv": "^8.2.0", 66 | "eslint": "^7.16.0", 67 | "eslint-config-prettier": "^8.3.0", 68 | "eslint-plugin-prettier": "^3.4.0", 69 | "eslint-plugin-react": "^7.20.6", 70 | "eslint-plugin-react-hooks": "^4.1.2", 71 | "file-loader": "^6.2.0", 72 | "fs-extra": "^9.0.1", 73 | "html-loader": "^1.3.2", 74 | "html-webpack-plugin": "^5.0.0-alpha.15", 75 | "husky": "^4.3.6", 76 | "lint-staged": "^10.5.3", 77 | "lodash-es": "^4.17.20", 78 | "mini-css-extract-plugin": "^1.3.3", 79 | "notistack": "^2.0.5", 80 | "np": "^7.2.0", 81 | "npm-run-all": "^4.1.5", 82 | "pino": "^6.9.0", 83 | "postcss": "^8.2.2", 84 | "postcss-import": "^14.0.0", 85 | "postcss-loader": "^4.1.0", 86 | "prettier": "^2.2.1", 87 | "process": "^0.11.10", 88 | "query-string": "^6.13.8", 89 | "rangy": "^1.3.0", 90 | "react": "^17.0.1", 91 | "react-clipboard.js": "^2.0.16", 92 | "react-collapse": "^5.1.0", 93 | "react-dom": "^17.0.1", 94 | "react-draggable": "^4.4.3", 95 | "react-hot-loader": "^4.13.0", 96 | "react-resizable": "^3.0.4", 97 | "react-scroll-to-bottom": "^4.1.0", 98 | "sass": "^1.54.3", 99 | "sass-loader": "^10.1.0", 100 | "scrollparent": "^2.0.1", 101 | "semver": "^7.3.4", 102 | "smoothscroll-polyfill": "^0.4.4", 103 | "source-map-loader": "^2.0.0", 104 | "style-loader": "^2.0.0", 105 | "tailwindcss": "^2.0.2", 106 | "terser-webpack-plugin": "^5.0.3", 107 | "twin.macro": "^2.0.7", 108 | "typescript": "4.7.4", 109 | "uuid": "^8.3.2", 110 | "webpack": "^5.10.1", 111 | "webpack-cli": "^4.2.0", 112 | "webpack-dev-server": "^3.11.0" 113 | }, 114 | "husky": { 115 | "hooks": { 116 | "pre-commit": "lint-staged", 117 | "commit-msg": "commitlint -c .commitlintrc.json -E HUSKY_GIT_PARAMS" 118 | } 119 | }, 120 | "lint-staged": { 121 | "*.js": "eslint", 122 | "*.jsx": "eslint", 123 | "*.ts": "eslint --ext .ts", 124 | "*.tsx": "eslint --ext .tsx" 125 | }, 126 | "engines": { 127 | "node": ">=16.0.0" 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('tailwindcss'), 5 | require('autoprefixer'), 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | mkdir -p ./release 6 | 7 | cd build && zip -r "../extension.zip" "./" && cd - 8 | mv extension.* ./release 9 | -------------------------------------------------------------------------------- /scripts/env.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | require('dotenv').config({ 4 | path: path.resolve(process.cwd(), '.env'), 5 | }) 6 | 7 | const NODE_ENV = process.env.NODE_ENV || 'development' 8 | 9 | require('dotenv').config({ 10 | path: path.resolve(process.cwd(), `.env.${NODE_ENV}`), 11 | }) 12 | 13 | // tiny wrapper with default env vars 14 | module.exports = { 15 | NODE_ENV, 16 | PORT: process.env.PORT || 3000, 17 | } 18 | -------------------------------------------------------------------------------- /scripts/webserver.js: -------------------------------------------------------------------------------- 1 | process.env.ASSET_PATH = '/' 2 | 3 | const WebpackDevServer = require('webpack-dev-server') 4 | const webpack = require('webpack') 5 | const config = require('../webpack.config') 6 | const env = require('./env') 7 | const path = require('path') 8 | 9 | const options = { 10 | notHotReload: ['contentScript'], 11 | } 12 | const excludeEntriesToHotReload = options.notHotReload || [] 13 | 14 | for (const entryName in config.entry) { 15 | if (excludeEntriesToHotReload.indexOf(entryName) === -1) { 16 | config.entry[entryName] = [ 17 | 'webpack-dev-server/client?http://localhost:' + env.PORT, 18 | 'webpack/hot/dev-server', 19 | ].concat(config.entry[entryName]) 20 | } 21 | } 22 | 23 | config.plugins = [new webpack.HotModuleReplacementPlugin()].concat( 24 | config.plugins || [], 25 | ) 26 | 27 | const compiler = webpack(config) 28 | 29 | const server = new WebpackDevServer(compiler, { 30 | https: false, 31 | hot: true, 32 | injectClient: false, 33 | writeToDisk: true, 34 | port: env.PORT, 35 | contentBase: path.join(__dirname, '../build'), 36 | publicPath: `http://localhost:${env.PORT}`, 37 | headers: { 38 | 'Access-Control-Allow-Origin': '*', 39 | }, 40 | disableHostCheck: true, 41 | }) 42 | 43 | server.listen(env.PORT) 44 | -------------------------------------------------------------------------------- /src/assets/img/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekdada/deepl-chrome-extension/7b55c8739b06721e9b56a08fed1c796e947dc291/src/assets/img/icon-128.png -------------------------------------------------------------------------------- /src/assets/img/icon-34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geekdada/deepl-chrome-extension/7b55c8739b06721e9b56a08fed1c796e947dc291/src/assets/img/icon-34.png -------------------------------------------------------------------------------- /src/assets/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/common/api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import qs from 'query-string' 3 | 4 | import { APIRegions, TranslateResult } from './types' 5 | 6 | class Client { 7 | axios = axios.create({ 8 | baseURL: this.getAPI(), 9 | }) 10 | 11 | constructor(private apiToken: string, private region: APIRegions) { 12 | this.axios.interceptors.response.use( 13 | function (response) { 14 | return response 15 | }, 16 | function (error) { 17 | if (error.response) { 18 | const { data, status } = error.response 19 | 20 | if (data?.message) { 21 | error.message = `${data.message} (${status})` 22 | } 23 | } 24 | return Promise.reject(error) 25 | }, 26 | ) 27 | } 28 | 29 | async translate(text: string, targetLang: string): Promise { 30 | return this.axios 31 | .post( 32 | '/v2/translate', 33 | qs.stringify({ 34 | target_lang: targetLang, 35 | split_sentences: '1', 36 | preserve_formatting: '0', 37 | text: text, 38 | }), 39 | { 40 | headers: { 41 | 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', 42 | Authorization: `DeepL-Auth-Key ${this.apiToken}`, 43 | }, 44 | responseType: 'json', 45 | }, 46 | ) 47 | .then((res) => res.data) 48 | } 49 | 50 | private getAPI(): string { 51 | switch (this.region) { 52 | case 'free': 53 | return 'https://api-free.deepl.com' 54 | default: 55 | return 'https://api.deepl.com' 56 | } 57 | } 58 | } 59 | 60 | export default Client 61 | -------------------------------------------------------------------------------- /src/common/constant.ts: -------------------------------------------------------------------------------- 1 | export const supportedLanguages = { 2 | ZH: '中文', 3 | BG: 'Bulgarian', 4 | CS: 'Czech', 5 | DA: 'Danish', 6 | DE: 'German', 7 | EL: 'Greek', 8 | 'EN-GB': 'English (British)', 9 | 'EN-US': 'English (American)', 10 | ES: 'Spanish', 11 | ET: 'Estonian', 12 | FI: 'Finnish', 13 | FR: 'French', 14 | HU: 'Hungarian', 15 | IT: 'Italian', 16 | JA: 'Japanese', 17 | LT: 'Lithuanian', 18 | LV: 'Latvian', 19 | NL: 'Dutch', 20 | PL: 'Polish', 21 | 'PT-PT': 'Portuguese', 22 | 'PT-BR': 'Portuguese (Brazilian)', 23 | RO: 'Romanian', 24 | RU: 'Russian', 25 | SK: 'Slovak', 26 | SL: 'Slovenian', 27 | SV: 'Swedish', 28 | } 29 | 30 | export const supportedRegions = { 31 | default: 'DeepL Pro', 32 | free: '免费', 33 | } 34 | -------------------------------------------------------------------------------- /src/common/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import omit from 'lodash-es/omit' 3 | 4 | const logger = pino({ 5 | level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', 6 | browser: { 7 | write(o) { 8 | // eslint-disable-next-line prefer-const 9 | let { level, msg, err } = o as { 10 | level: number 11 | msg: string 12 | err?: Error 13 | } 14 | if (!msg && err) { 15 | msg = err.message 16 | } 17 | 18 | const label = pino.levels.labels[level] 19 | const logs: any[] = [`[ATE] [${label.toUpperCase()}] ${msg || ''}`] 20 | const method = getMethod(level) 21 | const extra = omit(o, ['msg', 'level', 'time', 'err']) 22 | 23 | if (Object.keys(extra).length) { 24 | logs.push(extra) 25 | } 26 | 27 | if (err) { 28 | logs[0] += `\n${err.stack || err}` 29 | } 30 | 31 | method.apply(console, logs) 32 | }, 33 | }, 34 | }) 35 | 36 | function getMethod(level: number) { 37 | if (level <= 20) { 38 | return console.debug 39 | } else if (level <= 30) { 40 | return console.info 41 | } else if (level <= 40) { 42 | return console.warn 43 | } else { 44 | return console.error 45 | } 46 | } 47 | 48 | export default logger 49 | -------------------------------------------------------------------------------- /src/common/ocr-client.ts: -------------------------------------------------------------------------------- 1 | import defaultsDeep from 'lodash-es/defaultsDeep' 2 | import { SHA256, HmacSHA256, enc } from 'crypto-js' 3 | import axios from 'axios' 4 | 5 | export const OcrRegions = { 6 | 'ap-shanghai': '华东地区(上海)', 7 | 'ap-beijing': '华北地区(北京)', 8 | 'ap-guangzhou': '华南地区(广州)', 9 | 'ap-hongkong': '港澳台地区(中国香港)', 10 | 'ap-seoul': '亚太东北(首尔)', 11 | 'ap-singapore': '亚太东南(新加坡)', 12 | 'na-toronto': '北美地区(多伦多)', 13 | } 14 | export type OcrRegionKeys = keyof typeof OcrRegions 15 | 16 | export class TcRequestError extends Error { 17 | code?: string 18 | 19 | constructor(message: string, code?: string) { 20 | super(message + (code ? ` [${code}]` : '')) 21 | this.name = 'TcRequestError' 22 | if (code) this.code = code 23 | } 24 | } 25 | 26 | export class OcrClient { 27 | private config: { 28 | secretId: string 29 | secretKey: string 30 | region: OcrRegionKeys 31 | } 32 | private requestConfig = { 33 | host: 'ocr.tencentcloudapi.com', 34 | service: 'ocr', 35 | version: '2018-11-19', 36 | algorithm: 'TC3-HMAC-SHA256', 37 | httpRequestMethod: 'POST', 38 | canonicalUri: '/', 39 | canonicalQueryString: '', 40 | canonicalHeaders: 41 | 'content-type:application/json; charset=utf-8\nhost:ocr.tencentcloudapi.com\n', 42 | signedHeaders: 'content-type;host', 43 | } 44 | 45 | constructor(config: { 46 | secretId: string 47 | secretKey: string 48 | region?: OcrRegionKeys 49 | }) { 50 | this.config = defaultsDeep({}, config, { 51 | region: 'ap-shanghai', 52 | }) 53 | } 54 | 55 | async request(data: { dataUrl: string }): Promise { 56 | const payload = { 57 | ImageBase64: data.dataUrl, 58 | } 59 | const signature = this.signPayload(payload) 60 | const headers = { 61 | Authorization: signature.authorization, 62 | 'Content-Type': 'application/json; charset=UTF-8', 63 | 'X-TC-Action': 'GeneralBasicOCR', 64 | 'X-TC-Timestamp': signature.timestamp, 65 | 'X-TC-Version': this.requestConfig.version, 66 | 'X-TC-Region': this.config.region, 67 | 'X-TC-RequestClient': `WXAPP_SDK_OcrSDK_1.1.0`, 68 | } 69 | 70 | return axios 71 | .request({ 72 | url: 'https://ocr.tencentcloudapi.com', 73 | data: payload, 74 | method: 'POST', 75 | headers, 76 | responseType: 'json', 77 | }) 78 | .then((res) => { 79 | if (res.data?.Response?.Error) { 80 | const error = res.data.Response.Error 81 | throw new TcRequestError(error.Message, error.Code) 82 | } 83 | 84 | if (!res.data?.Response?.TextDetections) { 85 | throw new TcRequestError('没有数据返回') 86 | } 87 | 88 | const detections = res.data.Response.TextDetections 89 | const result: string[] = [] 90 | 91 | detections.forEach( 92 | (item: { DetectedText: string; AdvancedInfo: string }) => { 93 | const advanceInfo: { 94 | Parag: { 95 | ParagNo: number 96 | } 97 | } = JSON.parse(item.AdvancedInfo) 98 | const index = advanceInfo.Parag.ParagNo - 1 99 | 100 | if (result[index]) { 101 | result[index] += ` ${item.DetectedText}` 102 | } else { 103 | result.push(item.DetectedText) 104 | } 105 | }, 106 | ) 107 | 108 | return result 109 | }) 110 | } 111 | 112 | private signPayload(payload: Record): { 113 | authorization: string 114 | timestamp: number 115 | } { 116 | const hashedRequestPayload = SHA256(JSON.stringify(payload)) 117 | const canonicalRequest = [ 118 | this.requestConfig.httpRequestMethod, 119 | this.requestConfig.canonicalUri, 120 | this.requestConfig.canonicalQueryString, 121 | this.requestConfig.canonicalHeaders, 122 | this.requestConfig.signedHeaders, 123 | hashedRequestPayload, 124 | ].join('\n') 125 | const t = new Date() 126 | const date = t.toISOString().substr(0, 10) 127 | const timestamp = Math.round(t.getTime() / 1000) 128 | const credentialScope = `${date}/${this.requestConfig.service}/tc3_request` 129 | const hashedCanonicalRequest = SHA256(canonicalRequest) 130 | const stringToSign = [ 131 | this.requestConfig.algorithm, 132 | timestamp, 133 | credentialScope, 134 | hashedCanonicalRequest, 135 | ].join('\n') 136 | 137 | const secretDate = HmacSHA256(date, `TC3${this.config.secretKey}`) 138 | const secretService = HmacSHA256(this.requestConfig.service, secretDate) 139 | const secretSigning = HmacSHA256('tc3_request', secretService) 140 | 141 | const signature = enc.Hex.stringify(HmacSHA256(stringToSign, secretSigning)) 142 | 143 | return { 144 | authorization: 145 | `${this.requestConfig.algorithm} ` + 146 | `Credential=${this.config.secretId}/${credentialScope}, ` + 147 | `SignedHeaders=${this.requestConfig.signedHeaders}, ` + 148 | `Signature=${signature}`, 149 | timestamp, 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/common/rangy.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import * as rangy from 'rangy' 3 | // @ts-ignore 4 | import 'rangy/lib/rangy-classapplier' 5 | import 'rangy/lib/rangy-highlighter' 6 | 7 | export default rangy 8 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { supportedLanguages, supportedRegions } from './constant' 2 | import { OcrRegionKeys } from './ocr-client' 3 | 4 | export interface Config { 5 | token: string 6 | targetLang: SupportLanguageKeys 7 | region: APIRegions 8 | ocrSecretId?: string 9 | ocrSecretKey?: string 10 | ocrRegion?: OcrRegionKeys 11 | hoverButton?: boolean 12 | } 13 | 14 | export type SupportLanguageKeys = keyof typeof supportedLanguages 15 | export type SupportRegionKeys = keyof typeof supportedRegions 16 | 17 | export type APIRegions = 'default' | 'free' 18 | 19 | export type TranslateResult = { 20 | translations: Array<{ 21 | detected_source_language: SupportLanguageKeys 22 | text: string 23 | }> 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Icon: React.FC = (props) => { 4 | return {props.children} 5 | } 6 | 7 | export default Icon 8 | -------------------------------------------------------------------------------- /src/components/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | forwardRef, 3 | HTMLProps, 4 | MouseEventHandler, 5 | ReactNode, 6 | Ref, 7 | } from 'react' 8 | import tw from 'twin.macro' 9 | 10 | export interface BaseProps { 11 | children: ReactNode 12 | type?: 'button' | 'submit' | 'reset' | undefined 13 | ref?: Ref 14 | } 15 | type IconButtonProps = Omit, 'size'> & BaseProps 16 | 17 | const IconButton = forwardRef(function IconButton( 18 | props: IconButtonProps, 19 | ref?: BaseProps['ref'], 20 | ) { 21 | const handleClick: MouseEventHandler = (e) => { 22 | props.onClick && props.onClick(e) 23 | } 24 | 25 | return ( 26 | 36 | {props.children} 37 | 38 | ) 39 | }) 40 | 41 | export default IconButton 42 | -------------------------------------------------------------------------------- /src/components/svg/ArrowRight.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | const ArrowRight: React.FC> = (props) => { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default ArrowRight 24 | -------------------------------------------------------------------------------- /src/components/svg/ClipboardCopy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ClipboardCopy: React.FC = (props) => { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default ClipboardCopy 24 | -------------------------------------------------------------------------------- /src/components/svg/Close.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | const Close: React.FC> = (props) => { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default Close 24 | -------------------------------------------------------------------------------- /src/components/svg/CursorClick.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | const CursorClick: React.FC> = (props) => { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default CursorClick 24 | -------------------------------------------------------------------------------- /src/components/svg/LoadingCircle.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | import tw, { css } from 'twin.macro' 3 | 4 | const LoadingCircle: React.FC> = (props) => { 5 | return ( 6 | 13 | 21 | 26 | 27 | ) 28 | } 29 | 30 | export default LoadingCircle 31 | -------------------------------------------------------------------------------- /src/components/svg/Refresh.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | const Refresh: React.FC> = (props) => { 4 | return ( 5 | 13 | 19 | 20 | ) 21 | } 22 | 23 | export default Refresh 24 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'scrollparent' { 2 | export default function (element: Element): Element 3 | } 4 | 5 | declare module 'smoothscroll-polyfill' { 6 | export function polyfill(): void 7 | } 8 | 9 | declare module 'rangy' { 10 | interface RangyHighlighter { 11 | addClassApplier(classApplier: RangyClassApplier): void 12 | highlightSelection(className: string): void 13 | } 14 | 15 | interface RangyClassApplier { 16 | applyToSelection(win: Window): void 17 | } 18 | 19 | interface RangyExtendedStatic { 20 | init(): void 21 | createHighlighter(): RangyHighlighter 22 | createClassApplier( 23 | className: string, 24 | options?: { 25 | ignoreWhiteSpace?: boolean 26 | tagNames?: string[] 27 | }, 28 | ): RangyClassApplier 29 | } 30 | 31 | const rangy: RangyStatic & RangyExtendedStatic 32 | 33 | export = rangy 34 | } 35 | 36 | declare module 'react-resizable' { 37 | import { Axis, ResizeCallbackData, ResizeHandle } from 'react-resizable' 38 | 39 | export interface ResizableProps { 40 | className?: string 41 | width: number 42 | height: number 43 | handle?: React.ReactNode | ((resizeHandle: ResizeHandle) => React.ReactNode) 44 | handleSize?: [number, number] 45 | lockAspectRatio?: boolean 46 | axis?: Axis 47 | minConstraints?: [number, number] 48 | maxConstraints?: [number, number] 49 | onResizeStop?: (e: React.SyntheticEvent, data: ResizeCallbackData) => any 50 | onResizeStart?: (e: React.SyntheticEvent, data: ResizeCallbackData) => any 51 | onResize?: (e: React.SyntheticEvent, data: ResizeCallbackData) => any 52 | draggableOpts?: any 53 | resizeHandles?: ResizeHandle[] 54 | style?: Record 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DeepL Translate", 3 | "permissions": [ 4 | "http://*/*", 5 | "https://*/*", 6 | "storage", 7 | "activeTab", 8 | "contextMenus" 9 | ], 10 | "options_ui": { 11 | "page": "options.html", 12 | "chrome_style": false, 13 | "open_in_tab": true 14 | }, 15 | "background": { 16 | "page": "background.html" 17 | }, 18 | "browser_action": { 19 | "default_icon": { 20 | "128": "icon-128.png" 21 | }, 22 | "default_title": "DeepL Translate" 23 | }, 24 | "icons": { 25 | "128": "icon-128.png" 26 | }, 27 | "content_scripts": [ 28 | { 29 | "matches": ["http://*/*", "https://*/*"], 30 | "js": ["contentScript.bundle.js"], 31 | "css": ["contentScript.bundle.css"], 32 | "run_at": "document_start" 33 | } 34 | ], 35 | "web_accessible_resources": [ 36 | "content.styles.css", 37 | "icon-128.png", 38 | "icon-34.png" 39 | ], 40 | "commands": { 41 | "open_application": { 42 | "description": "打开应用", 43 | "suggested_key": { 44 | "default": "Ctrl+Shift+W", 45 | "mac": "MacCtrl+Command+W" 46 | } 47 | }, 48 | "toggle_ocr": { 49 | "description": "开启 OCR 识别", 50 | "suggested_key": { 51 | "default": "Ctrl+Shift+E", 52 | "mac": "MacCtrl+Command+E" 53 | } 54 | } 55 | }, 56 | "manifest_version": 2, 57 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'" 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/Background/common/server.ts: -------------------------------------------------------------------------------- 1 | import cc from 'chrome-call' 2 | import { createServer } from 'connect.io' 3 | 4 | import Client from '../../../common/api' 5 | import logger from '../../../common/logger' 6 | import { Config } from '../../../common/types' 7 | import { OcrClient } from '../../../common/ocr-client' 8 | import { Handler } from './types' 9 | import { cropImage } from './utils' 10 | 11 | const server = createServer() 12 | 13 | const onTranslate: Handler<{ 14 | text: string 15 | targetLang: string 16 | }> = (payload, resolve, reject) => { 17 | ;(async () => { 18 | logger.debug({ 19 | msg: 'receive translate payload', 20 | payload, 21 | }) 22 | 23 | const config: Config = await cc(chrome.storage.sync, 'get') 24 | const client = new Client(config.token, config.region) 25 | 26 | if (process.env.USE_MOCK_TRANSLATE === 'true') { 27 | resolve({ 28 | translations: [ 29 | { 30 | detected_source_language: 'EN', 31 | text: '模拟翻译结果', 32 | }, 33 | ], 34 | }) 35 | return 36 | } 37 | 38 | const result = await client.translate(payload.text, payload.targetLang) 39 | 40 | resolve(result) 41 | })().catch((err) => { 42 | logger.error({ 43 | err, 44 | }) 45 | reject({ 46 | message: err.message, 47 | }) 48 | }) 49 | } 50 | 51 | const onScreenshot: Handler<{ 52 | x: number 53 | y: number 54 | width: number 55 | height: number 56 | clientWidth: number 57 | clientHeight: number 58 | clientPixelRatio: number 59 | }> = (payload, resolve, reject) => { 60 | ;(async () => { 61 | logger.debug( 62 | { 63 | payload, 64 | }, 65 | 'receive screenshot payload', 66 | ) 67 | 68 | const dataUrl: string = await cc(chrome.tabs, 'captureVisibleTab', null, { 69 | quality: 75, 70 | }) 71 | 72 | resolve({ 73 | dataUrl: await cropImage(dataUrl, { 74 | ...payload, 75 | imageWidth: payload.clientWidth, 76 | imageHeight: payload.clientHeight, 77 | imageRatio: payload.clientPixelRatio, 78 | }), 79 | }) 80 | })().catch((err) => { 81 | logger.error({ 82 | err, 83 | }) 84 | reject({ 85 | message: err.message, 86 | }) 87 | }) 88 | } 89 | 90 | const onOCR: Handler<{ 91 | dataUrl: string 92 | }> = (payload, resolve, reject) => { 93 | ;(async () => { 94 | logger.debug( 95 | { 96 | payload, 97 | }, 98 | 'receive ocr payload', 99 | ) 100 | 101 | const config: Config = await cc(chrome.storage.sync, 'get') 102 | 103 | if (!config.ocrSecretId || !config.ocrSecretKey) { 104 | return 105 | } 106 | 107 | const client = new OcrClient({ 108 | secretId: config.ocrSecretId, 109 | secretKey: config.ocrSecretKey, 110 | region: config.ocrRegion, 111 | }) 112 | const data = await client.request({ dataUrl: payload.dataUrl }) 113 | 114 | resolve(data) 115 | })().catch((err) => { 116 | logger.error({ 117 | err, 118 | }) 119 | reject({ 120 | message: err.message, 121 | }) 122 | }) 123 | } 124 | 125 | server.on('connect', (client) => { 126 | client.on('translate', onTranslate) 127 | client.on('screenshot', onScreenshot) 128 | client.on('ocr', onOCR) 129 | }) 130 | 131 | export default server 132 | -------------------------------------------------------------------------------- /src/pages/Background/common/types.ts: -------------------------------------------------------------------------------- 1 | export type Handler = ( 2 | payload: T, 3 | resolve: (result: Record | string) => void, 4 | reject: (reason: Record | string) => void, 5 | ) => void 6 | -------------------------------------------------------------------------------- /src/pages/Background/common/utils.ts: -------------------------------------------------------------------------------- 1 | export const cropImage = async ( 2 | dataUrl: string, 3 | config: { 4 | x: number 5 | y: number 6 | width: number 7 | height: number 8 | imageWidth: number 9 | imageHeight: number 10 | imageRatio: number 11 | }, 12 | ): Promise => { 13 | const croppedCanvas = await new Promise( 14 | (resolve, reject) => { 15 | const canvas = document.createElement('canvas') 16 | const img = new Image() 17 | 18 | canvas.width = config.width 19 | canvas.height = config.height 20 | img.onload = () => { 21 | const ctx = canvas.getContext('2d') 22 | 23 | canvas.width = config.width 24 | canvas.height = config.height 25 | 26 | ctx?.drawImage( 27 | img, 28 | config.x * config.imageRatio, 29 | config.y * config.imageRatio, 30 | config.width * config.imageRatio, 31 | config.height * config.imageRatio, 32 | 0, 33 | 0, 34 | config.width, 35 | config.height, 36 | ) 37 | 38 | resolve(canvas) 39 | } 40 | 41 | img.onerror = () => { 42 | reject(new Error('Failed to load image')) 43 | } 44 | 45 | img.src = dataUrl 46 | }, 47 | ) 48 | 49 | return croppedCanvas.toDataURL('image/jpeg') 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/Background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/Background/index.ts: -------------------------------------------------------------------------------- 1 | import '../../assets/img/icon-34.png' 2 | import '../../assets/img/icon-128.png' 3 | 4 | import cc from 'chrome-call' 5 | import { createClient } from 'connect.io' 6 | 7 | import './common/server' 8 | import logger from '../../common/logger' 9 | 10 | const openExtension = (): void => { 11 | cc(chrome.tabs, 'query', { active: true, currentWindow: true }) 12 | .then((tabs: chrome.tabs.Tab[]) => { 13 | const client = createClient(tabs[0].id) 14 | 15 | client.send('open_extension') 16 | }) 17 | .catch((err) => { 18 | logger.error({ 19 | err, 20 | }) 21 | }) 22 | } 23 | 24 | const toggleOCR = (): void => { 25 | cc(chrome.tabs, 'query', { active: true, currentWindow: true }) 26 | .then((tabs: chrome.tabs.Tab[]) => { 27 | const client = createClient(tabs[0].id) 28 | 29 | client.send('toggle_ocr') 30 | }) 31 | .catch((err) => { 32 | logger.error({ 33 | err, 34 | }) 35 | }) 36 | } 37 | 38 | const translateText = (text: string): void => { 39 | cc(chrome.tabs, 'query', { active: true, currentWindow: true }) 40 | .then((tabs: chrome.tabs.Tab[]) => { 41 | const client = createClient(tabs[0].id) 42 | 43 | client.send('translate_text', { 44 | text, 45 | }) 46 | }) 47 | .catch((err) => { 48 | logger.error({ 49 | err, 50 | }) 51 | }) 52 | } 53 | 54 | chrome.browserAction.onClicked.addListener(openExtension) 55 | 56 | chrome.commands.onCommand.addListener(function (command) { 57 | switch (command) { 58 | case 'open_application': 59 | openExtension() 60 | break 61 | case 'toggle_ocr': 62 | toggleOCR() 63 | break 64 | default: 65 | // no default 66 | } 67 | }) 68 | 69 | chrome.contextMenus.create({ 70 | id: 'ate', 71 | title: 'DeepL Translate', 72 | contexts: ['page'], 73 | }) 74 | 75 | chrome.contextMenus.create({ 76 | id: 'ate-open_application', 77 | parentId: 'ate', 78 | title: '打开应用', 79 | onclick() { 80 | openExtension() 81 | }, 82 | }) 83 | 84 | chrome.contextMenus.create({ 85 | id: 'ate-toggle_ocr', 86 | parentId: 'ate', 87 | title: '开启 OCR 识别', 88 | onclick() { 89 | toggleOCR() 90 | }, 91 | }) 92 | 93 | chrome.contextMenus.create({ 94 | id: 'ate-translate_selection', 95 | contexts: ['selection'], 96 | title: '翻译选中文字', 97 | onclick(payload) { 98 | if (payload.selectionText) { 99 | translateText(payload.selectionText) 100 | } 101 | }, 102 | }) 103 | -------------------------------------------------------------------------------- /src/pages/Content/common/client.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'connect.io' 2 | 3 | const client = createClient() 4 | 5 | export default client 6 | -------------------------------------------------------------------------------- /src/pages/Content/common/polyfill.ts: -------------------------------------------------------------------------------- 1 | import smoothScrollPolyfill from 'smoothscroll-polyfill' 2 | 3 | if (!('scrollBehavior' in document.documentElement.style)) { 4 | smoothScrollPolyfill.polyfill() 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/Content/common/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'connect.io' 2 | 3 | const server = createServer() 4 | 5 | export default server 6 | -------------------------------------------------------------------------------- /src/pages/Content/common/translation-stack.ts: -------------------------------------------------------------------------------- 1 | import { AllJobTypes } from './types' 2 | 3 | class TranslationStack { 4 | stack: Array = [] 5 | onPush?: (job: AllJobTypes) => void 6 | 7 | attachQueue(onPush: (job: AllJobTypes) => void) { 8 | this.onPush = onPush 9 | 10 | while (this.stack.length) { 11 | const job = this.stack.shift() 12 | if (job) this.onPush(job) 13 | } 14 | } 15 | 16 | detachQueue(): void { 17 | this.onPush = undefined 18 | } 19 | 20 | push(job: AllJobTypes) { 21 | if (this.onPush) { 22 | this.onPush(job) 23 | } else { 24 | this.stack.push(job) 25 | } 26 | } 27 | } 28 | 29 | const translationStack = new TranslationStack() 30 | 31 | export default translationStack 32 | -------------------------------------------------------------------------------- /src/pages/Content/common/types.ts: -------------------------------------------------------------------------------- 1 | import { SupportLanguageKeys } from '../../../common/types' 2 | 3 | export interface TextSelection { 4 | text: string 5 | selection: RangySelection 6 | parentElement?: HTMLElement 7 | sourceLang?: SupportLanguageKeys 8 | id?: string 9 | anchorId?: string 10 | } 11 | 12 | export interface TranslateJob { 13 | type: 'translate' 14 | id: string 15 | text: string 16 | anchorId?: string 17 | sourceLang?: string 18 | } 19 | 20 | export interface DirectiveJob { 21 | type: 'directive' 22 | directive: string 23 | payload?: Record 24 | } 25 | 26 | export type AllJobTypes = TranslateJob | DirectiveJob 27 | -------------------------------------------------------------------------------- /src/pages/Content/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { SupportLanguageKeys } from '../../../common/types' 2 | 3 | export const getFirstRange = (sel: RangySelection): RangyRange | undefined => { 4 | return sel.rangeCount ? sel.getRangeAt(0) : undefined 5 | } 6 | 7 | export const getDocumentLang = (): SupportLanguageKeys | undefined => { 8 | const html = document.querySelector('html') 9 | 10 | if (!html) return 11 | 12 | if (!html.hasAttribute('lang')) { 13 | return 14 | } 15 | 16 | const lang = (html.getAttribute('lang') as string).toUpperCase() 17 | 18 | if (lang.startsWith('ZH')) { 19 | return 'ZH' 20 | } 21 | if (lang.startsWith('EN')) { 22 | return 'EN-US' 23 | } 24 | if (lang.startsWith('JA')) { 25 | return 'JA' 26 | } 27 | if (lang.startsWith('DE')) { 28 | return 'DE' 29 | } 30 | if (lang.startsWith('FR')) { 31 | return 'FR' 32 | } 33 | if (lang.startsWith('ES')) { 34 | return 'ES' 35 | } 36 | if (lang.startsWith('PT')) { 37 | return 'PT-PT' 38 | } 39 | if (lang.startsWith('IT')) { 40 | return 'IT' 41 | } 42 | if (lang.startsWith('NL')) { 43 | return 'NL' 44 | } 45 | if (lang.startsWith('PL')) { 46 | return 'PL' 47 | } 48 | if (lang.startsWith('RU')) { 49 | return 'RU' 50 | } 51 | 52 | return undefined 53 | } 54 | 55 | export const cleanText = (str: string): string => { 56 | return str 57 | .split('\n') 58 | .filter((item) => item !== '') 59 | .map((item) => item.trim()) 60 | .join('\n') 61 | } 62 | -------------------------------------------------------------------------------- /src/pages/Content/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import tw, { css } from 'twin.macro' 3 | import { ClassNames } from '@emotion/react' 4 | import React, { useRef, useCallback, useEffect, useMemo, useState } from 'react' 5 | import Draggable, { DraggableEventHandler } from 'react-draggable' 6 | import cc from 'chrome-call' 7 | // @ts-ignore 8 | import ScrollToBottom from 'react-scroll-to-bottom' 9 | import { useSnackbar } from 'notistack' 10 | 11 | import logger from '../../../../common/logger' 12 | import { Config } from '../../../../common/types' 13 | import IconButton from '../../../../components/IconButton' 14 | import CloseIcon from '../../../../components/svg/Close' 15 | import CursorClick from '../../../../components/svg/CursorClick' 16 | import LoadingCircle from '../../../../components/svg/LoadingCircle' 17 | import translationStack from '../../common/translation-stack' 18 | import { AllJobTypes, DirectiveJob } from '../../common/types' 19 | import { ConfigContext, ConfigState } from '../../providers/config' 20 | import { useTranslateJobsDispatch } from '../../providers/translate-jobs' 21 | import OCRTool, { OnFinish } from '../OCRTool' 22 | import TranslationList from '../TranslationList' 23 | import client from '../../common/client' 24 | 25 | const App: React.FC = () => { 26 | const [config, setConfig] = useState() 27 | const [close, setClose] = useState(false) 28 | const [showOCRTool, setShowOCRTool] = useState(false) 29 | const [loadingOCR, setLoadingOCR] = useState(false) 30 | const appRef = useRef(null) 31 | const closeButtonRef = useRef(null) 32 | const ocrToolButtonRef = useRef(null) 33 | const dispatch = useTranslateJobsDispatch() 34 | const { enqueueSnackbar } = useSnackbar() 35 | 36 | const enableOCR = useMemo(() => { 37 | return !!config && !!config.ocrSecretId && !!config.ocrSecretKey 38 | }, [config]) 39 | 40 | const appPosition = useMemo(() => { 41 | const vw = window.top.innerWidth || window.innerWidth || 0 42 | const vh = window.top.innerHeight || window.innerHeight || 0 43 | 44 | return { 45 | x: vw - 450 - 20, 46 | y: vh - 600 - 20, 47 | } 48 | }, []) 49 | 50 | const onNewJob = useCallback( 51 | (job: AllJobTypes) => { 52 | logger.debug({ 53 | msg: 'new job', 54 | job, 55 | }) 56 | 57 | const doDirectiveJob = (job: DirectiveJob): void => { 58 | switch (job.directive) { 59 | case 'toggle_ocr': 60 | if (enableOCR) { 61 | setShowOCRTool((oldVal) => !oldVal) 62 | } else { 63 | enqueueSnackbar('无法开启 OCR,请确认已正确设置腾讯云 OCR', { 64 | variant: 'warning', 65 | }) 66 | } 67 | break 68 | default: 69 | // no default 70 | } 71 | } 72 | 73 | switch (job.type) { 74 | case 'translate': 75 | if (!job.sourceLang) { 76 | job.sourceLang = 'EN' 77 | } 78 | 79 | dispatch({ 80 | type: 'add', 81 | payload: job, 82 | }) 83 | break 84 | 85 | case 'directive': 86 | doDirectiveJob(job) 87 | break 88 | } 89 | }, 90 | [dispatch, enableOCR, enqueueSnackbar], 91 | ) 92 | 93 | const onDragStart: DraggableEventHandler = useCallback((e) => { 94 | if ( 95 | e.target instanceof Element && 96 | (closeButtonRef.current?.contains(e.target) || 97 | ocrToolButtonRef.current?.contains(e.target)) 98 | ) { 99 | return false 100 | } 101 | }, []) 102 | 103 | const onOCRToolFinish = useCallback( 104 | (data) => { 105 | setShowOCRTool(false) 106 | logger.debug( 107 | { 108 | data, 109 | }, 110 | 'OCR tool finished', 111 | ) 112 | 113 | if (!data) { 114 | return 115 | } 116 | 117 | setLoadingOCR(true) 118 | 119 | // Wait for the overlay to be removed 120 | setTimeout(() => { 121 | const res = client.send('screenshot', data, true) as Promise<{ 122 | dataUrl: string 123 | }> 124 | 125 | res 126 | .then((data) => { 127 | return client.send('ocr', data, true) as Promise 128 | }) 129 | .then((result: string[]) => { 130 | translationStack.push({ 131 | type: 'translate', 132 | id: uuid(), 133 | text: result.join('\n'), 134 | }) 135 | }) 136 | .catch((err) => { 137 | enqueueSnackbar(err.message || '文字识别出错,请重试') 138 | }) 139 | .finally(() => { 140 | setLoadingOCR(false) 141 | }) 142 | }, 50) 143 | }, 144 | [enqueueSnackbar], 145 | ) 146 | 147 | useEffect(() => { 148 | translationStack.attachQueue(onNewJob) 149 | 150 | return () => { 151 | translationStack.detachQueue() 152 | } 153 | }, [onNewJob]) 154 | 155 | useEffect(() => { 156 | cc(chrome.storage.sync, 'get').then((config: Config) => { 157 | setConfig(config) 158 | }) 159 | 160 | window.__ate_setClose = setClose 161 | }, []) 162 | 163 | return ( 164 | 165 | 166 | {({ css: _css, cx }) => ( 167 | 171 | 189 | 192 | DeepL Translate 193 | 194 | {enableOCR ? ( 195 | !loadingOCR && setShowOCRTool(true)}> 200 | {loadingOCR ? ( 201 | 206 | ) : ( 207 | 208 | )} 209 | 210 | ) : undefined} 211 | 212 | setClose(true)}> 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | )} 228 | 229 | 230 | {showOCRTool ? : null} 231 | 232 | ) 233 | } 234 | 235 | export default App 236 | 237 | declare global { 238 | interface Window { 239 | __ate_setClose?: React.Dispatch 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/pages/Content/components/OCRTool/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler, useState } from 'react' 2 | import tw, { css, styled } from 'twin.macro' 3 | 4 | const ResizableBox = styled('div')` 5 | position: absolute; 6 | background-color: rgba(0, 0, 0, 0.3); 7 | z-index: 7; 8 | user-select: none; 9 | ` 10 | const Overlay = styled('div')` 11 | z-index: 8; 12 | cursor: crosshair; 13 | position: absolute; 14 | top: 0; 15 | left: 0; 16 | right: 0; 17 | bottom: 0; 18 | ` 19 | 20 | export type OnFinish = (data?: { 21 | x: number 22 | y: number 23 | width: number 24 | height: number 25 | clientWidth: number 26 | clientHeight: number 27 | clientPixelRatio: number 28 | }) => void 29 | 30 | const OCRTool: React.FC<{ 31 | onFinish?: OnFinish 32 | }> = (props) => { 33 | const [axisX, setAxisX] = useState(0) 34 | const [axisY, setAxisY] = useState(0) 35 | const [axisFixedX, setAxisFixedX] = useState(0) 36 | const [axisFixedY, setAxisFixedY] = useState(0) 37 | const [w, setW] = useState(0) 38 | const [h, setH] = useState(0) 39 | const [overlayActive, setOverlayActive] = useState(false) 40 | 41 | const onOverlayMouseDown: MouseEventHandler = (e) => { 42 | setAxisX(e.clientX) 43 | setAxisY(e.clientY) 44 | setAxisFixedX(e.clientX) 45 | setAxisFixedY(e.clientY) 46 | setW(0) 47 | setH(0) 48 | setOverlayActive(true) 49 | } 50 | 51 | const onOverlayMouseMove: MouseEventHandler = (e) => { 52 | if (overlayActive) { 53 | if (e.clientX >= axisFixedX) { 54 | setW(e.clientX - axisFixedX) 55 | } else { 56 | setW(axisFixedX - e.clientX) 57 | setAxisX(e.clientX) 58 | } 59 | 60 | if (e.clientY >= axisFixedY) { 61 | setH(e.clientY - axisFixedY) 62 | } else { 63 | setH(axisFixedY - e.clientY) 64 | setAxisY(e.clientY) 65 | } 66 | } 67 | } 68 | 69 | const onOverlayMouseUp: MouseEventHandler = () => { 70 | if (overlayActive) { 71 | if (props.onFinish) { 72 | const vw = window.top.innerWidth || window.innerWidth || 0 73 | const vh = window.top.innerHeight || window.innerHeight || 0 74 | 75 | if (w < 5 || h < 5) { 76 | props.onFinish() 77 | } else { 78 | props.onFinish({ 79 | x: axisX, 80 | y: axisY, 81 | width: w, 82 | height: h, 83 | clientWidth: vw, 84 | clientHeight: vh, 85 | clientPixelRatio: window.devicePixelRatio, 86 | }) 87 | } 88 | } 89 | setOverlayActive(false) 90 | } 91 | } 92 | 93 | return ( 94 | 105 | 113 | 114 | 119 | 120 | ) 121 | } 122 | 123 | export default OCRTool 124 | -------------------------------------------------------------------------------- /src/pages/Content/components/TranslationItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react' 2 | import Clipboard from 'react-clipboard.js' 3 | import { useSnackbar } from 'notistack' 4 | import { Collapse } from 'react-collapse' 5 | import scrollParent from 'scrollparent' 6 | import tw, { css } from 'twin.macro' 7 | 8 | import { supportedLanguages } from '../../../../common/constant' 9 | import logger from '../../../../common/logger' 10 | import { SupportLanguageKeys, TranslateResult } from '../../../../common/types' 11 | import IconButton from '../../../../components/IconButton' 12 | import ArrowRight from '../../../../components/svg/ArrowRight' 13 | import ClipboardCopy from '../../../../components/svg/ClipboardCopy' 14 | import Refresh from '../../../../components/svg/Refresh' 15 | import client from '../../common/client' 16 | import { TranslateJob } from '../../common/types' 17 | import { cleanText } from '../../common/utils' 18 | import { useConfig } from '../../providers/config' 19 | 20 | const TranslationItem: React.FC<{ 21 | job: TranslateJob 22 | }> = ({ job }) => { 23 | const config = useConfig() 24 | const [loading, setLoading] = useState(true) 25 | const [error, setError] = useState() 26 | const [result, setResult] = useState() 27 | const [overrideLang, setOverrideLang] = useState() 28 | const [dirty, setDirty] = useState(0) 29 | const [collapse, setCollapse] = useState(true) 30 | const { enqueueSnackbar } = useSnackbar() 31 | 32 | const textContent = useMemo((): string[] => { 33 | return job.text.split('\n') 34 | }, [job.text]) 35 | 36 | const findOriginal = () => { 37 | const { anchorId } = job 38 | const parentElement = document.querySelector(`.${anchorId}`) 39 | 40 | if (!parentElement) { 41 | enqueueSnackbar('已找不到原文', { variant: 'info' }) 42 | return 43 | } 44 | 45 | const scrollContainer = scrollParent(parentElement) 46 | 47 | if (parentElement instanceof HTMLElement) { 48 | if (scrollContainer === document.body) { 49 | document.documentElement.scrollTo({ 50 | top: parentElement.offsetTop - 20, 51 | left: 0, 52 | behavior: 'smooth', 53 | }) 54 | } else { 55 | scrollContainer.scrollTo({ 56 | top: parentElement.offsetTop - 20, 57 | left: 0, 58 | behavior: 'smooth', 59 | }) 60 | } 61 | } 62 | } 63 | 64 | const refreshResult = () => { 65 | setDirty((val) => val + 1) 66 | } 67 | 68 | useEffect(() => { 69 | if (!config) return 70 | 71 | setResult(undefined) 72 | setLoading(true) 73 | setError(undefined) 74 | 75 | const res = client.send( 76 | 'translate', 77 | { 78 | text: cleanText(job.text), 79 | id: job.id, 80 | targetLang: overrideLang || config.targetLang, 81 | }, 82 | true, 83 | ) as Promise 84 | 85 | res 86 | .then((payload) => { 87 | logger.debug({ 88 | msg: 'receive result', 89 | payload, 90 | }) 91 | 92 | const result: string[] = [] 93 | 94 | payload.translations.forEach((item) => { 95 | result.push(...item.text.split('\n')) 96 | }) 97 | 98 | setError(undefined) 99 | setResult(result) 100 | setLoading(false) 101 | }) 102 | .catch((err: Error) => { 103 | logger.error({ 104 | msg: 'translate failed', 105 | data: err, 106 | }) 107 | 108 | setError(err.message) 109 | setLoading(false) 110 | enqueueSnackbar(`翻译失败:${err.message}`, { variant: 'error' }) 111 | }) 112 | }, [job, config, enqueueSnackbar, dirty, overrideLang]) 113 | 114 | return ( 115 | 116 | 117 | setCollapse((prev) => !prev)}> 132 | 133 | <> 134 | {textContent.map((item, index) => ( 135 | {item} 136 | ))} 137 | > 138 | 139 | 140 | 141 | 142 | {error ? {error} : undefined} 143 | 144 | {loading ? {'翻译中…'} : undefined} 145 | 146 | {result ? ( 147 | <> 148 | {result.map((item, index) => ( 149 | {item} 150 | ))} 151 | > 152 | ) : undefined} 153 | 154 | 155 | div { 160 | ${tw`space-x-2`} 161 | } 162 | `, 163 | ]}> 164 | 169 | {job.sourceLang && ( 170 | 171 | {job.sourceLang} 172 | 173 | )} 174 | 175 | {config ? ( 176 | setOverrideLang(e.target.value)}> 184 | {Object.keys(supportedLanguages).map((lang, index) => ( 185 | 186 | {supportedLanguages[lang as SupportLanguageKeys]} 187 | 188 | ))} 189 | 190 | ) : undefined} 191 | 192 | 193 | 194 | {result ? ( 195 | result} 197 | button-title="复制翻译结果" 198 | onSuccess={() => enqueueSnackbar('复制成功')} 199 | component={IconButton} 200 | tw="p-1"> 201 | 202 | 203 | ) : undefined} 204 | 205 | {!loading && error ? ( 206 | refreshResult()} 209 | title="重试"> 210 | 211 | 212 | ) : undefined} 213 | 214 | {job.anchorId ? ( 215 | findOriginal()} 218 | title="跳转到原文"> 219 | 220 | 221 | ) : undefined} 222 | 223 | 224 | 225 | ) 226 | } 227 | 228 | export default TranslationItem 229 | -------------------------------------------------------------------------------- /src/pages/Content/components/TranslationList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import tw from 'twin.macro' 3 | 4 | import { useTranslateJobs } from '../../providers/translate-jobs' 5 | import TranslationItem from '../TranslationItem' 6 | 7 | const TranslationList: React.FC = () => { 8 | const jobsState = useTranslateJobs() 9 | 10 | return ( 11 | 12 | {!jobsState.jobs.length ? ( 13 | 还没有翻译… 14 | ) : undefined} 15 | 16 | {jobsState.jobs.map((job) => ( 17 | 18 | 19 | 20 | ))} 21 | 22 | ) 23 | } 24 | 25 | export default TranslationList 26 | -------------------------------------------------------------------------------- /src/pages/Content/index.tsx: -------------------------------------------------------------------------------- 1 | import './common/polyfill' 2 | import cc from 'chrome-call' 3 | 4 | import React from 'react' 5 | import { render } from 'react-dom' 6 | import { v4 as uuid } from 'uuid' 7 | import createCache from '@emotion/cache' 8 | import { CacheProvider } from '@emotion/react' 9 | import { SnackbarProvider } from 'notistack' 10 | 11 | import './styles/index.scss' 12 | 13 | import logger from '../../common/logger' 14 | import rangy from '../../common/rangy' 15 | import { Config } from '../../common/types' 16 | import server from './common/server' 17 | import translationStack from './common/translation-stack' 18 | import { TextSelection, TranslateJob } from './common/types' 19 | import { getDocumentLang, getFirstRange } from './common/utils' 20 | import App from './components/App' 21 | import { TranslateJobsProvider } from './providers/translate-jobs' 22 | 23 | let isAppAttached = false 24 | let lastSelection: TextSelection | undefined 25 | let highlighter: any 26 | let styleCache: ReturnType 27 | 28 | const main = async () => { 29 | const container = document.createElement('div') 30 | container.id = 'ate-container' 31 | 32 | const iconContainer = document.createElement('div') 33 | iconContainer.id = 'ate-icon-container' 34 | 35 | const iconElement = document.createElement('span') 36 | iconElement.id = 'ate-icon' 37 | iconElement.style.backgroundImage = `url(${chrome.runtime.getURL( 38 | 'icon-128.png', 39 | )})` 40 | 41 | iconContainer.appendChild(iconElement) 42 | 43 | window.addEventListener('load', () => { 44 | try { 45 | rangy.init() 46 | highlighter = rangy.createHighlighter() 47 | highlighter.addClassApplier( 48 | rangy.createClassApplier('ate-highlight', { 49 | ignoreWhiteSpace: true, 50 | tagNames: ['span', 'a'], 51 | }), 52 | ) 53 | 54 | document.querySelector('body')?.append(iconContainer) 55 | document.querySelector('body')?.append(container) 56 | 57 | cc(chrome.storage.sync, 'get').then((config: Partial) => { 58 | const hoverButton = 59 | config.hoverButton === undefined || config.hoverButton 60 | 61 | if (hoverButton) { 62 | document.querySelector('body')?.append(iconContainer) 63 | } 64 | 65 | attachListeners({ 66 | hoverButton, 67 | }) 68 | }) 69 | } catch (err) { 70 | logger.error({ 71 | err, 72 | }) 73 | } 74 | }) 75 | } 76 | 77 | const onMouseUp = (e: MouseEvent) => { 78 | const selection = rangy.getSelection() 79 | const iconElement = document.querySelector('#ate-icon') 80 | 81 | if (!iconElement || !(e.target instanceof Element)) { 82 | return 83 | } 84 | 85 | /** 86 | * 点击翻译按钮 87 | */ 88 | if (e.target === iconElement) { 89 | e.stopPropagation() 90 | e.preventDefault() 91 | 92 | logger.debug({ 93 | msg: 'lastSelection', 94 | lastSelection, 95 | }) 96 | 97 | if (lastSelection) { 98 | const id = uuid() 99 | const anchorId = `ate_anchor_${id}` 100 | 101 | if (lastSelection.selection.anchorNode?.parentElement) { 102 | lastSelection.selection.anchorNode?.parentElement.classList.add( 103 | anchorId, 104 | ) 105 | } 106 | 107 | highlightSelection(lastSelection.selection) 108 | 109 | addTranslateJob({ 110 | type: 'translate', 111 | anchorId, 112 | id, 113 | text: lastSelection.text, 114 | sourceLang: lastSelection.sourceLang, 115 | }) 116 | 117 | lastSelection?.selection.removeAllRanges() 118 | lastSelection = undefined 119 | iconElement.classList.remove('active') 120 | } 121 | 122 | return 123 | } 124 | 125 | /** 126 | * 没有点击翻译按钮 127 | */ 128 | if (selection.toString().trim()) { 129 | /** 130 | * 选择了文字 131 | */ 132 | 133 | const appElement = document.querySelector('.ate_App') 134 | 135 | if (appElement && appElement.contains(e.target)) { 136 | // 焦点处在 App 内,让按钮消失,清空上一次的选择 137 | iconElement.classList.remove('active') 138 | lastSelection = undefined 139 | 140 | return 141 | } 142 | 143 | lastSelection = getTextSelection(selection) 144 | 145 | iconElement.style.top = e.pageY + 20 + 'px' 146 | iconElement.style.left = e.pageX + 'px' 147 | iconElement.classList.add('active') 148 | } else { 149 | /** 150 | * 没有选择文字,有以下情况 151 | * 152 | * 1. 点击鼠标 153 | * 2. 空选择 154 | * 3. 拖拽 155 | */ 156 | 157 | lastSelection = undefined 158 | 159 | // 只要没有选中文字都让按钮消失 160 | iconElement.classList.remove('active') 161 | } 162 | } 163 | 164 | const addTranslateJob = (job: TranslateJob) => { 165 | initApp() 166 | translationStack.push(job) 167 | } 168 | 169 | const attachListeners = (config: { hoverButton: boolean }) => { 170 | if (config.hoverButton) { 171 | document.addEventListener('mouseup', onMouseUp, false) 172 | } 173 | 174 | server.on('connect', (client) => { 175 | client.on('open_extension', () => { 176 | initApp() 177 | }) 178 | 179 | client.on('toggle_ocr', () => { 180 | if (isAppAttached) { 181 | initApp() 182 | translationStack.push({ 183 | type: 'directive', 184 | directive: 'toggle_ocr', 185 | }) 186 | } else { 187 | initApp() 188 | setTimeout(() => { 189 | translationStack.push({ 190 | type: 'directive', 191 | directive: 'toggle_ocr', 192 | }) 193 | }, 50) 194 | } 195 | }) 196 | 197 | client.on('translate_text', (payload: { text: string }) => { 198 | initApp() 199 | translationStack.push({ 200 | type: 'translate', 201 | id: uuid(), 202 | text: payload.text, 203 | }) 204 | }) 205 | }) 206 | } 207 | 208 | const highlightSelection = (selection: RangySelection) => { 209 | const range = getFirstRange(selection) 210 | 211 | if (!range || !highlighter) { 212 | return 213 | } 214 | 215 | highlighter.highlightSelection('ate-highlight') 216 | } 217 | 218 | const getTextSelection = (selection: RangySelection): TextSelection => { 219 | let text: string 220 | 221 | if ('toString' in selection.nativeSelection) { 222 | text = selection.nativeSelection.toString().trim() 223 | } else { 224 | text = selection.toString().trim() 225 | } 226 | 227 | const parentElement = selection.anchorNode?.parentElement 228 | 229 | if ( 230 | parentElement && 231 | (parentElement.closest('pre') || parentElement.closest('.highlight')) 232 | ) { 233 | text = text.replaceAll('\n', ' ') 234 | } 235 | 236 | logger.debug(text.split('\n')) 237 | 238 | return { 239 | selection, 240 | sourceLang: getDocumentLang(), 241 | text, 242 | } 243 | } 244 | 245 | const initApp = (): void => { 246 | const containerEl = document.querySelector('#ate-container') 247 | 248 | if (!containerEl) { 249 | return 250 | } 251 | 252 | if (!styleCache) { 253 | styleCache = createCache({ 254 | key: 'ate', 255 | }) 256 | } 257 | 258 | if (isAppAttached) { 259 | window.__ate_setClose && window.__ate_setClose(false) 260 | } else { 261 | render( 262 | 263 | 264 | 265 | 266 | 267 | 268 | , 269 | containerEl, 270 | ) 271 | isAppAttached = true 272 | } 273 | } 274 | 275 | main().catch((err) => { 276 | logger.error({ 277 | err, 278 | }) 279 | }) 280 | -------------------------------------------------------------------------------- /src/pages/Content/providers/config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react' 2 | 3 | import { SupportLanguageKeys } from '../../../common/types' 4 | 5 | export type ConfigState = { 6 | targetLang: SupportLanguageKeys 7 | ocrSecretId?: string 8 | ocrSecretKey?: string 9 | } 10 | 11 | export const ConfigContext = createContext(undefined) 12 | 13 | export const useConfig = (): ConfigState | undefined => { 14 | return useContext(ConfigContext) 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/Content/providers/translate-jobs.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useReducer } from 'react' 2 | 3 | import { TranslateJob } from '../common/types' 4 | 5 | type TranslateJobsState = { 6 | jobs: Array 7 | } 8 | type TranslateJobsDispatch = (action: AddJobAction) => void 9 | type TranslateJobsReducer = ( 10 | state: TranslateJobsState, 11 | action: AddJobAction, 12 | ) => TranslateJobsState 13 | interface AddJobAction { 14 | type: 'add' 15 | payload: TranslateJob 16 | } 17 | 18 | const reducer: TranslateJobsReducer = (state, action) => { 19 | switch (action.type) { 20 | case 'add': 21 | return { 22 | jobs: [...state.jobs, action.payload], 23 | } 24 | default: 25 | throw new Error(`Unhandled action type: ${action.type}`) 26 | } 27 | } 28 | const TranslateJobsContext = createContext( 29 | undefined, 30 | ) 31 | const TranslateJobsDispatchContext = createContext< 32 | TranslateJobsDispatch | undefined 33 | >(undefined) 34 | 35 | export const TranslateJobsProvider: React.FC = (props) => { 36 | const [translateJobs, translateJobsDispatch] = useReducer(reducer, { 37 | jobs: [], 38 | }) 39 | 40 | return ( 41 | 42 | 43 | {props.children} 44 | 45 | 46 | ) 47 | } 48 | 49 | export const useTranslateJobs = (): TranslateJobsState => { 50 | const context = useContext(TranslateJobsContext) 51 | 52 | if (context === undefined) { 53 | throw new Error( 54 | 'useTranslateJobs must be used within a TranslateJobsProvider', 55 | ) 56 | } 57 | 58 | return context 59 | } 60 | 61 | export const useTranslateJobsDispatch = (): TranslateJobsDispatch => { 62 | const context = useContext(TranslateJobsDispatchContext) 63 | 64 | if (context === undefined) { 65 | throw new Error( 66 | 'useTranslateJobsDispatch must be used within a TranslateJobsProvider', 67 | ) 68 | } 69 | 70 | return context 71 | } 72 | -------------------------------------------------------------------------------- /src/pages/Content/styles/index.scss: -------------------------------------------------------------------------------- 1 | #ate-icon-container, 2 | #ate-container { 3 | width: 0; 4 | height: 0; 5 | top: 0; 6 | left: 0; 7 | margin: 0; 8 | padding: 0; 9 | font-size: 16px; 10 | z-index: 999999; 11 | 12 | @apply font-sans leading-normal; 13 | } 14 | 15 | #ate-container { 16 | position: fixed; 17 | 18 | @apply select-none; 19 | } 20 | 21 | #ate-icon-container { 22 | position: absolute; 23 | 24 | @apply select-none; 25 | } 26 | 27 | #ate-icon { 28 | display: none; 29 | z-index: 2; 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | width: 25px; 34 | height: 25px; 35 | background-repeat: no-repeat; 36 | background-size: 25px 25px; 37 | 38 | @apply rounded; 39 | } 40 | 41 | #ate-icon.active { 42 | display: block; 43 | } 44 | 45 | #ate-icon:hover { 46 | cursor: pointer; 47 | } 48 | 49 | .ate-highlight { 50 | @apply bg-purple-200 text-gray-800; 51 | } 52 | 53 | @keyframes ate-animation-spin { 54 | from { 55 | transform: rotate(0deg); 56 | } 57 | to { 58 | transform: rotate(360deg); 59 | } 60 | } -------------------------------------------------------------------------------- /src/pages/Options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FormEventHandler, 3 | MouseEventHandler, 4 | useEffect, 5 | useState, 6 | } from 'react' 7 | import tw, { css, styled } from 'twin.macro' 8 | import { Global } from '@emotion/react' 9 | import cc from 'chrome-call' 10 | import { useSnackbar } from 'notistack' 11 | 12 | import Client from '../../common/api' 13 | import { supportedLanguages, supportedRegions } from '../../common/constant' 14 | import { OcrRegionKeys, OcrRegions } from '../../common/ocr-client' 15 | import { 16 | APIRegions, 17 | Config, 18 | SupportLanguageKeys, 19 | SupportRegionKeys, 20 | } from '../../common/types' 21 | import OptionSection from './components/OptionSection' 22 | 23 | const InputGroup = styled('div')` 24 | ${tw`flex space-x-3 items-center`} 25 | ` 26 | 27 | const Options: React.FC = () => { 28 | const [targetLang, setTargetLang] = useState('ZH') 29 | const [token, setToken] = useState('') 30 | const [region, setRegion] = useState('default') 31 | const [ocrSecretId, setOCRSecretId] = useState('') 32 | const [ocrSecretKey, setOCRSecretKey] = useState('') 33 | const [ocrRegion, setOCRRegion] = useState('ap-shanghai') 34 | const [hoverButton, setHoverButton] = useState(true) 35 | const { enqueueSnackbar } = useSnackbar() 36 | 37 | const onSubmit: FormEventHandler = (e) => { 38 | e.preventDefault() 39 | ;(async () => { 40 | await cc(chrome.storage.sync, 'set', { 41 | targetLang, 42 | token, 43 | region, 44 | ocrSecretId, 45 | ocrSecretKey, 46 | ocrRegion, 47 | hoverButton, 48 | }) 49 | 50 | enqueueSnackbar('保存成功', { variant: 'success' }) 51 | })() 52 | } 53 | 54 | const onTestToken: MouseEventHandler = (e) => { 55 | e.preventDefault() 56 | 57 | if (!token) { 58 | enqueueSnackbar('请填入 API Token', { variant: 'warning' }) 59 | return 60 | } 61 | 62 | const client = new Client(token, region) 63 | 64 | client 65 | .translate('This is a test message.', 'ZH') 66 | .then(() => { 67 | enqueueSnackbar('测试成功', { variant: 'success' }) 68 | }) 69 | .catch((err) => { 70 | enqueueSnackbar('测试失败:' + err.message, { variant: 'error' }) 71 | }) 72 | } 73 | 74 | useEffect(() => { 75 | cc(chrome.storage.sync, 'get').then((config: Partial) => { 76 | if (config.targetLang !== undefined) setTargetLang(config.targetLang) 77 | if (config.token !== undefined) setToken(config.token) 78 | if (config.region !== undefined) setRegion(config.region) 79 | if (config.ocrSecretId !== undefined) setOCRSecretId(config.ocrSecretId) 80 | if (config.ocrSecretKey !== undefined) 81 | setOCRSecretKey(config.ocrSecretKey) 82 | if (config.ocrRegion !== undefined) setOCRRegion(config.ocrRegion) 83 | if (config.hoverButton !== undefined) setHoverButton(config.hoverButton) 84 | }) 85 | }, []) 86 | 87 | return ( 88 | 96 | 103 | 104 | 112 | 113 | 设定 114 | 115 | 116 | 119 | 120 | 121 | setTargetLang(e.target.value)}> 129 | {Object.keys(supportedLanguages).map((lang, index) => ( 130 | 131 | {supportedLanguages[lang as SupportLanguageKeys]} 132 | 133 | ))} 134 | 135 | 136 | 137 | 138 | 146 | setRegion(e.target.value as SupportRegionKeys) 147 | }> 148 | {Object.keys(supportedRegions).map((region, index) => ( 149 | 150 | {supportedRegions[region as SupportRegionKeys]} 151 | 152 | ))} 153 | 154 | 155 | 156 | 157 | setToken(e.target.value)} 163 | /> 164 | 165 | 166 | 167 | 168 | 169 | setOCRSecretId(e.target.value)} 175 | /> 176 | 177 | 178 | 179 | setOCRSecretKey(e.target.value)} 185 | /> 186 | 187 | 188 | 189 | 197 | setOCRRegion(e.target.value as OcrRegionKeys) 198 | }> 199 | {Object.keys(OcrRegions).map((region, index) => ( 200 | 201 | {OcrRegions[region as OcrRegionKeys]} 202 | 203 | ))} 204 | 205 | 206 | 207 | 208 | 可不填,填入后可使用 OCR 识别文字翻译。 209 | 210 | 211 | 212 | 213 | 214 | 215 | setHoverButton(e.target.checked)} 220 | /> 221 | 开启网页悬浮按钮 222 | 223 | 224 | 225 | 226 | 227 | 228 | 233 | → 如何配置腾讯云 OCR 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 245 | 248 | 反馈问题 249 | 250 | 251 | 252 | 255 | 测试 Token 256 | 257 | 258 | 261 | 保存 262 | 263 | 264 | 265 | 266 | 267 | ) 268 | } 269 | 270 | export default Options 271 | -------------------------------------------------------------------------------- /src/pages/Options/components/OptionSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import tw, { css, theme } from 'twin.macro' 3 | 4 | const OptionSection: React.FC<{ 5 | title: string 6 | }> = (props) => { 7 | return ( 8 | 9 | 10 | {props.title} 11 | 12 | {props.children} 13 | 14 | ) 15 | } 16 | 17 | export default OptionSection 18 | -------------------------------------------------------------------------------- /src/pages/Options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/Options/index.tsx: -------------------------------------------------------------------------------- 1 | import { SnackbarProvider } from 'notistack' 2 | import React from 'react' 3 | import { render } from 'react-dom' 4 | import { GlobalStyles } from 'twin.macro' 5 | 6 | import Options from './Options' 7 | 8 | render( 9 | 10 | 11 | 12 | , 13 | window.document.querySelector('#app'), 14 | ) 15 | -------------------------------------------------------------------------------- /src/pages/Popup/Popup.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0px; 4 | bottom: 0px; 5 | left: 0px; 6 | right: 0px; 7 | text-align: center; 8 | height: 100%; 9 | padding: 10px; 10 | background-color: #282c34; 11 | } 12 | 13 | .App-logo { 14 | height: 30vmin; 15 | pointer-events: none; 16 | } 17 | 18 | @media (prefers-reduced-motion: no-preference) { 19 | .App-logo { 20 | animation: App-logo-spin infinite 20s linear; 21 | } 22 | } 23 | 24 | .App-header { 25 | height: 100%; 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | justify-content: center; 30 | font-size: calc(10px + 2vmin); 31 | color: white; 32 | } 33 | 34 | .App-link { 35 | color: #61dafb; 36 | } 37 | 38 | @keyframes App-logo-spin { 39 | from { 40 | transform: rotate(0deg); 41 | } 42 | to { 43 | transform: rotate(360deg); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/pages/Popup/Popup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import logo from '../../assets/img/logo.svg' 4 | import './Popup.css' 5 | 6 | const Popup = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | Edit src/pages/Popup/Popup.js and save to reload. 13 | 14 | 19 | Learn React 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default Popup 27 | -------------------------------------------------------------------------------- /src/pages/Popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | height: 260px; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | 11 | position: relative; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/Popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/Popup/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | import Popup from './Popup' 5 | import './index.css' 6 | 7 | render(, window.document.querySelector('#app-container')) 8 | -------------------------------------------------------------------------------- /src/sass.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: Record 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /src/twin.d.ts: -------------------------------------------------------------------------------- 1 | // twin.d.ts 2 | import 'twin.macro' 3 | import styledImport from '@emotion/styled' 4 | import { css as cssImport } from '@emotion/react' 5 | 6 | // The css prop 7 | // https://emotion.sh/docs/typescript#css-prop 8 | import {} from '@emotion/react/types/css-prop' 9 | 10 | declare module 'twin.macro' { 11 | // The styled and css imports 12 | const styled: typeof styledImport 13 | const css: typeof cssImport 14 | } 15 | 16 | // The 'as' prop on styled components 17 | declare global { 18 | namespace JSX { 19 | interface IntrinsicAttributes extends DOMAttributes { 20 | as?: string 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: { 3 | enabled: true, 4 | content: ['./src/pages/**/*.{tsx,js}', './src/components/**/*.{tsx,js}'], 5 | }, 6 | darkMode: false, // or 'media' or 'class' 7 | theme: { 8 | fontSize: { 9 | xs: [remToPX(0.75), { lineHeight: remToPX(1) }], 10 | sm: [remToPX(0.875), { lineHeight: remToPX(1.25) }], 11 | base: [remToPX(1), { lineHeight: remToPX(1.5) }], 12 | lg: [remToPX(1.125), { lineHeight: remToPX(1.75) }], 13 | xl: [remToPX(1.25), { lineHeight: remToPX(1.75) }], 14 | '2xl': [remToPX(1.5), { lineHeight: remToPX(2) }], 15 | '3xl': [remToPX(1.875), { lineHeight: remToPX(2.25) }], 16 | '4xl': [remToPX(2.25), { lineHeight: remToPX(2.5) }], 17 | '5xl': [remToPX(3), { lineHeight: '1' }], 18 | '6xl': [remToPX(3.75), { lineHeight: '1' }], 19 | '7xl': [remToPX(4.5), { lineHeight: '1' }], 20 | '8xl': [remToPX(6), { lineHeight: '1' }], 21 | '9xl': [remToPX(8), { lineHeight: '1' }], 22 | }, 23 | spacing: { 24 | px: '1px', 25 | 0: '0px', 26 | 0.5: remToPX(0.125), 27 | 1: remToPX(0.25), 28 | 1.5: remToPX(0.375), 29 | 2: remToPX(0.5), 30 | 2.5: remToPX(0.625), 31 | 3: remToPX(0.75), 32 | 3.5: remToPX(0.875), 33 | 4: remToPX(1), 34 | 5: remToPX(1.25), 35 | 6: remToPX(1.5), 36 | 7: remToPX(1.75), 37 | 8: remToPX(2), 38 | 9: remToPX(2.25), 39 | 10: remToPX(2.5), 40 | 11: remToPX(2.75), 41 | 12: remToPX(3), 42 | 14: remToPX(3.5), 43 | 16: remToPX(4), 44 | 20: remToPX(5), 45 | 24: remToPX(6), 46 | 28: remToPX(7), 47 | 32: remToPX(8), 48 | 36: remToPX(9), 49 | 40: remToPX(10), 50 | 44: remToPX(11), 51 | 48: remToPX(12), 52 | 52: remToPX(13), 53 | 56: remToPX(14), 54 | 60: remToPX(15), 55 | 64: remToPX(16), 56 | 72: remToPX(18), 57 | 80: remToPX(20), 58 | 96: remToPX(24), 59 | }, 60 | borderRadius: { 61 | none: '0px', 62 | sm: remToPX(0.125), 63 | DEFAULT: remToPX(0.25), 64 | md: remToPX(0.375), 65 | lg: remToPX(0.5), 66 | xl: remToPX(0.75), 67 | '2xl': remToPX(1), 68 | '3xl': remToPX(1.5), 69 | full: '9999px', 70 | }, 71 | extend: {}, 72 | }, 73 | variants: { 74 | extend: { 75 | backgroundColor: ['active'], 76 | }, 77 | }, 78 | plugins: [require('@tailwindcss/forms')], 79 | } 80 | 81 | /** 82 | * @param {number} num 83 | * @returns {string} 84 | */ 85 | function remToPX(num) { 86 | return num * 16 + 'px' 87 | } 88 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", ".*.js", "**/*.js"], 4 | "exclude": ["build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "inlineSourceMap": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "noEmit": false, 17 | "jsx": "react" 18 | }, 19 | "include": ["src"], 20 | "exclude": ["build", "node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const env = require('./scripts/env') 2 | 3 | const webpack = require('webpack') 4 | const path = require('path') 5 | const fileSystem = require('fs-extra') 6 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 7 | const CopyWebpackPlugin = require('copy-webpack-plugin') 8 | const HtmlWebpackPlugin = require('html-webpack-plugin') 9 | const TerserPlugin = require('terser-webpack-plugin') 10 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 11 | const semver = require('semver') 12 | 13 | const ASSET_PATH = process.env.ASSET_PATH || '/' 14 | 15 | const alias = { 16 | 'react-dom': '@hot-loader/react-dom', 17 | } 18 | 19 | // load the secrets 20 | const secretsPath = path.join(__dirname, 'secrets.' + env.NODE_ENV + '.js') 21 | 22 | const fileExtensions = [ 23 | 'jpg', 24 | 'jpeg', 25 | 'png', 26 | 'gif', 27 | 'eot', 28 | 'otf', 29 | 'svg', 30 | 'ttf', 31 | 'woff', 32 | 'woff2', 33 | ] 34 | 35 | if (fileSystem.existsSync(secretsPath)) { 36 | alias['secrets'] = secretsPath 37 | } 38 | 39 | const options = { 40 | mode: env.NODE_ENV || 'development', 41 | entry: { 42 | options: path.join(__dirname, 'src/pages/Options/index.tsx'), 43 | // popup: path.join(__dirname, 'src/pages/Popup/index.jsx'), 44 | background: path.join(__dirname, 'src/pages/Background/index.ts'), 45 | contentScript: path.join(__dirname, 'src/pages/Content/index.tsx'), 46 | }, 47 | output: { 48 | path: path.resolve(__dirname, 'build'), 49 | filename: '[name].bundle.js', 50 | publicPath: ASSET_PATH, 51 | }, 52 | module: { 53 | rules: [ 54 | { 55 | test: /\.css$/i, 56 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'], 57 | }, 58 | { 59 | test: /\.scss$/i, 60 | use: [ 61 | MiniCssExtractPlugin.loader, 62 | 'css-loader', 63 | 'postcss-loader', 64 | 'sass-loader', 65 | ], 66 | }, 67 | { 68 | test: new RegExp('.(' + fileExtensions.join('|') + ')$'), 69 | loader: 'file-loader', 70 | options: { 71 | name: '[name].[ext]', 72 | }, 73 | exclude: /node_modules/, 74 | }, 75 | { 76 | test: /\.html$/, 77 | loader: 'html-loader', 78 | exclude: /node_modules/, 79 | }, 80 | { 81 | test: /\.(js|jsx|ts|tsx)$/, 82 | use: [ 83 | { 84 | loader: 'source-map-loader', 85 | }, 86 | { 87 | loader: 'babel-loader', 88 | }, 89 | ], 90 | exclude: /node_modules/, 91 | }, 92 | ], 93 | }, 94 | resolve: { 95 | alias: alias, 96 | extensions: fileExtensions 97 | .map((extension) => '.' + extension) 98 | .concat(['.js', '.jsx', '.ts', '.tsx', '.css']), 99 | fallback: { 100 | crypto: false, 101 | }, 102 | }, 103 | plugins: [ 104 | new webpack.ProgressPlugin(), 105 | // clean the build folder 106 | new CleanWebpackPlugin({ 107 | verbose: true, 108 | cleanStaleWebpackAssets: true, 109 | }), 110 | // expose and write the allowed env vars on the compiled bundle 111 | new webpack.EnvironmentPlugin(['NODE_ENV', 'USE_MOCK_TRANSLATE']), 112 | new webpack.ProvidePlugin({ 113 | process: 'process/browser', 114 | }), 115 | new CopyWebpackPlugin({ 116 | patterns: [ 117 | { 118 | from: 'src/manifest.json', 119 | to: path.join(__dirname, 'build'), 120 | force: true, 121 | transform: function (content) { 122 | const manifest = JSON.parse(content.toString()) 123 | 124 | // generates the manifest file using the package.json information 125 | return Buffer.from( 126 | JSON.stringify( 127 | { 128 | ...manifest, 129 | description: process.env.npm_package_description, 130 | version: semver 131 | .coerce(process.env.npm_package_version) 132 | .toString(), 133 | }, 134 | null, 135 | 2, 136 | ), 137 | ) 138 | }, 139 | }, 140 | ], 141 | }), 142 | new MiniCssExtractPlugin({ 143 | filename: '[name].bundle.css', 144 | }), 145 | new HtmlWebpackPlugin({ 146 | template: path.join(__dirname, 'src/pages/Options/index.html'), 147 | filename: 'options.html', 148 | chunks: ['options'], 149 | cache: false, 150 | }), 151 | // new HtmlWebpackPlugin({ 152 | // template: path.join(__dirname, 'src/pages/Popup/index.html'), 153 | // filename: 'popup.html', 154 | // chunks: ['popup'], 155 | // cache: false, 156 | // }), 157 | new HtmlWebpackPlugin({ 158 | template: path.join(__dirname, 'src/pages/Background/index.html'), 159 | filename: 'background.html', 160 | chunks: ['background'], 161 | cache: false, 162 | }), 163 | ], 164 | infrastructureLogging: { 165 | level: 'info', 166 | }, 167 | } 168 | 169 | if (env.NODE_ENV === 'development') { 170 | options.devtool = 'eval-source-map' 171 | } else { 172 | options.devtool = 'inline-source-map' 173 | options.optimization = { 174 | minimize: true, 175 | minimizer: [ 176 | new TerserPlugin({ 177 | extractComments: false, 178 | }), 179 | ], 180 | } 181 | } 182 | 183 | module.exports = options 184 | --------------------------------------------------------------------------------
12 | Edit src/pages/Popup/Popup.js and save to reload. 13 |
src/pages/Popup/Popup.js