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

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