add codes

This commit is contained in:
Gabe Yuan
2023-07-20 13:45:41 +08:00
parent 10183e3013
commit 0041d6d528
44 changed files with 13020 additions and 0 deletions

3
.env Normal file
View File

@@ -0,0 +1,3 @@
GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator

27
.gitignore vendored Normal file
View 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
View 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
View 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
View 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"
}
}

View File

@@ -0,0 +1,8 @@
{
"app_name": {
"message": "KISS Translator (by Gabe)"
},
"app_description": {
"message": "A simple translator extension"
}
}

View File

@@ -0,0 +1,8 @@
{
"app_name": {
"message": "简约翻译 (by Gabe)"
},
"app_description": {
"message": "一个简约的翻译插件"
}
}

216
public/content.html Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

22
public/index.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
</>
);
}
}

View 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>
);
}

View 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;

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

9959
yarn.lock Normal file

File diff suppressed because it is too large Load Diff