userscript...

This commit is contained in:
Gabe Yuan
2023-08-06 21:12:01 +08:00
parent 063d7c9ff0
commit fef4d50977
14 changed files with 456 additions and 102 deletions

1
.env
View File

@@ -3,3 +3,4 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_VERSION=1.2.3
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator
REACT_APP_OPTIONSPAGE=https://github.com/fishjar/kiss-translator

View File

@@ -4,36 +4,6 @@ const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require("webpack");
const isEnvUserscript = process.env.REACT_APP_CLIENT === "userscript";
const banner = `// ==UserScript==
// @name ${process.env.REACT_APP_NAME}
// @namespace ${process.env.REACT_APP_HOMEPAGE}
// @version ${process.env.REACT_APP_VERSION}
// @description A minimalist bilingual translation extension.
// @author Gabe<yugang2002@gmail.com>
// @homepageURL ${process.env.REACT_APP_HOMEPAGE}
// @match *://*/*
// @icon https://raw.githubusercontent.com/fishjar/kiss-translator/master/public/images/logo192.png
// @downloadURL https://raw.githubusercontent.com/fishjar/kiss-translator/master/dist/userscript/kiss-translator.user.js
// @updateURL https://raw.githubusercontent.com/fishjar/kiss-translator/master/dist/userscript/kiss-translator.user.js
// @grant GM_xmlhttpRequest
// @grant GM.xmlhttpRequest
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_deleteValue
// @grant GM.deleteValue
// @connect translate.googleapis.com
// @connect api-edge.cognitive.microsofttranslator.com
// @connect edge.microsoft.com
// @connect api.openai.com
// @connect localhost
// ==/UserScript==
`;
// 扩展
const extWebpack = (config, env) => {
const isEnvProduction = env === "production";
@@ -106,22 +76,60 @@ const extWebpack = (config, env) => {
// 油猴
const userscriptWebpack = (config, env) => {
const names = [
"HtmlWebpackPlugin",
"WebpackManifestPlugin",
"MiniCssExtractPlugin",
];
const banner = `// ==UserScript==
// @name ${process.env.REACT_APP_NAME}
// @namespace ${process.env.REACT_APP_HOMEPAGE}
// @version ${process.env.REACT_APP_VERSION}
// @description A minimalist bilingual translation extension.
// @author Gabe<yugang2002@gmail.com>
// @homepageURL ${process.env.REACT_APP_HOMEPAGE}
// @match *://*/*
// @icon https://raw.githubusercontent.com/fishjar/kiss-translator/master/public/images/logo192.png
// @downloadURL https://raw.githubusercontent.com/fishjar/kiss-translator/master/dist/userscript/kiss-translator.user.js
// @updateURL https://raw.githubusercontent.com/fishjar/kiss-translator/master/dist/userscript/kiss-translator.user.js
// @grant GM_xmlhttpRequest
// @grant GM.xmlhttpRequest
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_deleteValue
// @grant GM.deleteValue
// @connect translate.googleapis.com
// @connect api-edge.cognitive.microsofttranslator.com
// @connect edge.microsoft.com
// @connect api.openai.com
// @connect localhost
// ==/UserScript==
config.entry = {
"kiss-translator-options": paths.appSrc + "/userscriptOptions.js",
"kiss-translator.user": paths.appSrc + "/userscript.js",
};
`;
config.output.filename = "[name].js";
config.entry = paths.appSrc + "/userscript.js";
config.output.filename = "kiss-translator.user.js";
config.optimization.splitChunks = { cacheGroups: { default: false } };
config.optimization.runtimeChunk = false;
config.optimization.minimize = false;
config.plugins.push(
new webpack.BannerPlugin({
banner,
raw: true,
entryOnly: true,
})
);
return config;
};
// 文档
const webWebpack = (config, env) => {
const names = ["HtmlWebpackPlugin"];
config.entry = {
main: paths.appSrc + "/userscriptIndex.js",
options: paths.appSrc + "/userscriptOptions.js",
};
config.plugins = config.plugins.filter(
(plugin) => !names.includes(plugin.constructor.name)
);
@@ -129,21 +137,33 @@ const userscriptWebpack = (config, env) => {
config.plugins.push(
new HtmlWebpackPlugin({
inject: true,
chunks: ["kiss-translator-options"],
chunks: ["main"],
template: paths.appHtml,
filename: "kiss-translator-options.html",
filename: "index.html",
}),
new webpack.BannerPlugin({
banner,
raw: true,
entryOnly: true,
test: "kiss-translator.user.js",
new HtmlWebpackPlugin({
inject: true,
chunks: ["options"],
template: paths.appHtml,
filename: "options.html",
})
);
return config;
};
let webpackConfig;
switch (process.env.REACT_APP_CLIENT) {
case "userscript":
webpackConfig = userscriptWebpack;
break;
case "web":
webpackConfig = webWebpack;
break;
default:
webpackConfig = extWebpack;
}
module.exports = {
webpack: isEnvUserscript ? userscriptWebpack : extWebpack,
webpack: webpackConfig,
};

View File

@@ -24,7 +24,8 @@
"build:edge": "BUILD_PATH=./build/edge REACT_APP_CLIENT=edge react-app-rewired build && rm ./build/edge/manifest.firefox.json",
"build:firefox": "BUILD_PATH=./build/firefox REACT_APP_CLIENT=firefox react-app-rewired build && rm ./build/firefox/manifest.json && mv ./build/firefox/manifest.firefox.json ./build/firefox/manifest.json",
"build:userscript": "BUILD_PATH=./build/userscript REACT_APP_CLIENT=userscript react-app-rewired build",
"build:all": "yarn build && yarn build:edge && yarn build:firefox",
"build:web": "BUILD_PATH=./build/web REACT_APP_CLIENT=web react-app-rewired build",
"build:all": "yarn build && yarn build:edge && yarn build:firefox && yarn build:userscript && yarn build:web",
"dist": "yarn build:all && rm -r dist && cp -r build dist",
"test": "react-app-rewired test",
"eject": "react-scripts eject"

View File

@@ -28,6 +28,9 @@ 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 MSG_TRANS_CURRULE = "trans_currule";
export const EVENT_KISS = "kissEvent";
export const THEME_LIGHT = "light";
export const THEME_DARK = "dark";

32
src/hooks/Fetch.js Normal file
View File

@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
/**
* fetch data hook
* @returns
*/
export const useFetch = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) {
return;
}
setLoading(true);
fetch(url)
.then((res) => {
if (res.ok) {
if (res.headers.get("Content-Type")?.includes("json")) {
return res.json().then(setData);
}
return res.text().then(setData);
}
setError(`[${res.status}] ${res.statusText}`);
})
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return [data, loading, error];
};

View File

@@ -1,6 +1,6 @@
import { useSetting } from "./Setting";
import { I18N, URL_RAW_PREFIX } from "../config";
import { useEffect, useState } from "react";
import { useFetch } from "./Fetch";
/**
* 多语言 hook
@@ -13,29 +13,7 @@ export const useI18n = () => {
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];
const url = `${URL_RAW_PREFIX}/${fileName}`;
return useFetch(url);
};

View File

@@ -2,8 +2,9 @@ import { useEffect } from "react";
import { useState } from "react";
import { transPool } from "../libs/pool";
import { browser } from "../libs/browser";
import { MSG_TRANS_PUTRULE } from "../config";
import { MSG_TRANS_PUTRULE, EVENT_KISS } from "../config";
import { detectLang } from "../libs";
import { isExt } from "../libs/browser";
/**
* 翻译hook
@@ -25,10 +26,31 @@ export function useTranslate(q, initRule) {
return true;
};
const handleKissEvent = (e) => {
const action = e?.detail?.action;
const args = e?.detail?.args || {};
switch (action) {
case MSG_TRANS_PUTRULE:
setRule((pre) => ({ ...pre, ...args }));
break;
default:
// console.log(`[popup] kissEvent action skip: ${action}`);
}
};
useEffect(() => {
browser?.runtime.onMessage.addListener(handleMessage);
if (isExt) {
browser?.runtime.onMessage.addListener(handleMessage);
} else {
window.addEventListener(EVENT_KISS, handleKissEvent);
}
return () => {
browser?.runtime.onMessage.removeListener(handleMessage);
if (isExt) {
browser?.runtime.onMessage.removeListener(handleMessage);
} else {
window.removeEventListener(EVENT_KISS, handleKissEvent);
}
};
}, []);

View File

@@ -5,11 +5,12 @@ import Action from "./views/Action";
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import { browser } from "./libs/browser";
import {
MSG_TRANS_TOGGLE,
MSG_TRANS_GETRULE,
MSG_TRANS_PUTRULE,
MSG_TRANS_CURRULE,
EVENT_KISS,
} from "./config";
import { getRules, matchRule } from "./libs";
import { getSetting } from "./libs";
@@ -48,7 +49,10 @@ class ActionElement extends HTMLElement {
*/
(async () => {
// 设置页面
if (document.location.href.includes("kiss-translator-options")) {
if (
document.location.href.includes("http://localhost:3000/options.html") ||
document.location.href.includes(process.env.REACT_APP_OPTIONSPAGE)
) {
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
@@ -72,19 +76,28 @@ class ActionElement extends HTMLElement {
const translator = new Translator(rule);
// 监听消息
browser?.runtime.onMessage.addListener(async ({ action, args }) => {
window.addEventListener(EVENT_KISS, (e) => {
const action = e?.detail?.action;
const args = e?.detail?.args || {};
switch (action) {
case MSG_TRANS_TOGGLE:
translator.toggle();
break;
case MSG_TRANS_GETRULE:
window.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: {
action: MSG_TRANS_CURRULE,
args: translator.rule,
},
})
);
break;
case MSG_TRANS_PUTRULE:
translator.updateRule(args);
break;
default:
return { error: `message action is unavailable: ${action}` };
// console.log(`[entry] kissEvent action skip: ${action}`);
}
return { data: translator.rule };
});
})();

31
src/userscriptIndex.js Normal file
View File

@@ -0,0 +1,31 @@
import React from "react";
import ReactDOM from "react-dom/client";
import CircularProgress from "@mui/material/CircularProgress";
import ReactMarkdown from "react-markdown";
import Paper from "@mui/material/Paper";
import { useFetch } from "./hooks/Fetch";
import { I18N, URL_RAW_PREFIX } from "./config";
function App() {
const [data, loading, error] = useFetch(
`${URL_RAW_PREFIX}/${I18N?.["about_md"]?.["zh"]}`
);
return (
<Paper sx={{ padding: 2, margin: 2 }}>
{loading ? (
<center>
<CircularProgress />
</center>
) : (
<ReactMarkdown children={error ? error.message : data} />
)}
</Paper>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -16,19 +16,24 @@ const App = () => {
}, []);
if (loading) {
return <p style={{ textAlign: "center" }}>loading...</p>;
return (
<center>
<p>KISS Translator</p>
<h1 style={{ textAlign: "center" }}>loading...</h1>
</center>
);
}
return (
<div style={{ textAlign: "center" }}>
<center>
<p>
<a href={process.env.REACT_APP_HOMEPAGE}>KISS Translator</a> Script not
installed or disabled!
</p>
<p>
<h1>
<a href={process.env.REACT_APP_HOMEPAGE}>Click here read more!</a>
</p>
</div>
</h1>
</center>
);
};

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { limitNumber } from "../../libs/utils";
export default function Draggable(props) {
const [origin, setOrigin] = useState(null);
const [position, setPosition] = useState({
x: props.left,
y: props.top,
});
const handlePointerDown = (e) => {
e.target.setPointerCapture(e.pointerId);
props?.onStart();
setOrigin({
x: position.x,
y: position.y,
px: e.clientX,
py: e.clientY,
});
};
const handlePointerMove = (e) => {
props?.onMove();
if (origin) {
const dx = e.clientX - origin.px;
const dy = e.clientY - origin.py;
let x = origin.x + dx;
let y = origin.y + dy;
const { w, h } = props.windowSize;
x = limitNumber(x, 0, w - props.width);
y = limitNumber(y, 0, h - props.height);
setPosition({ x, y });
}
};
const handlePointerUp = (e) => {
setOrigin(null);
};
const handleClick = (e) => {
e.stopPropagation();
};
useEffect(() => {
const { w, h } = props.windowSize;
setPosition(({ x, y }) => ({
x: limitNumber(x, 0, w - props.width),
y: limitNumber(y, 0, h - props.height),
}));
}, [props.windowSize, props.width, props.height]);
return (
<div
style={{
position: "fixed",
left: position.x,
top: position.y,
zIndex: 2147483647,
}}
onClick={handleClick}
>
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
>
{props.handler}
</div>
<div>{props.children}</div>
</div>
);
}

View File

@@ -1,16 +1,132 @@
import Paper from "@mui/material/Paper";
import Box from "@mui/material/Box";
import Fab from "@mui/material/Fab";
import AddIcon from "@mui/icons-material/Add";
import ThemeProvider from "../../hooks/Theme";
import Draggable from "./Draggable";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import Stack from "@mui/material/Stack";
import { useEffect, useState, useMemo, useCallback } from "react";
import { StoragesProvider } from "../../hooks/Storage";
import Popup from "../Popup";
export default function Action() {
const fabWidth = 56;
const [showPopup, setShowPopup] = useState(false);
const [windowSize, setWindowSize] = useState({
w: window.innerWidth,
h: window.innerHeight,
});
const [moved, setMoved] = useState(false);
const handleWindowResize = (e) => {
setWindowSize({
w: window.innerWidth,
h: window.innerHeight,
});
};
const handleWindowClick = (e) => {
setShowPopup(false);
};
const handleStart = useCallback(() => {
setMoved(false);
}, []);
const handleMove = useCallback(() => {
setMoved(true);
}, []);
useEffect(() => {
window.addEventListener("resize", handleWindowResize);
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("resize", handleWindowResize);
window.removeEventListener("click", handleWindowClick);
};
}, []);
const popProps = useMemo(() => {
const width = Math.min(windowSize.w, 300);
const height = Math.min(windowSize.h, 386);
const left = (windowSize.w - width) / 2;
const top = (windowSize.h - height) / 2;
return {
windowSize,
width,
height,
left,
top,
};
}, [windowSize]);
const fabProps = {
windowSize,
width: fabWidth,
height: fabWidth,
left: window.innerWidth - fabWidth - fabWidth,
top: window.innerHeight - fabWidth - fabWidth,
};
return (
<ThemeProvider>
<Box style={{ position: "fixed", top: 16, right: 16, zIndex: 10001 }}>
<Fab color="primary">
<AddIcon />
</Fab>
</Box>
</ThemeProvider>
<StoragesProvider>
<ThemeProvider>
{showPopup ? (
<Draggable
key="pop"
{...popProps}
onStart={handleStart}
onMove={handleMove}
handler={
<Paper style={{ cursor: "move" }} elevation={3}>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={2}
>
<Box style={{ marginLeft: 16 }}>
{process.env.REACT_APP_NAME}
</Box>
<IconButton
onClick={() => {
setShowPopup(false);
}}
>
<CloseIcon />
</IconButton>
</Stack>
</Paper>
}
>
<Paper>
<Popup />
</Paper>
</Draggable>
) : (
<Draggable
key="fab"
{...fabProps}
onStart={handleStart}
onMove={handleMove}
handler={
<Fab
color="primary"
onClick={(e) => {
if (!moved) {
setShowPopup((pre) => !pre);
}
}}
>
<AddIcon />
</Fab>
}
/>
)}
</ThemeProvider>
</StoragesProvider>
);
}

View File

@@ -5,13 +5,15 @@ import { useI18n, useI18nMd } from "../../hooks/I18n";
export default function About() {
const i18n = useI18n();
const [md, loading, error] = useI18nMd("about_md");
const [data, loading, error] = useI18nMd("about_md");
return (
<Box>
{loading ? (
<CircularProgress />
<center>
<CircularProgress />
</center>
) : (
<ReactMarkdown children={error ? i18n("about_md_local") : md} />
<ReactMarkdown children={error ? i18n("about_md_local") : data} />
)}
</Box>
);

View File

@@ -6,7 +6,7 @@ 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 { browser, isExt } from "../../libs/browser";
import { useI18n } from "../../hooks/I18n";
import TextField from "@mui/material/TextField";
import {
@@ -17,6 +17,8 @@ import {
OPT_LANGS_FROM,
OPT_LANGS_TO,
OPT_STYLE_ALL,
EVENT_KISS,
MSG_TRANS_CURRULE,
} from "../../config";
export default function Popup() {
@@ -24,13 +26,28 @@ export default function Popup() {
const [rule, setRule] = useState(null);
const handleOpenSetting = () => {
browser?.runtime.openOptionsPage();
if (isExt) {
browser?.runtime.openOptionsPage();
} else {
window.open(process.env.REACT_APP_OPTIONSPAGE, "_blank");
}
};
const handleTransToggle = async (e) => {
try {
setRule({ ...rule, transOpen: e.target.checked });
await sendTabMsg(MSG_TRANS_TOGGLE);
if (isExt) {
await sendTabMsg(MSG_TRANS_TOGGLE);
} else {
window.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: {
action: MSG_TRANS_TOGGLE,
},
})
);
}
} catch (err) {
console.log("[toggle trans]", err);
}
@@ -40,13 +57,50 @@ export default function Popup() {
try {
const { name, value } = e.target;
setRule((pre) => ({ ...pre, [name]: value }));
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
if (isExt) {
await sendTabMsg(MSG_TRANS_PUTRULE, { [name]: value });
} else {
window.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: {
action: MSG_TRANS_PUTRULE,
args: { [name]: value },
},
})
);
}
} catch (err) {
console.log("[update rule]", err);
}
};
const handleKissEvent = (e) => {
const action = e?.detail?.action;
const args = e?.detail?.args || {};
switch (action) {
case MSG_TRANS_CURRULE:
setRule(args);
break;
default:
// console.log(`[popup] kissEvent action skip: ${action}`);
}
};
useEffect(() => {
if (!isExt) {
window.addEventListener(EVENT_KISS, handleKissEvent);
window.dispatchEvent(
new CustomEvent(EVENT_KISS, {
detail: { action: MSG_TRANS_GETRULE },
})
);
return () => {
window.removeEventListener(EVENT_KISS, handleKissEvent);
};
}
(async () => {
try {
const res = await sendTabMsg(MSG_TRANS_GETRULE);
@@ -75,7 +129,7 @@ export default function Popup() {
return (
<Box minWidth={300} sx={{ p: 2 }}>
<Stack spacing={3}>
<Stack spacing={2}>
<FormControlLabel
control={<Switch checked={transOpen} onChange={handleTransToggle} />}
label={i18n("translate")}
@@ -83,6 +137,7 @@ export default function Popup() {
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={translator}
name="translator"
@@ -98,6 +153,7 @@ export default function Popup() {
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={fromLang}
name="fromLang"
@@ -113,6 +169,7 @@ export default function Popup() {
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={toLang}
name="toLang"
@@ -128,6 +185,7 @@ export default function Popup() {
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={textStyle}
name="textStyle"