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