diff --git a/.env b/.env index e04fb10..621f3ed 100644 --- a/.env +++ b/.env @@ -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 diff --git a/config-overrides.js b/config-overrides.js index afc2dca..bfbf6fa 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -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 -// @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 +// @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, }; diff --git a/package.json b/package.json index c914bad..fb07a65 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/config/index.js b/src/config/index.js index c436d1c..b3aff04 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -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"; diff --git a/src/hooks/Fetch.js b/src/hooks/Fetch.js new file mode 100644 index 0000000..36068ef --- /dev/null +++ b/src/hooks/Fetch.js @@ -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]; +}; diff --git a/src/hooks/I18n.js b/src/hooks/I18n.js index f2adc54..d4e7684 100644 --- a/src/hooks/I18n.js +++ b/src/hooks/I18n.js @@ -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); }; diff --git a/src/hooks/Translate.js b/src/hooks/Translate.js index 1f3465f..eea2ebb 100644 --- a/src/hooks/Translate.js +++ b/src/hooks/Translate.js @@ -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); + } }; }, []); diff --git a/src/userscript.js b/src/userscript.js index b02fa6a..dd7183b 100644 --- a/src/userscript.js +++ b/src/userscript.js @@ -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( @@ -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 }; }); })(); diff --git a/src/userscriptIndex.js b/src/userscriptIndex.js new file mode 100644 index 0000000..666daf4 --- /dev/null +++ b/src/userscriptIndex.js @@ -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 ( + + {loading ? ( +
+ +
+ ) : ( + + )} +
+ ); +} + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + +); diff --git a/src/userscriptOptions.js b/src/userscriptOptions.js index b2d290c..8226ec2 100644 --- a/src/userscriptOptions.js +++ b/src/userscriptOptions.js @@ -16,19 +16,24 @@ const App = () => { }, []); if (loading) { - return

loading...

; + return ( +
+

KISS Translator

+

loading...

+
+ ); } return ( -
+

KISS Translator Script not installed or disabled!

-

+

Click here read more! -

-

+ + ); }; diff --git a/src/views/Action/Draggable.js b/src/views/Action/Draggable.js new file mode 100644 index 0000000..5276dc1 --- /dev/null +++ b/src/views/Action/Draggable.js @@ -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 ( +
+
+ {props.handler} +
+
{props.children}
+
+ ); +} diff --git a/src/views/Action/index.js b/src/views/Action/index.js index c027557..b962053 100644 --- a/src/views/Action/index.js +++ b/src/views/Action/index.js @@ -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 ( - - - - - - - + + + {showPopup ? ( + + + + {process.env.REACT_APP_NAME} + + { + setShowPopup(false); + }} + > + + + + + } + > + + + + + ) : ( + { + if (!moved) { + setShowPopup((pre) => !pre); + } + }} + > + + + } + /> + )} + + ); } diff --git a/src/views/Options/About.js b/src/views/Options/About.js index b6d59b4..aaaaa8d 100644 --- a/src/views/Options/About.js +++ b/src/views/Options/About.js @@ -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 ( {loading ? ( - +
+ +
) : ( - + )}
); diff --git a/src/views/Popup/index.js b/src/views/Popup/index.js index aa60902..a70acde 100644 --- a/src/views/Popup/index.js +++ b/src/views/Popup/index.js @@ -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 ( - + } label={i18n("translate")} @@ -83,6 +137,7 @@ export default function Popup() {