add codes
This commit is contained in:
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
GENERATE_SOURCEMAP=false
|
||||
|
||||
REACT_APP_NAME=KISS Translator
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
*.crx
|
||||
*.pem
|
||||
131
config-overrides.js
Normal file
131
config-overrides.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const paths = require("react-scripts/config/paths");
|
||||
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
// Export override function(s) via object
|
||||
module.exports = {
|
||||
webpack: override,
|
||||
// You may also override the Jest config (used for tests) by adding property with 'jest' name below. See react-app-rewired library's docs for details
|
||||
};
|
||||
|
||||
// Function to override the CRA webpack config
|
||||
function override(config, env) {
|
||||
// Replace single entry point in the config with multiple ones
|
||||
// Note: you may remove any property below except "popup" to exclude respective entry point from compilation
|
||||
config.entry = {
|
||||
popup: paths.appIndexJs,
|
||||
options: paths.appSrc + "/options.js",
|
||||
background: paths.appSrc + "/background.js",
|
||||
content: paths.appSrc + "/content.js",
|
||||
};
|
||||
// Change output filename template to get rid of hash there
|
||||
config.output.filename = "static/js/[name].js";
|
||||
config.output.assetModuleFilename = "static/media/[name][ext]";
|
||||
// Disable built-in SplitChunksPlugin
|
||||
config.optimization.splitChunks = {
|
||||
cacheGroups: { default: false },
|
||||
};
|
||||
// Disable runtime chunk addition for each entry point
|
||||
config.optimization.runtimeChunk = false;
|
||||
|
||||
// Shared minify options to be used in HtmlWebpackPlugin constructor
|
||||
const minifyOpts = {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
keepClosingSlash: true,
|
||||
minifyJS: true,
|
||||
minifyCSS: true,
|
||||
minifyURLs: true,
|
||||
};
|
||||
const isEnvProduction = env === "production";
|
||||
|
||||
// Custom HtmlWebpackPlugin instance for index (popup) page
|
||||
const indexHtmlPlugin = new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
chunks: ["popup"],
|
||||
template: paths.appHtml,
|
||||
filename: "popup.html",
|
||||
minify: isEnvProduction && minifyOpts,
|
||||
});
|
||||
// Replace origin HtmlWebpackPlugin instance in config.plugins with the above one
|
||||
config.plugins = replacePlugin(
|
||||
config.plugins,
|
||||
(name) => /HtmlWebpackPlugin/i.test(name),
|
||||
indexHtmlPlugin
|
||||
);
|
||||
|
||||
// Extra HtmlWebpackPlugin instance for options page
|
||||
const optionsHtmlPlugin = new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
chunks: ["options"],
|
||||
template: paths.appHtml,
|
||||
filename: "options.html",
|
||||
minify: isEnvProduction && minifyOpts,
|
||||
});
|
||||
// Add the above HtmlWebpackPlugin instance into config.plugins
|
||||
// Note: you may remove/comment the next line if you don't need an options page
|
||||
config.plugins.push(optionsHtmlPlugin);
|
||||
|
||||
// Extra HtmlWebpackPlugin instance for options page
|
||||
const contentHtmlPlugin = new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
chunks: ["content"],
|
||||
template: paths.appPublic + "/content.html",
|
||||
filename: "content.html",
|
||||
minify: isEnvProduction && minifyOpts,
|
||||
});
|
||||
// Add the above HtmlWebpackPlugin instance into config.plugins
|
||||
// Note: you may remove/comment the next line if you don't need an options page
|
||||
config.plugins.push(contentHtmlPlugin);
|
||||
|
||||
// Custom ManifestPlugin instance to cast asset-manifest.json back to old plain format
|
||||
const manifestPlugin = new WebpackManifestPlugin({
|
||||
fileName: "asset-manifest.json",
|
||||
});
|
||||
// Replace origin ManifestPlugin instance in config.plugins with the above one
|
||||
config.plugins = replacePlugin(
|
||||
config.plugins,
|
||||
(name) => /ManifestPlugin/i.test(name),
|
||||
manifestPlugin
|
||||
);
|
||||
|
||||
// Custom MiniCssExtractPlugin instance to get rid of hash in filename template
|
||||
const miniCssExtractPlugin = new MiniCssExtractPlugin({
|
||||
filename: "static/css/[name].css",
|
||||
});
|
||||
// Replace origin MiniCssExtractPlugin instance in config.plugins with the above one
|
||||
config.plugins = replacePlugin(
|
||||
config.plugins,
|
||||
(name) => /MiniCssExtractPlugin/i.test(name),
|
||||
miniCssExtractPlugin
|
||||
);
|
||||
|
||||
// Remove GenerateSW plugin from config.plugins to disable service worker generation
|
||||
config.plugins = replacePlugin(config.plugins, (name) =>
|
||||
/GenerateSW/i.test(name)
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Utility function to replace/remove specific plugin in a webpack config
|
||||
function replacePlugin(plugins, nameMatcher, newPlugin) {
|
||||
const i = plugins.findIndex((plugin) => {
|
||||
return (
|
||||
plugin.constructor &&
|
||||
plugin.constructor.name &&
|
||||
nameMatcher(plugin.constructor.name)
|
||||
);
|
||||
});
|
||||
return i > -1
|
||||
? plugins
|
||||
.slice(0, i)
|
||||
.concat(newPlugin || [])
|
||||
.concat(plugins.slice(i + 1))
|
||||
: plugins;
|
||||
}
|
||||
33
manifest.firefox.json
Normal file
33
manifest.firefox.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.0.1",
|
||||
"default_locale": "zh",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
"background": {
|
||||
"scripts": ["static/js/background.js"]
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["static/js/content.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"permissions": ["<all_urls>", "storage"],
|
||||
"icons": {
|
||||
"192": "images/logo192.png"
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"192": "images/logo192.png"
|
||||
},
|
||||
"default_title": "__MSG_app_name__",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
49
package.json
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "kiss-translator",
|
||||
"description": "A simple translator extension",
|
||||
"version": "1.0.1",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.10.8",
|
||||
"@mui/icons-material": "^5.11.11",
|
||||
"@mui/material": "^5.11.12",
|
||||
"query-string": "^8.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-router-dom": "^6.10.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"webextension-polyfill": "^0.10.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "BUILD_PATH=./build/chrome REACT_APP_BROWSER=chrome react-app-rewired build",
|
||||
"build:edge": "BUILD_PATH=./build/edge REACT_APP_BROWSER=edge react-app-rewired build",
|
||||
"build:firefox": "BUILD_PATH=./build/firefox REACT_APP_BROWSER=firefox react-app-rewired build && cp ./manifest.firefox.json ./build/firefox/manifest.json",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-app-rewired": "^2.2.1"
|
||||
}
|
||||
}
|
||||
8
public/_locales/en/messages.json
Normal file
8
public/_locales/en/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "KISS Translator (by Gabe)"
|
||||
},
|
||||
"app_description": {
|
||||
"message": "A simple translator extension"
|
||||
}
|
||||
}
|
||||
8
public/_locales/zh/messages.json
Normal file
8
public/_locales/zh/messages.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app_name": {
|
||||
"message": "简约翻译 (by Gabe)"
|
||||
},
|
||||
"app_description": {
|
||||
"message": "一个简约的翻译插件"
|
||||
}
|
||||
}
|
||||
216
public/content.html
Normal file
216
public/content.html
Normal file
@@ -0,0 +1,216 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>%REACT_APP_NAME%</title>
|
||||
<style>
|
||||
img {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
svg {
|
||||
max-width: 1.2em;
|
||||
max-height: 1.2em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root">
|
||||
<div class="cont cont1">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont2">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont3">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont4">
|
||||
<h2>
|
||||
React is a <code>JavaScript</code> <a href="#">library</a> for
|
||||
building user interfaces.
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<p></p>
|
||||
<div class="cont cont5">
|
||||
<h2>React is a JavaScript library for building user interfaces.</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Declarative: React makes it painless to create interactive UIs.
|
||||
Design simple views for each state in your application, and React
|
||||
will efficiently update and render just the right components when
|
||||
your data changes. Declarative views make your code more
|
||||
predictable, simpler to understand, and easier to debug.
|
||||
</li>
|
||||
<li>
|
||||
Component-Based: Build encapsulated components that manage their own
|
||||
state, then compose them to make complex UIs. Since component logic
|
||||
is written in JavaScript instead of templates, you can easily pass
|
||||
rich data through your app and keep the state out of the DOM.
|
||||
</li>
|
||||
<li>
|
||||
React 使创建交互式 UI
|
||||
变得轻而易举。为你应用的每一个状态设计简洁的视图,当数据变动时 React
|
||||
能高效更新并渲染合适的组件。
|
||||
</li>
|
||||
<li>以声明式编写 UI,可以让你的代码更加可靠,且方便调试。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
public/images/logo192.png
Normal file
BIN
public/images/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
22
public/index.html
Normal file
22
public/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>%REACT_APP_NAME%</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
35
public/manifest.json
Normal file
35
public/manifest.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "__MSG_app_name__",
|
||||
"description": "__MSG_app_description__",
|
||||
"version": "1.0.1",
|
||||
"default_locale": "zh",
|
||||
"author": "Gabe<yugang2002@gmail.com>",
|
||||
"homepage_url": "https://github.com/fishjar/kiss-translator",
|
||||
"background": {
|
||||
"service_worker": "static/js/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["static/js/content.js"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
],
|
||||
"permissions": ["storage"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"icons": {
|
||||
"192": "images/logo192.png"
|
||||
},
|
||||
"action": {
|
||||
"default_icon": {
|
||||
"192": "images/logo192.png"
|
||||
},
|
||||
"default_title": "__MSG_app_name__",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
}
|
||||
}
|
||||
134
src/apis/index.js
Normal file
134
src/apis/index.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import queryString from "query-string";
|
||||
import { fetchPolyfill } from "../libs/fetch";
|
||||
import {
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_OPENAI,
|
||||
URL_MICROSOFT_TRANS,
|
||||
OPT_LANGS_SPECIAL,
|
||||
PROMPT_PLACE_FROM,
|
||||
PROMPT_PLACE_TO,
|
||||
} from "../config";
|
||||
import { getSetting, detectLang } from "../libs";
|
||||
|
||||
/**
|
||||
* 谷歌翻译
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* @returns
|
||||
*/
|
||||
const apiGoogleTranslate = async (text, to, from) => {
|
||||
const params = {
|
||||
client: "gtx",
|
||||
dt: "t",
|
||||
dj: 1,
|
||||
ie: "UTF-8",
|
||||
sl: from,
|
||||
tl: to,
|
||||
q: text,
|
||||
};
|
||||
const { googleUrl } = await getSetting();
|
||||
const input = `${googleUrl}?${queryString.stringify(params)}`;
|
||||
return fetchPolyfill(input, {
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-Translator": OPT_TRANS_GOOGLE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 微软翻译
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* @returns
|
||||
*/
|
||||
const apiMicrosoftTranslate = (text, to, from) => {
|
||||
const params = {
|
||||
from,
|
||||
to,
|
||||
"api-version": "3.0",
|
||||
};
|
||||
const input = `${URL_MICROSOFT_TRANS}?${queryString.stringify(params)}`;
|
||||
return fetchPolyfill(input, {
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-Translator": OPT_TRANS_MICROSOFT,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ Text: text }]),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI 翻译
|
||||
* @param {*} text
|
||||
* @param {*} to
|
||||
* @param {*} from
|
||||
* @returns
|
||||
*/
|
||||
const apiOpenaiTranslate = async (text, to, from) => {
|
||||
const { openaiUrl, openaiModel, openaiPrompt } = await getSetting();
|
||||
let prompt = openaiPrompt
|
||||
.replaceAll(PROMPT_PLACE_FROM, from)
|
||||
.replaceAll(PROMPT_PLACE_TO, to);
|
||||
return fetchPolyfill(openaiUrl, {
|
||||
useCache: true,
|
||||
usePool: true,
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
"X-Translator": OPT_TRANS_OPENAI,
|
||||
},
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: openaiModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 256,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一翻译接口
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export const apiTranslate = async ({ translator, q, fromLang, toLang }) => {
|
||||
let trText = "";
|
||||
let isSame = false;
|
||||
|
||||
let from = OPT_LANGS_SPECIAL?.[translator]?.get(fromLang) ?? fromLang;
|
||||
let to = OPT_LANGS_SPECIAL?.[translator]?.get(toLang) ?? toLang;
|
||||
|
||||
if (translator === OPT_TRANS_GOOGLE) {
|
||||
const res = await apiGoogleTranslate(q, to, from);
|
||||
trText = res.sentences.map((item) => item.trans).join(" ");
|
||||
isSame = to === res.src;
|
||||
} else if (translator === OPT_TRANS_MICROSOFT) {
|
||||
const res = await apiMicrosoftTranslate(q, to, from);
|
||||
trText = res[0].translations[0].text;
|
||||
isSame = to === res[0].detectedLanguage.language;
|
||||
} else if (translator === OPT_TRANS_OPENAI) {
|
||||
const res = await apiOpenaiTranslate(q, to, from);
|
||||
trText = res?.choices?.[0].message.content;
|
||||
isSame = (await detectLang(q)) === (await detectLang(trText));
|
||||
}
|
||||
|
||||
return [trText, isSame];
|
||||
};
|
||||
59
src/background.js
Normal file
59
src/background.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import {
|
||||
MSG_FETCH,
|
||||
MSG_FETCH_LIMIT,
|
||||
DEFAULT_SETTING,
|
||||
DEFAULT_RULES,
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
CACHE_NAME,
|
||||
} from "./config";
|
||||
import { fetchData, setFetchLimit } from "./libs/fetch";
|
||||
import storage from "./libs/storage";
|
||||
import { getSetting } from "./libs";
|
||||
|
||||
/**
|
||||
* 插件安装
|
||||
*/
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log("onInstalled");
|
||||
storage.trySetObj(STOKEY_SETTING, DEFAULT_SETTING);
|
||||
storage.trySetObj(STOKEY_RULES, DEFAULT_RULES);
|
||||
});
|
||||
|
||||
/**
|
||||
* 浏览器启动
|
||||
*/
|
||||
browser.runtime.onStartup.addListener(async () => {
|
||||
console.log("onStartup");
|
||||
const { clearCache } = await getSetting();
|
||||
if (clearCache) {
|
||||
caches.delete(CACHE_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听消息
|
||||
*/
|
||||
browser.runtime.onMessage.addListener(
|
||||
({ action, args }, sender, sendResponse) => {
|
||||
switch (action) {
|
||||
case MSG_FETCH:
|
||||
fetchData(args.input, args.init)
|
||||
.then((data) => {
|
||||
sendResponse({ data });
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ error: error.message });
|
||||
});
|
||||
break;
|
||||
case MSG_FETCH_LIMIT:
|
||||
setFetchLimit(args.limit);
|
||||
sendResponse({ data: "ok" });
|
||||
break;
|
||||
default:
|
||||
sendResponse({ error: `message action is unavailable: ${action}` });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
189
src/config/i18n.js
Normal file
189
src/config/i18n.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import { URL_APP_HOMEPAGE } from ".";
|
||||
|
||||
export const UI_LANGS = [
|
||||
["zh", "中文"],
|
||||
["en", "English"],
|
||||
];
|
||||
|
||||
export const I18N = {
|
||||
app_name: {
|
||||
zh: `简约翻译`,
|
||||
en: `KISS Translator`,
|
||||
},
|
||||
translate: {
|
||||
zh: `翻译`,
|
||||
en: `Translate`,
|
||||
},
|
||||
basic_setting: {
|
||||
zh: `基本设置`,
|
||||
en: `Basic Setting`,
|
||||
},
|
||||
rules_setting: {
|
||||
zh: `规则设置`,
|
||||
en: `Rules Setting`,
|
||||
},
|
||||
about: {
|
||||
zh: `关于`,
|
||||
en: `About`,
|
||||
},
|
||||
about_md: {
|
||||
zh: `README.md`,
|
||||
en: `README.en.md`,
|
||||
},
|
||||
about_md_local: {
|
||||
zh: `请 [点击这里](${URL_APP_HOMEPAGE}) 查看详情。`,
|
||||
en: `Please [click here](${URL_APP_HOMEPAGE}) for details.`,
|
||||
},
|
||||
ui_lang: {
|
||||
zh: `界面语言`,
|
||||
en: `Interface Language`,
|
||||
},
|
||||
fetch_limit: {
|
||||
zh: `并发请求数量`,
|
||||
en: `Concurrent Requests Limit`,
|
||||
},
|
||||
translate_service: {
|
||||
zh: `翻译服务`,
|
||||
en: `Translate Service`,
|
||||
},
|
||||
from_lang: {
|
||||
zh: `原文语言`,
|
||||
en: `Source Language`,
|
||||
},
|
||||
to_lang: {
|
||||
zh: `目标语言`,
|
||||
en: `Target Language`,
|
||||
},
|
||||
text_style: {
|
||||
zh: `文字样式`,
|
||||
en: `Text Style`,
|
||||
},
|
||||
google_api: {
|
||||
zh: `谷歌翻译接口`,
|
||||
en: `Google Translate API`,
|
||||
},
|
||||
default_selector: {
|
||||
zh: `默认选择器`,
|
||||
en: `Default selector`,
|
||||
},
|
||||
selector_rules: {
|
||||
zh: `选择器规则`,
|
||||
en: `Selector Rules`,
|
||||
},
|
||||
save: {
|
||||
zh: `保存`,
|
||||
en: `Save`,
|
||||
},
|
||||
edit: {
|
||||
zh: `编辑`,
|
||||
en: `Edit`,
|
||||
},
|
||||
cancel: {
|
||||
zh: `取消`,
|
||||
en: `Cancel`,
|
||||
},
|
||||
delete: {
|
||||
zh: `删除`,
|
||||
en: `Delete`,
|
||||
},
|
||||
reset: {
|
||||
zh: `重置`,
|
||||
en: `Reset`,
|
||||
},
|
||||
add: {
|
||||
zh: `添加`,
|
||||
en: `Add`,
|
||||
},
|
||||
advanced_warn: {
|
||||
zh: `如不明白,谨慎修改!不同的浏览器,选择器规则不一定通用。`,
|
||||
en: `If you don't understand, modify it carefully! Different browsers, the selector rules are not necessarily universal.`,
|
||||
},
|
||||
under_line: {
|
||||
zh: `下划线`,
|
||||
en: `Under Line`,
|
||||
},
|
||||
fuzzy: {
|
||||
zh: `模糊`,
|
||||
en: `Fuzzy`,
|
||||
},
|
||||
setting: {
|
||||
zh: `设置`,
|
||||
en: `Setting`,
|
||||
},
|
||||
pattern: {
|
||||
zh: `匹配网址`,
|
||||
en: `URL pattern`,
|
||||
},
|
||||
pattern_helper: {
|
||||
zh: `多个URL支持英文逗号“,”分隔`,
|
||||
en: `Multiple URLs can be separated by English commas ","`,
|
||||
},
|
||||
selector_helper: {
|
||||
zh: `遵循CSS选择器规则,但不同浏览器,可能支持不同,有些不同的写法。`,
|
||||
en: `Follow the CSS selector rules, but different browsers may support different, and some have different ways of writing.`,
|
||||
},
|
||||
translate_switch: {
|
||||
zh: `开启翻译`,
|
||||
en: `Translate Switch`,
|
||||
},
|
||||
default_enabled: {
|
||||
zh: `默认开启`,
|
||||
en: `Enabled`,
|
||||
},
|
||||
default_disabled: {
|
||||
zh: `默认关闭`,
|
||||
en: `Disabled`,
|
||||
},
|
||||
selector: {
|
||||
zh: `选择器`,
|
||||
en: `Selector`,
|
||||
},
|
||||
import: {
|
||||
zh: `导入`,
|
||||
en: `Import`,
|
||||
},
|
||||
export: {
|
||||
zh: `导出`,
|
||||
en: `Export`,
|
||||
},
|
||||
error_cant_be_blank: {
|
||||
zh: `不能为空`,
|
||||
en: `Can not be blank`,
|
||||
},
|
||||
error_duplicate_values: {
|
||||
zh: `存在重复的值`,
|
||||
en: `There are duplicate values`,
|
||||
},
|
||||
error_wrong_file_type: {
|
||||
zh: `错误的文件类型`,
|
||||
en: `Wrong file type`,
|
||||
},
|
||||
openai_api: {
|
||||
zh: `OpenAI 接口地址`,
|
||||
en: `OpenAI API`,
|
||||
},
|
||||
openai_key: {
|
||||
zh: `OpenAI 密钥`,
|
||||
en: `OpenAI Key`,
|
||||
},
|
||||
openai_model: {
|
||||
zh: `OpenAI 模型`,
|
||||
en: `OpenAI Model`,
|
||||
},
|
||||
openai_prompt: {
|
||||
zh: `OpenAI 提示词`,
|
||||
en: `OpenAI Prompt`,
|
||||
},
|
||||
clear_cache: {
|
||||
zh: `是否清除缓存`,
|
||||
en: `Whether clear cache`,
|
||||
},
|
||||
clear_cache_never: {
|
||||
zh: `不清除缓存`,
|
||||
en: `Never clear cache`,
|
||||
},
|
||||
clear_cache_restart: {
|
||||
zh: `重启浏览器时清除缓存`,
|
||||
en: `Clear cache when restarting browser`,
|
||||
},
|
||||
};
|
||||
132
src/config/index.js
Normal file
132
src/config/index.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import { DEFAULT_SELECTOR, RULES } from "./rules";
|
||||
export { I18N, UI_LANGS } from "./i18n";
|
||||
|
||||
const APP_NAME = process.env.REACT_APP_NAME.trim().split(/\s+/).join("-");
|
||||
|
||||
export const APP_LCNAME = APP_NAME.toLowerCase();
|
||||
|
||||
export const STOKEY_MSAUTH = `${APP_NAME}_msauth`;
|
||||
export const STOKEY_SETTING = `${APP_NAME}_setting`;
|
||||
export const STOKEY_RULES = `${APP_NAME}_rules`;
|
||||
|
||||
export const CACHE_NAME = `${APP_NAME}_cache`;
|
||||
|
||||
export const MSG_FETCH = "fetch";
|
||||
export const MSG_FETCH_LIMIT = "fetch_limit";
|
||||
export const MSG_TRANS_TOGGLE = "trans_toggle";
|
||||
export const MSG_TRANS_GETRULE = "trans_getrule";
|
||||
export const MSG_TRANS_PUTRULE = "trans_putrule";
|
||||
|
||||
export const THEME_LIGHT = "light";
|
||||
export const THEME_DARK = "dark";
|
||||
|
||||
export const URL_APP_HOMEPAGE = "https://github.com/fishjar/kiss-translator";
|
||||
export const URL_RAW_PREFIX =
|
||||
"https://raw.githubusercontent.com/fishjar/kiss-translator/master";
|
||||
export const URL_MICROSOFT_AUTH = "https://edge.microsoft.com/translate/auth";
|
||||
export const URL_MICROSOFT_TRANS =
|
||||
"https://api-edge.cognitive.microsofttranslator.com/translate";
|
||||
|
||||
export const OPT_TRANS_GOOGLE = "Google";
|
||||
export const OPT_TRANS_MICROSOFT = "Microsoft";
|
||||
export const OPT_TRANS_OPENAI = "OpenAI";
|
||||
export const OPT_TRANS_ALL = [
|
||||
OPT_TRANS_GOOGLE,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_OPENAI,
|
||||
];
|
||||
|
||||
export const OPT_LANGS_TO = [
|
||||
["en", "English - English"],
|
||||
["zh-CN", "Simplified Chinese - 简体中文"],
|
||||
["zh-TW", "Traditional Chinese - 繁體中文"],
|
||||
["ar", "Arabic - العربية"],
|
||||
["bg", "Bulgarian - Български"],
|
||||
["ca", "Catalan - Català"],
|
||||
["hr", "Croatian - Hrvatski"],
|
||||
["cs", "Czech - Čeština"],
|
||||
["da", "Danish - Dansk"],
|
||||
["nl", "Dutch - Nederlands"],
|
||||
["fi", "Finnish - Suomi"],
|
||||
["fr", "French - Français"],
|
||||
["de", "German - Deutsch"],
|
||||
["el", "Greek - Ελληνικά"],
|
||||
["hi", "Hindi - हिन्दी"],
|
||||
["hu", "Hungarian - Magyar"],
|
||||
["id", "Indonesian - Indonesia"],
|
||||
["it", "Italian - Italiano"],
|
||||
["ja", "Japanese - 日本語"],
|
||||
["ko", "Korean - 한국어"],
|
||||
["ms", "Malay - Melayu"],
|
||||
["mt", "Maltese - Malti"],
|
||||
["nb", "Norwegian - Norsk Bokmål"],
|
||||
["pl", "Polish - Polski"],
|
||||
["pt", "Portuguese - Português"],
|
||||
["ro", "Romanian - Română"],
|
||||
["ru", "Russian - Русский"],
|
||||
["sk", "Slovak - Slovenčina"],
|
||||
["sl", "Slovenian - Slovenščina"],
|
||||
["es", "Spanish - Español"],
|
||||
["sv", "Swedish - Svenska"],
|
||||
["ta", "Tamil - தமிழ்"],
|
||||
["te", "Telugu - తెలుగు"],
|
||||
["th", "Thai - ไทย"],
|
||||
["tr", "Turkish - Türkçe"],
|
||||
["uk", "Ukrainian - Українська"],
|
||||
["vi", "Vietnamese - Tiếng Việt"],
|
||||
];
|
||||
export const OPT_LANGS_FROM = [["auto", "Auto-detect"], ...OPT_LANGS_TO];
|
||||
export const OPT_LANGS_SPECIAL = {
|
||||
[OPT_TRANS_MICROSOFT]: new Map([
|
||||
["auto", ""],
|
||||
["zh-CN", "zh-Hans"],
|
||||
["zh-TW", "zh-Hant"],
|
||||
]),
|
||||
[OPT_TRANS_OPENAI]: new Map(
|
||||
OPT_LANGS_FROM.map(([key, val]) => [key, val.split("-")[0].trim()])
|
||||
),
|
||||
};
|
||||
|
||||
export const OPT_STYLE_LINE = "under_line"; // 下划线
|
||||
export const OPT_STYLE_FUZZY = "fuzzy"; // 模糊
|
||||
export const OPT_STYLE_ALL = [OPT_STYLE_LINE, OPT_STYLE_FUZZY];
|
||||
|
||||
export const DEFAULT_FETCH_LIMIT = 1; // 默认并发请求数
|
||||
export const DEFAULT_FETCH_INTERVAL = 500; // 默认请求间隔时间
|
||||
|
||||
export const PROMPT_PLACE_FROM = "{{from}}"; // 占位符
|
||||
export const PROMPT_PLACE_TO = "{{to}}"; // 占位符
|
||||
|
||||
export const DEFAULT_RULE = {
|
||||
pattern: "*",
|
||||
selector: DEFAULT_SELECTOR,
|
||||
translator: OPT_TRANS_MICROSOFT,
|
||||
fromLang: "auto",
|
||||
toLang: "zh-CN",
|
||||
textStyle: OPT_STYLE_LINE,
|
||||
transOpen: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_SETTING = {
|
||||
darkMode: false, // 深色模式
|
||||
uiLang: "zh", // 界面语言
|
||||
fetchLimit: DEFAULT_FETCH_LIMIT, // 请求并发数量
|
||||
clearCache: false, // 是否在浏览器下次启动时清除缓存
|
||||
googleUrl: "https://translate.googleapis.com/translate_a/single", // 谷歌翻译接口
|
||||
openaiUrl: "https://api.openai.com/v1/chat/completions",
|
||||
openaiKey: "",
|
||||
openaiModel: "gpt-4",
|
||||
openaiPrompt: `You will be provided with a sentence in ${PROMPT_PLACE_FROM}, and your task is to translate it into ${PROMPT_PLACE_TO}.`,
|
||||
};
|
||||
|
||||
export const DEFAULT_RULES = [
|
||||
...RULES.map((item) => ({
|
||||
...DEFAULT_RULE,
|
||||
...item,
|
||||
transOpen: true,
|
||||
})),
|
||||
DEFAULT_RULE,
|
||||
];
|
||||
|
||||
export const TRANS_MIN_LENGTH = 5; // 最短翻译长度
|
||||
export const TRANS_MAX_LENGTH = 5000; // 最长翻译长度
|
||||
41
src/config/rules.js
Normal file
41
src/config/rules.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const els = `li, p, h1, h2, h3, h4, h5, h6, dd`;
|
||||
|
||||
export const DEFAULT_SELECTOR =
|
||||
process.env.REACT_APP_BROWSER === "firefox"
|
||||
? `:is(${els})`
|
||||
: `:is(${els}):not(:has(:is(${els})))`;
|
||||
|
||||
export const RULES = [
|
||||
{
|
||||
pattern: `platform.openai.com/docs`,
|
||||
selector: `.docs-body ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `en.wikipedia.org`,
|
||||
selector: `h1, .mw-parser-output ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `stackoverflow.com`,
|
||||
selector: `h1, .s-prose p, .comment-body .comment-copy`,
|
||||
},
|
||||
{
|
||||
pattern: `developer.chrome.com/docs, medium.com`,
|
||||
selector: `h1, article ${DEFAULT_SELECTOR}`,
|
||||
},
|
||||
{
|
||||
pattern: `news.ycombinator.com`,
|
||||
selector: `.title, .commtext`,
|
||||
},
|
||||
{
|
||||
pattern: `github.com`,
|
||||
selector: `.markdown-body ${DEFAULT_SELECTOR}, .repo-description p, .Layout-sidebar .f4, .container-lg .py-4 .f5, .container-lg .my-4 .f5, .Box-row .pr-4, .Box-row article .mt-1, [itemprop='description']`,
|
||||
},
|
||||
{
|
||||
pattern: `twitter.com`,
|
||||
selector: `[data-testid='tweetText']`,
|
||||
},
|
||||
{
|
||||
pattern: `youtube.com`,
|
||||
selector: `h1, h3:not(:has(#author-text)), #content-text, #description, yt-attributed-string>span>span`,
|
||||
},
|
||||
];
|
||||
146
src/content.js
Normal file
146
src/content.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import browser from "./libs/browser";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
APP_LCNAME,
|
||||
MSG_TRANS_TOGGLE,
|
||||
MSG_TRANS_GETRULE,
|
||||
MSG_TRANS_PUTRULE,
|
||||
} from "./config";
|
||||
import Content from "./views/Content";
|
||||
import { StoragesProvider } from "./hooks/Storage";
|
||||
import { queryEls, getRules, matchRule } from "./libs";
|
||||
|
||||
/**
|
||||
* 翻译类
|
||||
*/
|
||||
class Translator {
|
||||
_rule = {};
|
||||
|
||||
_interseObserver = new IntersectionObserver(
|
||||
(intersections) => {
|
||||
intersections.forEach((intersection) => {
|
||||
if (intersection.isIntersecting) {
|
||||
this._render(intersection.target);
|
||||
this._interseObserver.unobserve(intersection.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
_mutaObserver = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
try {
|
||||
queryEls(this._rule.selector, node).forEach((el) => {
|
||||
this._interseObserver.observe(el);
|
||||
});
|
||||
} catch (err) {
|
||||
//
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
constructor(rule) {
|
||||
this._rule = rule;
|
||||
if (rule.transOpen) {
|
||||
this._register();
|
||||
}
|
||||
}
|
||||
|
||||
get rule() {
|
||||
return this._rule;
|
||||
}
|
||||
|
||||
updateRule = (obj) => {
|
||||
this._rule = { ...this._rule, ...obj };
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
if (this._rule.transOpen) {
|
||||
this._rule.transOpen = false;
|
||||
this._unRegister();
|
||||
} else {
|
||||
this._rule.transOpen = true;
|
||||
this._register();
|
||||
}
|
||||
};
|
||||
|
||||
_register = () => {
|
||||
// 监听节点变化
|
||||
this._mutaObserver.observe(document, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// 监听节点显示
|
||||
queryEls(this._rule.selector).forEach((el) => {
|
||||
this._interseObserver.observe(el);
|
||||
});
|
||||
};
|
||||
|
||||
_unRegister = () => {
|
||||
// 解除节点变化监听
|
||||
this._mutaObserver.disconnect();
|
||||
|
||||
// 解除节点显示监听
|
||||
queryEls(this._rule.selector).forEach((el) =>
|
||||
this._interseObserver.unobserve(el)
|
||||
);
|
||||
|
||||
// 移除已插入元素
|
||||
queryEls(APP_LCNAME).forEach((el) => el.remove());
|
||||
};
|
||||
|
||||
_render = (el) => {
|
||||
if (el.querySelector(APP_LCNAME)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const q = el.innerText.trim();
|
||||
if (!q) {
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("---> ", q);
|
||||
|
||||
const span = document.createElement(APP_LCNAME);
|
||||
el.appendChild(span);
|
||||
|
||||
const root = createRoot(span);
|
||||
root.render(
|
||||
<StoragesProvider>
|
||||
<Content q={q} rule={this._rule} />
|
||||
</StoragesProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口函数
|
||||
*/
|
||||
(async () => {
|
||||
const rules = await getRules();
|
||||
const rule = matchRule(rules, document.location.href);
|
||||
const translator = new Translator(rule);
|
||||
|
||||
// 监听消息
|
||||
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
|
||||
switch (action) {
|
||||
case MSG_TRANS_TOGGLE:
|
||||
translator.toggle();
|
||||
break;
|
||||
case MSG_TRANS_GETRULE:
|
||||
break;
|
||||
case MSG_TRANS_PUTRULE:
|
||||
translator.updateRule(args);
|
||||
break;
|
||||
default:
|
||||
return { error: `message action is unavailable: ${action}` };
|
||||
}
|
||||
return { data: translator.rule };
|
||||
});
|
||||
})();
|
||||
22
src/hooks/ColorMode.js
Normal file
22
src/hooks/ColorMode.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useSetting, useSettingUpdate } from "./Setting";
|
||||
|
||||
/**
|
||||
* 深色模式hook
|
||||
* @returns
|
||||
*/
|
||||
export function useDarkMode() {
|
||||
const setting = useSetting();
|
||||
return !!setting?.darkMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换深色模式
|
||||
* @returns
|
||||
*/
|
||||
export function useDarkModeSwitch() {
|
||||
const darkMode = useDarkMode();
|
||||
const updateSetting = useSettingUpdate();
|
||||
return async () => {
|
||||
await updateSetting({ darkMode: !darkMode });
|
||||
};
|
||||
}
|
||||
41
src/hooks/I18n.js
Normal file
41
src/hooks/I18n.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useSetting } from "./Setting";
|
||||
import { I18N, URL_RAW_PREFIX } from "../config";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* 多语言 hook
|
||||
* @returns
|
||||
*/
|
||||
export const useI18n = () => {
|
||||
const { uiLang } = useSetting() ?? {};
|
||||
return (key, defaultText = "") => I18N?.[key]?.[uiLang] ?? defaultText;
|
||||
};
|
||||
|
||||
export const useI18nMd = (key) => {
|
||||
const i18n = useI18n();
|
||||
const [md, setMd] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const fileName = i18n(key);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = `${URL_RAW_PREFIX}/${fileName}`;
|
||||
setLoading(true);
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
return res.text().then(setMd);
|
||||
}
|
||||
setError(`[${res.status}] ${res.statusText}`);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setLoading(false));
|
||||
}, [fileName]);
|
||||
|
||||
return [md, loading, error];
|
||||
};
|
||||
99
src/hooks/Rules.js
Normal file
99
src/hooks/Rules.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
STOKEY_RULES,
|
||||
OPT_TRANS_ALL,
|
||||
OPT_STYLE_ALL,
|
||||
OPT_LANGS_FROM,
|
||||
OPT_LANGS_TO,
|
||||
} from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { useStorages } from "./Storage";
|
||||
import { matchValue } from "../libs/utils";
|
||||
|
||||
/**
|
||||
* 匹配规则增删改查 hook
|
||||
* @returns
|
||||
*/
|
||||
export function useRules() {
|
||||
const storages = useStorages();
|
||||
let rules = storages?.[STOKEY_RULES] || [];
|
||||
|
||||
const add = async (rule) => {
|
||||
rules = [...rules];
|
||||
if (rule.pattern === "*") {
|
||||
return;
|
||||
}
|
||||
if (rules.map((item) => item.pattern).includes(rule.pattern)) {
|
||||
return;
|
||||
}
|
||||
await storage.setObj(STOKEY_RULES, [rule, ...rules]);
|
||||
};
|
||||
|
||||
const del = async (pattern) => {
|
||||
rules = [...rules];
|
||||
if (pattern === "*") {
|
||||
return;
|
||||
}
|
||||
await storage.setObj(
|
||||
STOKEY_RULES,
|
||||
rules.filter((item) => item.pattern !== pattern)
|
||||
);
|
||||
};
|
||||
|
||||
const put = async (index, obj) => {
|
||||
rules = [...rules];
|
||||
if (!rules[index]) {
|
||||
return;
|
||||
}
|
||||
if (index === rules.length - 1) {
|
||||
obj.pattern = "*";
|
||||
}
|
||||
rules[index] = { ...rules[index], ...obj };
|
||||
await storage.setObj(STOKEY_RULES, rules);
|
||||
};
|
||||
|
||||
const merge = async (newRules) => {
|
||||
const fromLangs = OPT_LANGS_FROM.map((item) => item[0]);
|
||||
const toLangs = OPT_LANGS_TO.map((item) => item[0]);
|
||||
rules = [...rules];
|
||||
newRules
|
||||
.filter(
|
||||
({ pattern, selector }) =>
|
||||
pattern &&
|
||||
selector &&
|
||||
typeof pattern === "string" &&
|
||||
typeof selector === "string"
|
||||
)
|
||||
.map(
|
||||
({
|
||||
pattern,
|
||||
selector,
|
||||
translator,
|
||||
fromLang,
|
||||
toLang,
|
||||
textStyle,
|
||||
transOpen,
|
||||
}) => ({
|
||||
pattern,
|
||||
selector,
|
||||
translator: matchValue(OPT_TRANS_ALL, translator),
|
||||
fromLang: matchValue(fromLangs, fromLang),
|
||||
toLang: matchValue(toLangs, toLang),
|
||||
textStyle: matchValue(OPT_STYLE_ALL, textStyle),
|
||||
transOpen: matchValue([true, false], transOpen),
|
||||
})
|
||||
)
|
||||
.forEach((newRule) => {
|
||||
const rule = rules.find(
|
||||
(oldRule) => oldRule.pattern === newRule.pattern
|
||||
);
|
||||
if (rule) {
|
||||
Object.assign(rule, newRule);
|
||||
} else {
|
||||
rules.unshift(newRule);
|
||||
}
|
||||
});
|
||||
await storage.setObj(STOKEY_RULES, rules);
|
||||
};
|
||||
|
||||
return [rules, add, del, put, merge];
|
||||
}
|
||||
22
src/hooks/Setting.js
Normal file
22
src/hooks/Setting.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { STOKEY_SETTING } from "../config";
|
||||
import storage from "../libs/storage";
|
||||
import { useStorages } from "./Storage";
|
||||
|
||||
/**
|
||||
* 设置hook
|
||||
* @returns
|
||||
*/
|
||||
export function useSetting() {
|
||||
const storages = useStorages();
|
||||
return storages?.[STOKEY_SETTING];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设置
|
||||
* @returns
|
||||
*/
|
||||
export function useSettingUpdate() {
|
||||
return async (obj) => {
|
||||
await storage.putObj(STOKEY_SETTING, obj);
|
||||
};
|
||||
}
|
||||
86
src/hooks/Storage.js
Normal file
86
src/hooks/Storage.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import browser from "../libs/browser";
|
||||
import {
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
STOKEY_MSAUTH,
|
||||
DEFAULT_SETTING,
|
||||
DEFAULT_RULES,
|
||||
} from "../config";
|
||||
import storage from "../libs/storage";
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const defaultStorage = {
|
||||
[STOKEY_MSAUTH]: null,
|
||||
[STOKEY_SETTING]: DEFAULT_SETTING,
|
||||
[STOKEY_RULES]: DEFAULT_RULES,
|
||||
};
|
||||
|
||||
const StoragesContext = createContext(null);
|
||||
|
||||
export function StoragesProvider({ children }) {
|
||||
const [storages, setStorages] = useState(null);
|
||||
|
||||
const handleChanged = (changes) => {
|
||||
if (!browser) {
|
||||
const { key, oldValue, newValue } = changes;
|
||||
changes = {
|
||||
[key]: {
|
||||
oldValue,
|
||||
newValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
const newStorages = {};
|
||||
Object.entries(changes)
|
||||
.filter(([_, { oldValue, newValue }]) => oldValue !== newValue)
|
||||
.forEach(([key, { newValue }]) => {
|
||||
newStorages[key] = JSON.parse(newValue);
|
||||
});
|
||||
if (Object.keys(newStorages).length !== 0) {
|
||||
setStorages((pre) => ({ ...pre, ...newStorages }));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 首次从storage同步配置到内存
|
||||
(async () => {
|
||||
const curStorages = {};
|
||||
const keys = Object.keys(defaultStorage);
|
||||
for (const key of keys) {
|
||||
const val = await storage.get(key);
|
||||
if (val) {
|
||||
curStorages[key] = JSON.parse(val);
|
||||
} else {
|
||||
await storage.setObj(key, defaultStorage[key]);
|
||||
curStorages[key] = defaultStorage[key];
|
||||
}
|
||||
}
|
||||
setStorages(curStorages);
|
||||
})();
|
||||
|
||||
// 监听storage,并同步到内存中
|
||||
storage.onChanged(handleChanged);
|
||||
|
||||
// 解除监听
|
||||
return () => {
|
||||
if (browser?.storage) {
|
||||
browser.storage.onChanged.removeListener(handleChanged);
|
||||
} else {
|
||||
window.removeEventListener("storage", handleChanged);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StoragesContext.Provider value={storages}>
|
||||
{children}
|
||||
</StoragesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useStorages() {
|
||||
return useContext(StoragesContext);
|
||||
}
|
||||
30
src/hooks/Theme.js
Normal file
30
src/hooks/Theme.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from "react";
|
||||
import { ThemeProvider, createTheme } from "@mui/material/styles";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import { useDarkMode } from "./ColorMode";
|
||||
import { THEME_DARK, THEME_LIGHT } from "../config";
|
||||
|
||||
/**
|
||||
* mui 主题配置
|
||||
* @param {*} param0
|
||||
* @returns
|
||||
*/
|
||||
export default function MuiThemeProvider({ children, options }) {
|
||||
const darkMode = useDarkMode();
|
||||
const theme = useMemo(() => {
|
||||
return createTheme({
|
||||
palette: {
|
||||
mode: darkMode ? THEME_DARK : THEME_LIGHT,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}, [darkMode, options]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
79
src/hooks/Translate.js
Normal file
79
src/hooks/Translate.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { apiTranslate } from "../apis";
|
||||
import browser from "../libs/browser";
|
||||
import {
|
||||
TRANS_MIN_LENGTH,
|
||||
TRANS_MAX_LENGTH,
|
||||
MSG_TRANS_PUTRULE,
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
MSG_FETCH_LIMIT,
|
||||
} from "../config";
|
||||
import { useSetting } from "./Setting";
|
||||
import { sendMsg } from "../libs/msg";
|
||||
import { detectLang } from "../libs";
|
||||
|
||||
/**
|
||||
* 翻译hook
|
||||
* @param {*} q
|
||||
* @returns
|
||||
*/
|
||||
export function useTranslate(q, initRule) {
|
||||
const [text, setText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sameLang, setSamelang] = useState(false);
|
||||
const [rule, setRule] = useState(initRule);
|
||||
const { fetchLimit = DEFAULT_FETCH_LIMIT } = useSetting() || {};
|
||||
|
||||
const { translator, fromLang, toLang, textStyle } = rule;
|
||||
|
||||
const handleMessage = ({ action, args }) => {
|
||||
if (action === MSG_TRANS_PUTRULE) {
|
||||
setRule((pre) => ({ ...pre, ...args }));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
browser?.runtime.onMessage.addListener(handleMessage);
|
||||
return () => {
|
||||
browser?.runtime.onMessage.removeListener(handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
sendMsg(MSG_FETCH_LIMIT, { limit: fetchLimit });
|
||||
}, [fetchLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
// 太长或太短不翻译
|
||||
if (q.length < TRANS_MIN_LENGTH || q.length > TRANS_MAX_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const deLang = await detectLang(q);
|
||||
if (toLang.includes(deLang)) {
|
||||
setSamelang(true);
|
||||
} else {
|
||||
const [trText, isSame] = await apiTranslate({
|
||||
translator,
|
||||
q,
|
||||
fromLang,
|
||||
toLang,
|
||||
});
|
||||
setText(trText);
|
||||
setSamelang(isSame);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[translate]", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [q, translator, fromLang, toLang]);
|
||||
|
||||
return { text, sameLang, loading, textStyle };
|
||||
}
|
||||
16
src/index.js
Normal file
16
src/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { StoragesProvider } from "./hooks/Storage";
|
||||
import ThemeProvider from "./hooks/Theme";
|
||||
import Popup from "./views/Popup";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StoragesProvider>
|
||||
<ThemeProvider>
|
||||
<Popup />
|
||||
</ThemeProvider>
|
||||
</StoragesProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
37
src/libs/auth.js
Normal file
37
src/libs/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import storage from "./storage";
|
||||
import { STOKEY_MSAUTH, URL_MICROSOFT_AUTH } from "../config";
|
||||
import { fetchData } from "./fetch";
|
||||
|
||||
const parseMSToken = (token) => JSON.parse(atob(token.split(".")[1])).exp;
|
||||
|
||||
/**
|
||||
* 闭包缓存token,减少对storage查询
|
||||
* @returns
|
||||
*/
|
||||
const _msAuth = () => {
|
||||
let { token, exp } = {};
|
||||
|
||||
return async () => {
|
||||
// 查询内存缓存
|
||||
const now = Date.now();
|
||||
if (token && exp * 1000 > now + 1000) {
|
||||
return [token, exp];
|
||||
}
|
||||
|
||||
// 查询storage缓存
|
||||
const res = (await storage.getObj(STOKEY_MSAUTH)) || {};
|
||||
token = res.token;
|
||||
exp = res.exp;
|
||||
if (token && exp * 1000 > now + 1000) {
|
||||
return [token, exp];
|
||||
}
|
||||
|
||||
// 缓存没有或失效,查询接口
|
||||
token = await fetchData(URL_MICROSOFT_AUTH);
|
||||
exp = parseMSToken(token);
|
||||
await storage.setObj(STOKEY_MSAUTH, { token, exp });
|
||||
return [token, exp];
|
||||
};
|
||||
};
|
||||
|
||||
export const msAuth = _msAuth();
|
||||
15
src/libs/browser.js
Normal file
15
src/libs/browser.js
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 浏览器兼容插件,另可用于判断是插件模式还是网页模式,方便开发
|
||||
* @returns
|
||||
*/
|
||||
function _browser() {
|
||||
try {
|
||||
return require("webextension-polyfill");
|
||||
} catch (err) {
|
||||
console.log("[browser]", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = _browser();
|
||||
|
||||
export default browser;
|
||||
183
src/libs/fetch.js
Normal file
183
src/libs/fetch.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import browser from "./browser";
|
||||
import { sendMsg } from "./msg";
|
||||
import {
|
||||
MSG_FETCH,
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
DEFAULT_FETCH_INTERVAL,
|
||||
CACHE_NAME,
|
||||
OPT_TRANS_MICROSOFT,
|
||||
OPT_TRANS_OPENAI,
|
||||
} from "../config";
|
||||
import { msAuth } from "./auth";
|
||||
import { getSetting } from ".";
|
||||
|
||||
/**
|
||||
* request 改造,因缓存必须是GET方法
|
||||
* @param {*} request
|
||||
* @returns
|
||||
*/
|
||||
const newCacheReq = async (request) => {
|
||||
if (request.method === "GET") {
|
||||
return request;
|
||||
}
|
||||
|
||||
const body = await request.clone().text();
|
||||
const cacheUrl = new URL(request.url);
|
||||
cacheUrl.pathname += body;
|
||||
|
||||
return new Request(cacheUrl.toString(), { method: "GET" });
|
||||
};
|
||||
|
||||
/**
|
||||
* request 改造,根据不同翻译服务
|
||||
* @param {*} request
|
||||
* @returns
|
||||
*/
|
||||
const newReq = async (request) => {
|
||||
const translator = request.headers.get("X-Translator");
|
||||
if (translator === OPT_TRANS_MICROSOFT) {
|
||||
const [token] = await msAuth();
|
||||
request.headers.set("Authorization", `Bearer ${token}`);
|
||||
} else if (translator === OPT_TRANS_OPENAI) {
|
||||
const { openaiKey } = await getSetting();
|
||||
request.headers.set("Authorization", `Bearer ${openaiKey}`); // OpenAI
|
||||
request.headers.set("api-key", openaiKey); // Azure OpenAI
|
||||
}
|
||||
request.headers.delete("X-Translator");
|
||||
return request;
|
||||
};
|
||||
|
||||
/**
|
||||
* 请求池
|
||||
* @param {*} l
|
||||
* @param {*} t
|
||||
* @returns
|
||||
*/
|
||||
const _fetchPool = (l = 1, t = 1000) => {
|
||||
let limitCount = l; // 限制并发数量
|
||||
const intervalTime = t; // 请求间隔时间
|
||||
const pool = []; // 请求池
|
||||
const maxRetry = 2; // 最大重试次数
|
||||
let currentCount = 0; // 当前请求数量
|
||||
|
||||
setInterval(async () => {
|
||||
const count = limitCount - currentCount;
|
||||
|
||||
if (pool.length === 0 || count <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = pool.shift();
|
||||
if (item) {
|
||||
const { request, resolve, reject, retry } = item;
|
||||
currentCount++;
|
||||
try {
|
||||
const req = await request();
|
||||
const res = await fetch(req);
|
||||
resolve(res);
|
||||
} catch (err) {
|
||||
if (retry < maxRetry) {
|
||||
pool.push({ request, resolve, reject, retry: retry + 1 });
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} finally {
|
||||
currentCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, intervalTime);
|
||||
|
||||
return [
|
||||
async (req, usePool) => {
|
||||
const request = () => newReq(req.clone());
|
||||
if (usePool) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pool.push({ request, resolve, reject, retry: 0 });
|
||||
});
|
||||
} else {
|
||||
return fetch(await request());
|
||||
}
|
||||
},
|
||||
(limit = -1) => {
|
||||
if (limit >= 1 && limit <= 10 && limitCount !== limit) {
|
||||
limitCount = limit;
|
||||
}
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const [_fetch, setFetchLimit] = _fetchPool(
|
||||
DEFAULT_FETCH_LIMIT,
|
||||
DEFAULT_FETCH_INTERVAL
|
||||
);
|
||||
|
||||
/**
|
||||
* 调用fetch接口
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const fetchData = async (
|
||||
input,
|
||||
{ useCache = false, usePool = false, ...init } = {}
|
||||
) => {
|
||||
const req = new Request(input, init);
|
||||
const cacheReq = await newCacheReq(req);
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
let res;
|
||||
|
||||
// 查询缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
res = await cache.match(cacheReq);
|
||||
} catch (err) {
|
||||
console.log("[cache match]", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
if (!res) {
|
||||
res = await _fetch(req, usePool);
|
||||
}
|
||||
|
||||
if (!res?.ok) {
|
||||
throw new Error(`response: ${res.statusText}`);
|
||||
}
|
||||
|
||||
// 插入缓存
|
||||
if (useCache) {
|
||||
try {
|
||||
await cache.put(cacheReq, res.clone());
|
||||
} catch (err) {
|
||||
console.log("[cache put]", err);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("Content-Type");
|
||||
if (contentType?.includes("json")) {
|
||||
return await res.json();
|
||||
}
|
||||
return await res.text();
|
||||
};
|
||||
|
||||
/**
|
||||
* 兼容性封装
|
||||
* @param {*} input
|
||||
* @param {*} init
|
||||
* @returns
|
||||
*/
|
||||
export const fetchPolyfill = async (input, init) => {
|
||||
if (browser?.runtime) {
|
||||
// 插件调用
|
||||
const res = await sendMsg(MSG_FETCH, { input, init });
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// 网页直接调用
|
||||
return await fetchData(input, init);
|
||||
};
|
||||
57
src/libs/index.js
Normal file
57
src/libs/index.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import storage from "./storage";
|
||||
import {
|
||||
DEFAULT_SETTING,
|
||||
STOKEY_SETTING,
|
||||
STOKEY_RULES,
|
||||
DEFAULT_RULE,
|
||||
} from "../config";
|
||||
import browser from "./browser";
|
||||
|
||||
/**
|
||||
* 获取节点列表并转为数组
|
||||
* @param {*} selector
|
||||
* @param {*} el
|
||||
* @returns
|
||||
*/
|
||||
export const queryEls = (selector, el = document) =>
|
||||
Array.from(el.querySelectorAll(selector));
|
||||
|
||||
/**
|
||||
* 查询storage中的设置
|
||||
* @returns
|
||||
*/
|
||||
export const getSetting = async () => ({
|
||||
...DEFAULT_SETTING,
|
||||
...((await storage.getObj(STOKEY_SETTING)) || {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* 查询规则列表
|
||||
* @returns
|
||||
*/
|
||||
export const getRules = async () => (await storage.getObj(STOKEY_RULES)) || [];
|
||||
|
||||
/**
|
||||
* 根据href匹配规则
|
||||
* TODO: 支持通配符(*)匹配
|
||||
* @param {*} rules
|
||||
* @param {string} href
|
||||
* @returns
|
||||
*/
|
||||
export const matchRule = (rules, href) =>
|
||||
rules.find((rule) =>
|
||||
rule.pattern
|
||||
.split(",")
|
||||
.some((p) => p.trim() === "*" || href.includes(p.trim()))
|
||||
) || DEFAULT_RULE;
|
||||
|
||||
/**
|
||||
* 本地语言识别
|
||||
* @param {*} q
|
||||
* @returns
|
||||
*/
|
||||
export const detectLang = async (q) => {
|
||||
const res = await browser?.i18n.detectLanguage(q);
|
||||
console.log("detecLang", q, res);
|
||||
return res?.languages?.[0]?.language;
|
||||
};
|
||||
21
src/libs/msg.js
Normal file
21
src/libs/msg.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import browser from "./browser";
|
||||
|
||||
/**
|
||||
* 发送消息给background
|
||||
* @param {*} action
|
||||
* @param {*} args
|
||||
* @returns
|
||||
*/
|
||||
export const sendMsg = (action, args) =>
|
||||
browser?.runtime?.sendMessage({ action, args });
|
||||
|
||||
/**
|
||||
* 发送消息给当前页面
|
||||
* @param {*} action
|
||||
* @param {*} args
|
||||
* @returns
|
||||
*/
|
||||
export const sendTabMsg = async (action, args) => {
|
||||
const tabs = await browser?.tabs.query({ active: true, currentWindow: true });
|
||||
return await browser?.tabs.sendMessage(tabs[0].id, { action, args });
|
||||
};
|
||||
91
src/libs/storage.js
Normal file
91
src/libs/storage.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import browser from "./browser";
|
||||
|
||||
async function set(key, val) {
|
||||
if (browser?.storage) {
|
||||
await browser.storage.local.set({ [key]: val });
|
||||
} else {
|
||||
const oldValue = window.localStorage.getItem(key);
|
||||
window.localStorage.setItem(key, val);
|
||||
// 手动唤起事件
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: val,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function get(key) {
|
||||
if (browser?.storage) {
|
||||
const res = await browser.storage.local.get([key]);
|
||||
return res[key];
|
||||
}
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
|
||||
async function del(key) {
|
||||
if (browser?.storage) {
|
||||
await browser.storage.local.remove([key]);
|
||||
} else {
|
||||
const oldValue = window.localStorage.getItem(key);
|
||||
window.localStorage.removeItem(key);
|
||||
// 手动唤起事件
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", {
|
||||
key,
|
||||
oldValue,
|
||||
newValue: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function setObj(key, obj) {
|
||||
await set(key, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
async function trySetObj(key, obj) {
|
||||
if (!(await get(key))) {
|
||||
await setObj(key, obj);
|
||||
}
|
||||
}
|
||||
|
||||
async function getObj(key) {
|
||||
const val = await get(key);
|
||||
return val && JSON.parse(val);
|
||||
}
|
||||
|
||||
async function putObj(key, obj) {
|
||||
const cur = (await getObj(key)) ?? {};
|
||||
await setObj(key, { ...cur, ...obj });
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听storage事件
|
||||
* @param {*} handleChanged
|
||||
*/
|
||||
function onChanged(handleChanged) {
|
||||
if (browser?.storage) {
|
||||
browser.storage.onChanged.addListener(handleChanged);
|
||||
} else {
|
||||
window.addEventListener("storage", handleChanged);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对storage的封装
|
||||
*/
|
||||
const storage = {
|
||||
get,
|
||||
set,
|
||||
del,
|
||||
setObj,
|
||||
trySetObj,
|
||||
getObj,
|
||||
putObj,
|
||||
onChanged,
|
||||
};
|
||||
|
||||
export default storage;
|
||||
29
src/libs/utils.js
Normal file
29
src/libs/utils.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 限制数字大小
|
||||
* @param {*} num
|
||||
* @param {*} min
|
||||
* @param {*} max
|
||||
* @returns
|
||||
*/
|
||||
export const limitNumber = (num, min = 0, max = 100) => {
|
||||
const number = parseInt(num);
|
||||
if (Number.isNaN(number) || number < min) {
|
||||
return min;
|
||||
} else if (number > max) {
|
||||
return max;
|
||||
}
|
||||
return number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 匹配是否为数组中的值
|
||||
* @param {*} arr
|
||||
* @param {*} val
|
||||
* @returns
|
||||
*/
|
||||
export const matchValue = (arr, val) => {
|
||||
if (arr.length === 0 || arr.includes(val)) {
|
||||
return val;
|
||||
}
|
||||
return arr[0];
|
||||
};
|
||||
19
src/options.js
Normal file
19
src/options.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import ThemeProvider from "./hooks/Theme";
|
||||
import Options from "./views/Options";
|
||||
import { HashRouter } from "react-router-dom";
|
||||
import { StoragesProvider } from "./hooks/Storage";
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<StoragesProvider>
|
||||
<ThemeProvider>
|
||||
<HashRouter>
|
||||
<Options />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
</StoragesProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
42
src/views/Content/LoadingIcon.js
Normal file
42
src/views/Content/LoadingIcon.js
Normal file
@@ -0,0 +1,42 @@
|
||||
export default function LoadingIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
style={{
|
||||
maxWidth: "1.2em",
|
||||
maxHeight: "1.2em",
|
||||
}}
|
||||
>
|
||||
<circle fill="#209CEE" stroke="none" cx="6" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 15 ; 0 -15; 0 15"
|
||||
repeatCount="indefinite"
|
||||
begin="0.1"
|
||||
/>
|
||||
</circle>
|
||||
<circle fill="#209CEE" stroke="none" cx="30" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 10 ; 0 -10; 0 10"
|
||||
repeatCount="indefinite"
|
||||
begin="0.2"
|
||||
/>
|
||||
</circle>
|
||||
<circle fill="#209CEE" stroke="none" cx="54" cy="50" r="6">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
type="translate"
|
||||
values="0 5 ; 0 -5; 0 5"
|
||||
repeatCount="indefinite"
|
||||
begin="0.3"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
59
src/views/Content/index.js
Normal file
59
src/views/Content/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import LoadingIcon from "./LoadingIcon";
|
||||
import { OPT_STYLE_FUZZY, OPT_STYLE_LINE } from "../../config";
|
||||
import { useTranslate } from "../../hooks/Translate";
|
||||
|
||||
export default function Content({ q, rule }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const { text, sameLang, loading, textStyle } = useTranslate(q, rule);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHover(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHover(false);
|
||||
};
|
||||
|
||||
const style = useMemo(() => {
|
||||
switch (textStyle) {
|
||||
case OPT_STYLE_LINE:
|
||||
return {
|
||||
opacity: hover ? 1 : 0.6,
|
||||
textDecoration: "dashed underline 2px",
|
||||
textUnderlineOffset: "0.3em",
|
||||
};
|
||||
case OPT_STYLE_FUZZY:
|
||||
return {
|
||||
filter: hover ? "none" : "blur(5px)",
|
||||
transition: "filter 0.3s ease-in-out",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}, [textStyle, hover]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{q.length > 40 ? <br /> : " "}
|
||||
<LoadingIcon />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (text && !sameLang) {
|
||||
return (
|
||||
<>
|
||||
{q.length > 40 ? <br /> : " "}
|
||||
<span
|
||||
style={style}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/views/Options/About.js
Normal file
18
src/views/Options/About.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useI18n, useI18nMd } from "../../hooks/I18n";
|
||||
|
||||
export default function About() {
|
||||
const i18n = useI18n();
|
||||
const [md, loading, error] = useI18nMd("about_md");
|
||||
return (
|
||||
<Box>
|
||||
{loading ? (
|
||||
<CircularProgress />
|
||||
) : (
|
||||
<ReactMarkdown children={error ? i18n("about_md_local") : md} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
51
src/views/Options/Header.js
Normal file
51
src/views/Options/Header.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import PropTypes from "prop-types";
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useDarkModeSwitch } from "../../hooks/ColorMode";
|
||||
import { useDarkMode } from "../../hooks/ColorMode";
|
||||
import LightModeIcon from "@mui/icons-material/LightMode";
|
||||
import DarkModeIcon from "@mui/icons-material/DarkMode";
|
||||
import { useI18n } from "../../hooks/I18n";
|
||||
|
||||
function Header(props) {
|
||||
const i18n = useI18n();
|
||||
const { onDrawerToggle } = props;
|
||||
const switchColorMode = useDarkModeSwitch();
|
||||
const darkMode = useDarkMode();
|
||||
|
||||
return (
|
||||
<AppBar
|
||||
color="primary"
|
||||
position="sticky"
|
||||
sx={{
|
||||
zIndex: 1300,
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense">
|
||||
<Box sx={{ display: { sm: "none", xs: "block" } }}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={onDrawerToggle}
|
||||
edge="start"
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>{i18n("app_name")}</Box>
|
||||
<IconButton onClick={switchColorMode} color="inherit">
|
||||
{darkMode ? <LightModeIcon /> : <DarkModeIcon />}
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
onDrawerToggle: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default Header;
|
||||
49
src/views/Options/Layout.js
Normal file
49
src/views/Options/Layout.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import CssBaseline from "@mui/material/CssBaseline";
|
||||
import Box from "@mui/material/Box";
|
||||
import Navigator from "./Navigator";
|
||||
import Header from "./Header";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
|
||||
export default function Layout() {
|
||||
const navWidth = 256;
|
||||
const location = useLocation();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const isSm = useMediaQuery(theme.breakpoints.up("sm"));
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CssBaseline />
|
||||
<Header onDrawerToggle={handleDrawerToggle} />
|
||||
|
||||
<Box sx={{ display: "flex" }}>
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { sm: navWidth }, flexShrink: { sm: 0 } }}
|
||||
>
|
||||
<Navigator
|
||||
PaperProps={{ style: { width: navWidth } }}
|
||||
variant={isSm ? "permanent" : "temporary"}
|
||||
open={isSm ? true : open}
|
||||
onClose={handleDrawerToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box component="main" sx={{ flex: 1, p: 2 }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
50
src/views/Options/Navigator.js
Normal file
50
src/views/Options/Navigator.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import List from "@mui/material/List";
|
||||
import ListItemButton from "@mui/material/ListItemButton";
|
||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import ListItemText from "@mui/material/ListItemText";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import { NavLink, useMatch } from "react-router-dom";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import InfoIcon from "@mui/icons-material/Info";
|
||||
import DesignServicesIcon from "@mui/icons-material/DesignServices";
|
||||
import { useI18n } from "../../hooks/I18n";
|
||||
|
||||
function LinkItem({ label, url, icon }) {
|
||||
const match = useMatch(url);
|
||||
return (
|
||||
<ListItemButton component={NavLink} to={url} selected={!!match}>
|
||||
<ListItemIcon>{icon}</ListItemIcon>
|
||||
<ListItemText>{label}</ListItemText>
|
||||
</ListItemButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navigator(props) {
|
||||
const i18n = useI18n();
|
||||
const memus = [
|
||||
{
|
||||
id: "basic_setting",
|
||||
label: i18n("basic_setting"),
|
||||
url: "/",
|
||||
icon: <SettingsIcon />,
|
||||
},
|
||||
{
|
||||
id: "rules_setting",
|
||||
label: i18n("rules_setting"),
|
||||
url: "/rules",
|
||||
icon: <DesignServicesIcon />,
|
||||
},
|
||||
{ id: "about", label: i18n("about"), url: "/about", icon: <InfoIcon /> },
|
||||
];
|
||||
return (
|
||||
<Drawer {...props}>
|
||||
<Toolbar variant="dense" />
|
||||
<List component="nav">
|
||||
{memus.map(({ id, label, url, icon }) => (
|
||||
<LinkItem key={id} label={label} url={url} icon={icon} />
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
412
src/views/Options/Rules.js
Normal file
412
src/views/Options/Rules.js
Normal file
@@ -0,0 +1,412 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import Button from "@mui/material/Button";
|
||||
import {
|
||||
DEFAULT_RULE,
|
||||
OPT_LANGS_FROM,
|
||||
OPT_LANGS_TO,
|
||||
OPT_TRANS_ALL,
|
||||
OPT_STYLE_ALL,
|
||||
} from "../../config";
|
||||
import { useState, useRef } from "react";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import { useI18n } from "../../hooks/I18n";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Accordion from "@mui/material/Accordion";
|
||||
import AccordionSummary from "@mui/material/AccordionSummary";
|
||||
import AccordionDetails from "@mui/material/AccordionDetails";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import { useRules } from "../../hooks/Rules";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import FileDownloadIcon from "@mui/icons-material/FileDownload";
|
||||
import FileUploadIcon from "@mui/icons-material/FileUpload";
|
||||
|
||||
function RuleFields({
|
||||
rule,
|
||||
rules,
|
||||
index,
|
||||
addRule,
|
||||
delRule,
|
||||
putRule,
|
||||
setShow,
|
||||
}) {
|
||||
const initFormValues = rule || {
|
||||
...DEFAULT_RULE,
|
||||
pattern: "",
|
||||
transOpen: true,
|
||||
};
|
||||
const editMode = !!rule;
|
||||
|
||||
const i18n = useI18n();
|
||||
const [disabled, setDisabled] = useState(editMode);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [formValues, setFormValues] = useState(initFormValues);
|
||||
const {
|
||||
pattern,
|
||||
selector,
|
||||
translator,
|
||||
fromLang,
|
||||
toLang,
|
||||
textStyle,
|
||||
transOpen,
|
||||
} = formValues;
|
||||
|
||||
const hasSamePattern = (str) => {
|
||||
for (const item of rules) {
|
||||
if (item.pattern === str && rule?.pattern !== str) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFocus = (e) => {
|
||||
e.preventDefault();
|
||||
const { name } = e.target;
|
||||
setErrors((pre) => ({ ...pre, [name]: "" }));
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
e.preventDefault();
|
||||
const { name, value } = e.target;
|
||||
setFormValues((pre) => ({ ...pre, [name]: value }));
|
||||
};
|
||||
|
||||
const handleCancel = (e) => {
|
||||
e.preventDefault();
|
||||
if (editMode) {
|
||||
setDisabled(true);
|
||||
} else {
|
||||
setShow(false);
|
||||
}
|
||||
setErrors({});
|
||||
setFormValues(initFormValues);
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const errors = {};
|
||||
if (!pattern.trim()) {
|
||||
errors.pattern = i18n("error_cant_be_blank");
|
||||
}
|
||||
if (!selector.trim()) {
|
||||
errors.selector = i18n("error_cant_be_blank");
|
||||
}
|
||||
if (hasSamePattern(pattern)) {
|
||||
errors.pattern = i18n("error_duplicate_values");
|
||||
}
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editMode) {
|
||||
// 编辑
|
||||
setDisabled(true);
|
||||
putRule(index, formValues);
|
||||
} else {
|
||||
// 添加
|
||||
addRule(formValues);
|
||||
setShow(false);
|
||||
setFormValues(initFormValues);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("pattern")}
|
||||
error={!!errors.pattern}
|
||||
helperText={errors.pattern ?? i18n("pattern_helper")}
|
||||
name="pattern"
|
||||
value={pattern}
|
||||
disabled={rule?.pattern === "*" || disabled}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("selector")}
|
||||
error={!!errors.selector}
|
||||
helperText={errors.selector ?? i18n("selector_helper")}
|
||||
name="selector"
|
||||
value={selector}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Grid container spacing={2} columns={20}>
|
||||
<Grid item xs={10} md={4}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
name="transOpen"
|
||||
value={transOpen}
|
||||
label={i18n("translate_switch")}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value={true}>{i18n("default_enabled")}</MenuItem>
|
||||
<MenuItem value={false}>{i18n("default_disabled")}</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={10} md={4}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
name="translator"
|
||||
value={translator}
|
||||
label={i18n("translate_service")}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_TRANS_ALL.map((item) => (
|
||||
<MenuItem value={item}>{item}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={10} md={4}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
name="fromLang"
|
||||
value={fromLang}
|
||||
label={i18n("from_lang")}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_LANGS_FROM.map(([lang, name]) => (
|
||||
<MenuItem value={lang}>{name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={10} md={4}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
name="toLang"
|
||||
value={toLang}
|
||||
label={i18n("to_lang")}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_LANGS_TO.map(([lang, name]) => (
|
||||
<MenuItem value={lang}>{name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={10} md={4}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
fullWidth
|
||||
name="textStyle"
|
||||
value={textStyle}
|
||||
label={i18n("text_style")}
|
||||
disabled={disabled}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_STYLE_ALL.map((item) => (
|
||||
<MenuItem value={item}>{i18n(item)}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{editMode ? (
|
||||
// 编辑
|
||||
<Stack direction="row" spacing={2}>
|
||||
{disabled ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDisabled(false);
|
||||
}}
|
||||
>
|
||||
{i18n("edit")}
|
||||
</Button>
|
||||
{rule?.pattern !== "*" && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
delRule(rule.pattern);
|
||||
}}
|
||||
>
|
||||
{i18n("delete")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button size="small" variant="contained" type="submit">
|
||||
{i18n("save")}
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={handleCancel}>
|
||||
{i18n("cancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
// 添加
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button size="small" variant="contained" type="submit">
|
||||
{i18n("save")}
|
||||
</Button>
|
||||
<Button size="small" variant="outlined" onClick={handleCancel}>
|
||||
{i18n("cancel")}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton({ data, text, fileName }) {
|
||||
const handleClick = (e) => {
|
||||
e.preventDefault();
|
||||
if (data) {
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", fileName || `${Date.now()}.json`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleClick}
|
||||
startIcon={<FileDownloadIcon />}
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadButton({ onChange, text }) {
|
||||
const inputRef = useRef(null);
|
||||
const handleClick = () => {
|
||||
inputRef.current && inputRef.current.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={handleClick}
|
||||
startIcon={<FileUploadIcon />}
|
||||
>
|
||||
{text}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
ref={inputRef}
|
||||
onChange={onChange}
|
||||
hidden
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Rules() {
|
||||
const i18n = useI18n();
|
||||
const [rules, addRule, delRule, putRule, mergeRules] = useRules();
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const handleImport = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.includes("json")) {
|
||||
alert(i18n("error_wrong_file_type"));
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
await mergeRules(JSON.parse(e.target.result));
|
||||
} catch (err) {
|
||||
console.log("[import rules]", err);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={3}>
|
||||
<Alert severity="warning">{i18n("advanced_warn")}</Alert>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={showAdd}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowAdd(true);
|
||||
}}
|
||||
>
|
||||
{i18n("add")}
|
||||
</Button>
|
||||
|
||||
<UploadButton text={i18n("import")} onChange={handleImport} />
|
||||
<DownloadButton
|
||||
data={JSON.stringify([...rules].reverse(), null, "\t")}
|
||||
text={i18n("export")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{showAdd && (
|
||||
<RuleFields addRule={addRule} rules={rules} setShow={setShowAdd} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
{rules.map((rule, index) => (
|
||||
<Accordion key={rule.pattern}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography>{rule.pattern}</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<RuleFields
|
||||
rule={rule}
|
||||
index={index}
|
||||
putRule={putRule}
|
||||
delRule={delRule}
|
||||
rules={rules}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
141
src/views/Options/Setting.js
Normal file
141
src/views/Options/Setting.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import Select from "@mui/material/Select";
|
||||
import { useSetting, useSettingUpdate } from "../../hooks/Setting";
|
||||
import { limitNumber } from "../../libs/utils";
|
||||
import { useI18n } from "../../hooks/I18n";
|
||||
import { UI_LANGS } from "../../config";
|
||||
|
||||
export default function Settings() {
|
||||
const i18n = useI18n();
|
||||
const setting = useSetting();
|
||||
const updateSetting = useSettingUpdate();
|
||||
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
uiLang,
|
||||
googleUrl,
|
||||
fetchLimit,
|
||||
openaiUrl,
|
||||
openaiKey,
|
||||
openaiModel,
|
||||
openaiPrompt,
|
||||
clearCache,
|
||||
} = setting;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack spacing={3}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>{i18n("ui_lang")}</InputLabel>
|
||||
<Select
|
||||
value={uiLang}
|
||||
label={i18n("ui_lang")}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
uiLang: e.target.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{UI_LANGS.map(([lang, name]) => (
|
||||
<MenuItem value={lang}>{name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("fetch_limit")}
|
||||
type="number"
|
||||
defaultValue={fetchLimit}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
fetchLimit: limitNumber(e.target.value, 1, 10),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>{i18n("clear_cache")}</InputLabel>
|
||||
<Select
|
||||
value={clearCache}
|
||||
label={i18n("clear_cache")}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
clearCache: e.target.value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MenuItem value={false}>{i18n("clear_cache_never")}</MenuItem>
|
||||
<MenuItem value={true}>{i18n("clear_cache_restart")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("google_api")}
|
||||
defaultValue={googleUrl}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
googleUrl: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("openai_api")}
|
||||
defaultValue={openaiUrl}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
openaiUrl: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("openai_key")}
|
||||
defaultValue={openaiKey}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
openaiKey: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("openai_model")}
|
||||
defaultValue={openaiModel}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
openaiModel: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={i18n("openai_prompt")}
|
||||
defaultValue={openaiPrompt}
|
||||
onChange={(e) => {
|
||||
updateSetting({
|
||||
openaiPrompt: e.target.value,
|
||||
});
|
||||
}}
|
||||
multiline
|
||||
minRows={2}
|
||||
maxRows={10}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
17
src/views/Options/index.js
Normal file
17
src/views/Options/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import About from "./About";
|
||||
import Rules from "./Rules";
|
||||
import Setting from "./Setting";
|
||||
import Layout from "./Layout";
|
||||
|
||||
export default function Options() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Setting />} />
|
||||
<Route path="rules" element={<Rules />} />
|
||||
<Route path="about" element={<About />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
142
src/views/Popup/index.js
Normal file
142
src/views/Popup/index.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import Switch from "@mui/material/Switch";
|
||||
import Button from "@mui/material/Button";
|
||||
import { sendTabMsg } from "../../libs/msg";
|
||||
import browser from "../../libs/browser";
|
||||
import { useI18n } from "../../hooks/I18n";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
MSG_TRANS_TOGGLE,
|
||||
MSG_TRANS_GETRULE,
|
||||
MSG_TRANS_PUTRULE,
|
||||
OPT_TRANS_ALL,
|
||||
OPT_LANGS_FROM,
|
||||
OPT_LANGS_TO,
|
||||
OPT_STYLE_ALL,
|
||||
} from "../../config";
|
||||
|
||||
export default function Popup() {
|
||||
const i18n = useI18n();
|
||||
const [rule, setRule] = useState(null);
|
||||
|
||||
const handleOpenSetting = () => {
|
||||
browser?.runtime.openOptionsPage();
|
||||
};
|
||||
|
||||
const handleTransToggle = async (e) => {
|
||||
try {
|
||||
setRule({ ...rule, transOpen: e.target.checked });
|
||||
await sendTabMsg(MSG_TRANS_TOGGLE);
|
||||
} catch (err) {
|
||||
console.log("[toggle trans]", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (e) => {
|
||||
try {
|
||||
const { name, value } = e.target;
|
||||
setRule((pre) => ({ ...pre, [name]: value }));
|
||||
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
|
||||
} catch (err) {
|
||||
console.log("[update rule]", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await sendTabMsg(MSG_TRANS_GETRULE);
|
||||
if (!res.error) {
|
||||
setRule(res.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[query rule]", err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!rule) {
|
||||
return (
|
||||
<Box minWidth={300} sx={{ p: 2 }}>
|
||||
<Stack spacing={3}>
|
||||
<Button variant="text" onClick={handleOpenSetting}>
|
||||
{i18n("setting")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { transOpen, translator, fromLang, toLang, textStyle } = rule;
|
||||
|
||||
return (
|
||||
<Box minWidth={300} sx={{ p: 2 }}>
|
||||
<Stack spacing={3}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={transOpen} onChange={handleTransToggle} />}
|
||||
label={i18n("translate")}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={translator}
|
||||
name="translator"
|
||||
label={i18n("translate_service")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_TRANS_ALL.map((item) => (
|
||||
<MenuItem value={item}>{item}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={fromLang}
|
||||
name="fromLang"
|
||||
label={i18n("from_lang")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_LANGS_FROM.map(([lang, name]) => (
|
||||
<MenuItem value={lang}>{name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={toLang}
|
||||
name="toLang"
|
||||
label={i18n("to_lang")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_LANGS_TO.map(([lang, name]) => (
|
||||
<MenuItem value={lang}>{name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
value={textStyle}
|
||||
name="textStyle"
|
||||
label={i18n("text_style")}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{OPT_STYLE_ALL.map((item) => (
|
||||
<MenuItem value={item}>{i18n(item)}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<Button variant="text" onClick={handleOpenSetting}>
|
||||
{i18n("setting")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user