├── .nvmrc ├── .prettierignore ├── src ├── pages │ ├── Options │ │ ├── index.css │ │ ├── Options.css │ │ ├── index.html │ │ ├── index.jsx │ │ └── Options.tsx │ ├── Background │ │ ├── index.html │ │ └── index.js │ ├── Popup │ │ ├── index.html │ │ ├── index.jsx │ │ ├── index.css │ │ ├── Popup.jsx │ │ └── Popup.css │ └── Content │ │ ├── api.js │ │ ├── index.js │ │ ├── content.styles.css │ │ └── ContentApp.jsx ├── assets │ ├── icons │ │ ├── icon16.png │ │ ├── icon48.png │ │ └── icon128.png │ └── images │ │ └── loading.gif ├── lib │ ├── hooks │ │ ├── useEvent.js │ │ ├── useSelectionRect.js │ │ └── useRect.js │ ├── channel.js │ └── store.js ├── containers │ └── Greetings │ │ └── Greetings.jsx └── manifest.json ├── .vscode └── settings.json ├── .eslintrc ├── docs └── images │ ├── screenshot.png │ └── Brand_GitHub.png ├── .prettierrc ├── utils ├── env.js ├── build.js └── webserver.js ├── jsconfig.json ├── .babelrc ├── README.md ├── tsconfig.json ├── LICENSE ├── .gitignore ├── package.json └── webpack.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 15 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /src/pages/Options/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "globals": { 4 | "chrome": "readonly" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /docs/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/docs/images/screenshot.png -------------------------------------------------------------------------------- /src/assets/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/src/assets/icons/icon16.png -------------------------------------------------------------------------------- /src/assets/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/src/assets/icons/icon48.png -------------------------------------------------------------------------------- /docs/images/Brand_GitHub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/docs/images/Brand_GitHub.png -------------------------------------------------------------------------------- /src/assets/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/src/assets/icons/icon128.png -------------------------------------------------------------------------------- /src/assets/images/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yukaii/mojidict-helper/HEAD/src/assets/images/loading.gif -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "requirePragma": false, 5 | "arrowParens": "always", 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /utils/env.js: -------------------------------------------------------------------------------- 1 | // tiny wrapper with default env vars 2 | module.exports = { 3 | NODE_ENV: process.env.NODE_ENV || 'development', 4 | PORT: process.env.PORT || 3000, 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/Background/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/pages/Options/Options.css: -------------------------------------------------------------------------------- 1 | .OptionsContainer { 2 | width: 100%; 3 | height: 50vh; 4 | font-size: 2rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true, 4 | "module": "commonjs", 5 | "resolveJsonModule": true 6 | }, 7 | "exclude": [ 8 | "node_modules", 9 | "build" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/pages/Popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | // "@babel/preset-env", 4 | // "@babel/preset-react" 5 | "react-app" 6 | ], 7 | "plugins": [ 8 | // "@babel/plugin-proposal-class-properties", 9 | "react-hot-loader/babel" 10 | ] 11 | } -------------------------------------------------------------------------------- /src/pages/Options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Settings 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/pages/Content/api.js: -------------------------------------------------------------------------------- 1 | import Channel from '../../lib/channel' 2 | 3 | const channel = new Channel('mojidict-api') 4 | 5 | export const search = (searchText) => channel.send('search', { searchText }) 6 | export const fetchWord = (wordId) => channel.send('fetchWord', { wordId }) 7 | -------------------------------------------------------------------------------- /src/pages/Popup/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | import Popup from './Popup' 5 | import './index.css' 6 | 7 | render(, window.document.querySelector('#app-container')) 8 | 9 | if (module.hot) module.hot.accept() 10 | -------------------------------------------------------------------------------- /src/pages/Options/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | import Options from './Options' 5 | import './index.css' 6 | 7 | render( 8 | , 9 | window.document.querySelector('#app-container') 10 | ) 11 | 12 | if (module.hot) module.hot.accept() 13 | -------------------------------------------------------------------------------- /src/pages/Options/Options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Options.css'; 3 | 4 | interface Props { 5 | title: string; 6 | } 7 | 8 | const Options: React.FC = ({ title }: Props) => { 9 | return
{title.toUpperCase()} PAGE
; 10 | }; 11 | 12 | export default Options; 13 | -------------------------------------------------------------------------------- /src/lib/hooks/useEvent.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | 3 | export const useEvent = (event, callback, useCapture) => { 4 | useEffect(() => { 5 | callback() 6 | window.addEventListener(event, callback, useCapture) 7 | return () => window.removeEventListener(event, callback, useCapture) 8 | }, []) 9 | } 10 | 11 | export default useEvent 12 | -------------------------------------------------------------------------------- /utils/build.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.BABEL_ENV = 'production' 3 | process.env.NODE_ENV = 'production' 4 | process.env.ASSET_PATH = '/' 5 | 6 | var webpack = require('webpack'), 7 | config = require('../webpack.config') 8 | 9 | delete config.chromeExtensionBoilerplate 10 | 11 | config.mode = 'production' 12 | 13 | webpack(config, function (err) { 14 | if (err) throw err 15 | }) 16 | -------------------------------------------------------------------------------- /src/containers/Greetings/Greetings.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import icon from '../../assets/img/icon-128.png' 3 | 4 | class GreetingComponent extends Component { 5 | state = { 6 | name: 'dev', 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 |

Hello, {this.state.name}!

13 | extension icon 14 |
15 | ) 16 | } 17 | } 18 | 19 | export default GreetingComponent 20 | -------------------------------------------------------------------------------- /src/pages/Popup/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | width: 300px; 3 | height: 260px; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 7 | sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | 11 | position: relative; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MOJiDict Helper - 日文字典的瀏覽器擴充功能 2 | 3 | ![screenshot](./docs/images/Brand_GitHub.png) 4 | 5 | 只要輕點兩下網頁中的任意文字,MOJiDict Helper 就會跳出小視窗搜尋單字,讓你快速記憶查詢日文單字! 6 | 7 | 背後利用了「MOJi辞書」mojidict.com 的 API。 8 | 9 | 目前為 Google Chrome 限定的瀏覽器擴充功能,Chromium 系的 Opera 和 Edge 瀏覽器也都能使用。 10 | 11 | ## Screenshots 12 | 13 | ![1](./docs/images/screenshot.png) 14 | 15 | ## Credits 16 | 17 | - [MOJi辞書](https://www.mojidict.com) 的介面和 App 都漂亮好用,我個人是有付費解鎖,推薦給各位 18 | - 擴充功能圖示和宣傳圖,都使用了今天 (2020/3/14) 剛釋出熱騰騰的「[jf open 粉圓字型](https://justfont.com/huninn/)」來製作,這字體實在太可愛啦! 19 | 20 | ## LICENSE 21 | 22 | MIT 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "noEmit": false, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"], 19 | "exclude": ["build", "node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/Popup/Popup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './Popup.css' 3 | 4 | const Popup = () => { 5 | return ( 6 |
7 |
8 |

9 | Edit src/pages/Popup/Popup.js and save to reload. 10 |

11 | 17 | Learn React! 18 | 19 |
20 |
21 | ) 22 | } 23 | 24 | export default Popup 25 | -------------------------------------------------------------------------------- /src/lib/hooks/useSelectionRect.js: -------------------------------------------------------------------------------- 1 | // Modify From https://gist.github.com/morajabi/523d7a642d8c0a2f71fcfa0d8b3d2846#gistcomment-3874273 2 | 3 | import { useCallback, useState } from 'react' 4 | 5 | export const useSelectionRect = () => { 6 | const [selectionRect, setRect] = useState({}) 7 | 8 | const updateSelection = useCallback(() => { 9 | const selection = window.getSelection() 10 | const range = selection.rangeCount > 0 && selection?.getRangeAt(0) 11 | 12 | setRect(range ? range.getBoundingClientRect() : {}) 13 | }, []) 14 | 15 | return [selectionRect, updateSelection] 16 | } 17 | 18 | export default useSelectionRect 19 | -------------------------------------------------------------------------------- /src/lib/hooks/useRect.js: -------------------------------------------------------------------------------- 1 | // From https://gist.github.com/morajabi/523d7a642d8c0a2f71fcfa0d8b3d2846#gistcomment-3874273 2 | 3 | import { useState, useRef, useEffect } from 'react' 4 | 5 | export const useRect = () => { 6 | const ref = useRef() 7 | const [rect, setRect] = useState({}) 8 | 9 | const set = () => 10 | setRect(ref && ref.current ? ref.current.getBoundingClientRect() : {}) 11 | 12 | const useEffectInEvent = (event, useCapture) => { 13 | useEffect(() => { 14 | set() 15 | window.addEventListener(event, set, useCapture) 16 | return () => window.removeEventListener(event, set, useCapture) 17 | }, []) 18 | } 19 | 20 | useEffectInEvent('resize') 21 | useEffectInEvent('scroll', true) 22 | 23 | return [rect, ref] 24 | } 25 | 26 | export default useRect 27 | -------------------------------------------------------------------------------- /src/pages/Popup/Popup.css: -------------------------------------------------------------------------------- 1 | .App { 2 | position: absolute; 3 | top: 0px; 4 | bottom: 0px; 5 | left: 0px; 6 | right: 0px; 7 | text-align: center; 8 | height: 100%; 9 | padding: 10px; 10 | background-color: #282c34; 11 | } 12 | 13 | .App-logo { 14 | height: 30vmin; 15 | pointer-events: none; 16 | } 17 | 18 | @media (prefers-reduced-motion: no-preference) { 19 | .App-logo { 20 | animation: App-logo-spin infinite 20s linear; 21 | } 22 | } 23 | 24 | .App-header { 25 | height: 100%; 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | justify-content: center; 30 | font-size: calc(10px + 2vmin); 31 | color: white; 32 | } 33 | 34 | .App-link { 35 | color: #61dafb; 36 | } 37 | 38 | @keyframes App-logo-spin { 39 | from { 40 | transform: rotate(0deg); 41 | } 42 | to { 43 | transform: rotate(360deg); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/channel.js: -------------------------------------------------------------------------------- 1 | export default class Channel { 2 | constructor(port) { 3 | this.messageId = 0 4 | this.promises = {} 5 | 6 | this._setupListener(port) 7 | } 8 | 9 | send(type, payload) { 10 | return new Promise((resolve, reject) => { 11 | const messageId = this.messageId 12 | this.promises[messageId] = { resolve, reject } 13 | 14 | this.port.postMessage({ 15 | messageId, 16 | type, 17 | payload, 18 | }) 19 | 20 | this.messageId += 1 21 | }) 22 | } 23 | 24 | _setupListener(port) { 25 | this.port = chrome.runtime.connect({ name: port }) 26 | this.port.onMessage.addListener(({ messageId, result, error }) => { 27 | const promise = this.promises[messageId] 28 | 29 | if (promise) { 30 | const { resolve, reject } = promise 31 | 32 | if (error) { 33 | reject(error) 34 | } else { 35 | resolve(result) 36 | } 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Yukai Huang 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "MOJiDict Helper", 4 | "description": "Show popup of Japanese vocabulary when double clicking on any text in webpage", 5 | "version": "2.0.0", 6 | "incognito": "split", 7 | "permissions": ["activeTab", "contextMenus", "storage"], 8 | "options_page": "options.html", 9 | "background": { 10 | "page": "background.html", 11 | "persistent": true 12 | }, 13 | "action": { 14 | "default_popup": "popup.html", 15 | "default_icon": "icons/icon48.png" 16 | }, 17 | "icons": { 18 | "16": "icons/icon16.png", 19 | "48": "icons/icon48.png", 20 | "128": "icons/icon128.png" 21 | }, 22 | "content_scripts": [ 23 | { 24 | "matches": ["http://*/*", "https://*/*", ""], 25 | "exclude_matches": ["https://*.mojidict.com/*"], 26 | "js": ["contentScript.bundle.js"], 27 | "css": ["content.styles.css"] 28 | } 29 | ], 30 | "web_accessible_resources": [ 31 | "content.styles.css", 32 | "icons/icon128.png", 33 | "icons/icon48.png", 34 | "icons/icon16.png", 35 | "images/loading.gif" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /src/pages/Content/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import ContentApp from './ContentApp' 4 | import { QueryClient, QueryClientProvider } from 'react-query' 5 | 6 | import { searchFromSelection } from '../../lib/store' 7 | 8 | chrome.runtime.onMessage.addListener(function ({ type }, sender, sendResponse) { 9 | if (type === 'mojidict:searchSelection') { 10 | searchFromSelection() 11 | } 12 | }) 13 | 14 | function getWordCardContainer() { 15 | return document.querySelector('.mojidict-helper-card-container') 16 | } 17 | 18 | function findOrCreateWordCardContainer() { 19 | const appContainer = getWordCardContainer() 20 | if (appContainer) { 21 | return appContainer 22 | } else { 23 | const div = document.createElement('div') 24 | div.className = 'mojidict-helper-card-container' 25 | 26 | document.body.appendChild(div) 27 | 28 | return div 29 | } 30 | } 31 | 32 | const queryClient = new QueryClient() 33 | 34 | function setupReactApp() { 35 | const appContainer = findOrCreateWordCardContainer() 36 | 37 | ReactDOM.render( 38 | 39 | 40 | 41 | 42 | , 43 | appContainer 44 | ) 45 | } 46 | 47 | setupReactApp() 48 | -------------------------------------------------------------------------------- /utils/webserver.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.BABEL_ENV = 'development' 3 | process.env.NODE_ENV = 'development' 4 | process.env.ASSET_PATH = '/' 5 | 6 | var WebpackDevServer = require('webpack-dev-server'), 7 | webpack = require('webpack'), 8 | config = require('../webpack.config'), 9 | env = require('./env'), 10 | path = require('path') 11 | 12 | var options = config.chromeExtensionBoilerplate || {} 13 | var excludeEntriesToHotReload = options.notHotReload || [] 14 | 15 | for (var entryName in config.entry) { 16 | if (excludeEntriesToHotReload.indexOf(entryName) === -1) { 17 | config.entry[entryName] = [ 18 | 'webpack-dev-server/client?http://localhost:' + env.PORT, 19 | 'webpack/hot/dev-server', 20 | ].concat(config.entry[entryName]) 21 | } 22 | } 23 | 24 | config.plugins = [new webpack.HotModuleReplacementPlugin()].concat( 25 | config.plugins || [] 26 | ) 27 | 28 | delete config.chromeExtensionBoilerplate 29 | 30 | var compiler = webpack(config) 31 | 32 | var server = new WebpackDevServer(compiler, { 33 | https: false, 34 | hot: true, 35 | injectClient: false, 36 | writeToDisk: true, 37 | port: env.PORT, 38 | contentBase: path.join(__dirname, '../build'), 39 | publicPath: `http://localhost:${env.PORT}`, 40 | headers: { 41 | 'Access-Control-Allow-Origin': '*', 42 | }, 43 | disableHostCheck: true, 44 | }) 45 | 46 | if (process.env.NODE_ENV === 'development' && module.hot) { 47 | module.hot.accept() 48 | } 49 | 50 | server.listen(env.PORT) 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | dist 76 | 77 | # See https://help.github.com/ignore-files/ for more about ignoring files. 78 | 79 | # dependencies 80 | /node_modules 81 | 82 | # testing 83 | /coverage 84 | 85 | # production 86 | /build 87 | 88 | # misc 89 | .DS_Store 90 | .env.local 91 | .env.development.local 92 | .env.test.local 93 | .env.production.local 94 | .history 95 | 96 | # secrets 97 | secrets.*.js 98 | -------------------------------------------------------------------------------- /src/pages/Background/index.js: -------------------------------------------------------------------------------- 1 | // #region MojiDict API 2 | const basePayload = { 3 | _ApplicationId: 'E62VyFVLMiW7kvbtVq3p', 4 | _ClientVersion: 'js2.10.0', 5 | } 6 | const API_ENDPOINT = 'https://api.mojidict.com/parse/functions' 7 | 8 | const request = (method, body) => 9 | fetch(`${API_ENDPOINT}/${method}`, { 10 | method: 'POST', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | }, 14 | mode: 'no-cors', 15 | body: JSON.stringify({ 16 | ...basePayload, 17 | ...body, 18 | }), 19 | }).then((r) => r.json()) 20 | 21 | const search = (searchText) => 22 | request('search_v3', { 23 | searchText, 24 | needWords: true, 25 | langEnv: 'zh-CN_ja', 26 | }) 27 | 28 | const fetchWord = (wordId) => request('fetchWord_v2', { wordId }) 29 | // #endregion 30 | 31 | chrome.runtime.onConnect.addListener(function (port) { 32 | if (!port.name === 'mojidict-api') { 33 | return 34 | } 35 | 36 | port.onMessage.addListener(function ({ messageId, type, payload }) { 37 | if (type === 'search') { 38 | search(payload.searchText) 39 | .then((result) => { 40 | port.postMessage({ messageId, result }) 41 | }) 42 | .catch((error) => { 43 | port.postMessage({ messageId, result: error, error: true }) 44 | }) 45 | } else if (type === 'fetchWord') { 46 | fetchWord(payload.wordId) 47 | .then((result) => { 48 | port.postMessage({ messageId, result }) 49 | }) 50 | .catch((error) => { 51 | port.postMessage({ messageId, result: error, error: true }) 52 | }) 53 | } 54 | }) 55 | }) 56 | 57 | chrome.runtime.onInstalled.addListener(() => { 58 | chrome.contextMenus.create({ 59 | id: 'mojidict:searchSelection', 60 | title: 'Search "%s" with MojiDict', 61 | contexts: ['selection'], 62 | }) 63 | }) 64 | 65 | chrome.contextMenus.onClicked.addListener(function (info, tab) { 66 | chrome.tabs.sendMessage(tab.id, { type: 'mojidict:searchSelection' }) 67 | }) 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mojidict-helper", 3 | "version": "2.0.0", 4 | "description": "Show popup of Japanese vocabulary when double clicking on any text in webpage", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Yukaii/mojidict-helper.git" 9 | }, 10 | "scripts": { 11 | "build": "node utils/build.js", 12 | "start": "node utils/webserver.js", 13 | "prettier": "prettier --write '**/*.{js,jsx,css,html}'" 14 | }, 15 | "dependencies": { 16 | "@hot-loader/react-dom": "^17.0.1", 17 | "@types/chrome": "0.0.132", 18 | "@types/react": "^17.0.2", 19 | "@types/react-dom": "^17.0.1", 20 | "react": "^17.0.1", 21 | "react-dom": "^17.0.1", 22 | "react-hot-loader": "^4.13.0", 23 | "react-query": "^3.23.2", 24 | "zustand": "^3.5.10" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.12.17", 28 | "@babel/plugin-proposal-class-properties": "^7.12.13", 29 | "@babel/preset-env": "^7.12.17", 30 | "@babel/preset-react": "^7.12.13", 31 | "babel-eslint": "^10.1.0", 32 | "babel-loader": "^8.2.2", 33 | "babel-preset-react-app": "^10.0.0", 34 | "clean-webpack-plugin": "^3.0.0", 35 | "copy-webpack-plugin": "^7.0.0", 36 | "css-loader": "^5.0.2", 37 | "eslint": "^7.20.0", 38 | "eslint-config-react-app": "^6.0.0", 39 | "eslint-plugin-flowtype": "^5.2.2", 40 | "eslint-plugin-import": "^2.22.1", 41 | "eslint-plugin-jsx-a11y": "^6.4.1", 42 | "eslint-plugin-react": "^7.22.0", 43 | "eslint-plugin-react-hooks": "^4.2.0", 44 | "file-loader": "^6.2.0", 45 | "fs-extra": "^9.1.0", 46 | "html-loader": "^2.1.0", 47 | "html-webpack-plugin": "^5.2.0", 48 | "node-sass": "^4.14.1", 49 | "prettier": "^2.2.1", 50 | "sass-loader": "^11.0.1", 51 | "source-map-loader": "^2.0.1", 52 | "style-loader": "^2.0.0", 53 | "terser-webpack-plugin": "^5.1.1", 54 | "ts-loader": "^8.0.17", 55 | "typescript": "^4.1.5", 56 | "webpack": "^5.23.0", 57 | "webpack-cli": "^4.5.0", 58 | "webpack-dev-server": "^3.11.2" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/store.js: -------------------------------------------------------------------------------- 1 | import create from 'zustand/vanilla' 2 | import { persist } from 'zustand/middleware' 3 | 4 | const storage = { 5 | getItem: async (name) => { 6 | return new Promise((resolve) => { 7 | chrome.storage.sync.get([name], function (result) { 8 | resolve(result.key) 9 | }) 10 | }) 11 | }, 12 | setItem: async (name, value) => { 13 | return new Promise((resolve) => { 14 | chrome.storage.sync.set({ [name]: value }, function () { 15 | resolve() 16 | }) 17 | }) 18 | }, 19 | } 20 | 21 | const STORAGE_KEY = 'mojidict-app-storage' 22 | 23 | const store = create( 24 | // persist( 25 | (set) => ({ 26 | showCard: false, 27 | searchKeyword: null, 28 | selectionRect: null, 29 | 30 | setShowCard: (showCard) => set((state) => ({ ...state, showCard })), 31 | setSearchKeyword: (searchKeyword) => 32 | set((state) => ({ ...state, searchKeyword })), 33 | setSelectionRect: (selectionRect) => 34 | set((state) => ({ ...state, selectionRect })), 35 | }) 36 | // { 37 | // name: STORAGE_KEY, 38 | // getStorage: () => storage, 39 | // } 40 | // ) 41 | ) 42 | 43 | export default store 44 | 45 | export function searchFromSelection() { 46 | const selection = window.getSelection() 47 | const selectionText = selection.toString().trim() 48 | const range = selection.rangeCount > 0 && selection.getRangeAt(0) 49 | 50 | if (!selectionText.length || !range) { 51 | return 52 | } 53 | 54 | store.setState((state) => ({ 55 | ...state, 56 | searchKeyword: selectionText, 57 | showCard: true, 58 | })) 59 | } 60 | 61 | const getStorageState = (rawState) => { 62 | try { 63 | return JSON.parse(rawState).state 64 | } catch { 65 | return {} 66 | } 67 | } 68 | 69 | chrome.storage.onChanged.addListener(function (changes, namespace) { 70 | if (namespace === 'sync') { 71 | if (changes[STORAGE_KEY]) { 72 | const newState = getStorageState(changes[STORAGE_KEY].newValue) 73 | 74 | if (JSON.stringify(newState) !== JSON.stringify(store.getState())) { 75 | store.setState(newState) 76 | } 77 | } 78 | } 79 | }) 80 | -------------------------------------------------------------------------------- /src/pages/Content/content.styles.css: -------------------------------------------------------------------------------- 1 | .mojidict-helper-card-container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, 6 | Microsoft YaHei, 微软雅黑, Arial, sans-serif; 7 | font-size: 16px; 8 | } 9 | 10 | .mojidict-helper-card { 11 | background-color: white; 12 | color: black; 13 | border-radius: 10px; 14 | 15 | width: 300px; 16 | min-width: 150px; 17 | max-height: 350px; 18 | overflow-y: auto; 19 | position: absolute; 20 | z-index: 999999999; 21 | 22 | border: 1px solid #ebeef5; 23 | z-index: 2000; 24 | color: #606266; 25 | line-height: 1.4em; 26 | text-align: justify; 27 | font-size: 14px; 28 | box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); 29 | word-break: break-all; 30 | } 31 | 32 | .mojidict-helper-card .close-button { 33 | display: block; 34 | width: 15px; 35 | height: 15px; 36 | position: absolute; 37 | top: 12px; 38 | right: 10px; 39 | cursor: pointer; 40 | } 41 | 42 | .mojidict-helper-card .close-button:before, 43 | .mojidict-helper-card .close-button:after { 44 | content: ''; 45 | width: 12px; 46 | height: 1px; 47 | background-color: #606266; 48 | position: absolute; 49 | top: 6px; 50 | right: 0; 51 | } 52 | 53 | .mojidict-helper-card .close-button:before { 54 | transform: rotate(45deg); 55 | } 56 | .mojidict-helper-card .close-button:after { 57 | transform: rotate(-45deg); 58 | } 59 | 60 | .mojidict-helper-card .loading-placeholder { 61 | width: 200px; 62 | height: 200px; 63 | margin: 0 auto; 64 | } 65 | 66 | .mojidict-helper-card .word-detail-container { 67 | padding: 12px 12px 0; 68 | } 69 | 70 | .mojidict-helper-card .word-title { 71 | font-size: 20px; 72 | margin: 0 0 10px; 73 | font-weight: bold; 74 | display: inline-block; 75 | text-align: left; 76 | } 77 | 78 | .mojidict-helper-card .word-spell { 79 | margin: 0 0 6px; 80 | font-size: 18px; 81 | text-align: left; 82 | } 83 | 84 | .mojidict-helper-card .word-detail { 85 | padding-bottom: 12px; 86 | font-size: 14px; 87 | } 88 | 89 | .mojidict-helper-card .word-detail p { 90 | margin: 0 0 6px; 91 | } 92 | 93 | .mojidict-helper-card .detail-title { 94 | display: inline-block; 95 | border: 1px solid #a4a4a5; 96 | border-radius: 10px; 97 | padding: 0 8px; 98 | color: #71757b; 99 | margin-bottom: 8px; 100 | } 101 | 102 | .mojidict-helper-card .no-result { 103 | padding: 12px; 104 | text-align: center; 105 | font-size: 14px; 106 | } 107 | 108 | .mojidict-helper-card .button-group { 109 | display: flex; 110 | flex-direction: row; 111 | justify-content: space-around; 112 | background-color: #fff; 113 | width: 100%; 114 | border-top: 1px solid #d9d9d9; 115 | } 116 | 117 | .mojidict-helper-card .moji-button { 118 | color: #606266; 119 | font-size: 14px; 120 | background-color: white; 121 | cursor: pointer; 122 | 123 | padding: 10px; 124 | width: 100%; 125 | text-align: center; 126 | text-decoration: none; 127 | } 128 | 129 | .mojidict-helper-card .moji-button:first-child { 130 | border-right: 1px solid #d9d9d9; 131 | } 132 | 133 | .mojidict-helper-card .moji-button:hover { 134 | color: #ff4a42; 135 | } 136 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'), 2 | path = require('path'), 3 | fileSystem = require('fs-extra'), 4 | env = require('./utils/env'), 5 | { CleanWebpackPlugin } = require('clean-webpack-plugin'), 6 | CopyWebpackPlugin = require('copy-webpack-plugin'), 7 | HtmlWebpackPlugin = require('html-webpack-plugin'), 8 | TerserPlugin = require('terser-webpack-plugin') 9 | 10 | const ASSET_PATH = process.env.ASSET_PATH || '/' 11 | 12 | var alias = { 13 | 'react-dom': '@hot-loader/react-dom', 14 | } 15 | 16 | // load the secrets 17 | var secretsPath = path.join(__dirname, 'secrets.' + env.NODE_ENV + '.js') 18 | 19 | var fileExtensions = [ 20 | 'jpg', 21 | 'jpeg', 22 | 'png', 23 | 'gif', 24 | 'eot', 25 | 'otf', 26 | 'svg', 27 | 'ttf', 28 | 'woff', 29 | 'woff2', 30 | ] 31 | 32 | if (fileSystem.existsSync(secretsPath)) { 33 | alias['secrets'] = secretsPath 34 | } 35 | 36 | var options = { 37 | mode: process.env.NODE_ENV || 'development', 38 | entry: { 39 | options: path.join(__dirname, 'src', 'pages', 'Options', 'index.jsx'), 40 | popup: path.join(__dirname, 'src', 'pages', 'Popup', 'index.jsx'), 41 | background: path.join(__dirname, 'src', 'pages', 'Background', 'index.js'), 42 | contentScript: path.join(__dirname, 'src', 'pages', 'Content', 'index.js'), 43 | }, 44 | chromeExtensionBoilerplate: { 45 | notHotReload: ['contentScript', 'background'], 46 | }, 47 | output: { 48 | path: path.resolve(__dirname, 'build'), 49 | filename: '[name].bundle.js', 50 | publicPath: ASSET_PATH, 51 | }, 52 | module: { 53 | rules: [ 54 | { 55 | // look for .css or .scss files 56 | test: /\.(css|scss)$/, 57 | // in the `src` directory 58 | use: [ 59 | { 60 | loader: 'style-loader', 61 | }, 62 | { 63 | loader: 'css-loader', 64 | }, 65 | { 66 | loader: 'sass-loader', 67 | options: { 68 | sourceMap: true, 69 | }, 70 | }, 71 | ], 72 | }, 73 | { 74 | test: new RegExp('.(' + fileExtensions.join('|') + ')$'), 75 | loader: 'file-loader', 76 | options: { 77 | name: '[name].[ext]', 78 | }, 79 | exclude: /node_modules/, 80 | }, 81 | { 82 | test: /\.html$/, 83 | loader: 'html-loader', 84 | exclude: /node_modules/, 85 | }, 86 | { test: /\.(ts|tsx)$/, loader: 'ts-loader', exclude: /node_modules/ }, 87 | { 88 | test: /\.(js|jsx)$/, 89 | use: [ 90 | { 91 | loader: 'source-map-loader', 92 | }, 93 | { 94 | loader: 'babel-loader', 95 | }, 96 | ], 97 | exclude: /node_modules/, 98 | }, 99 | ], 100 | }, 101 | resolve: { 102 | alias: alias, 103 | extensions: fileExtensions 104 | .map((extension) => '.' + extension) 105 | .concat(['.js', '.jsx', '.ts', '.tsx', '.css']), 106 | }, 107 | plugins: [ 108 | new webpack.ProvidePlugin({ 109 | React: 'react', 110 | }), 111 | new webpack.ProgressPlugin(), 112 | // clean the build folder 113 | new CleanWebpackPlugin({ 114 | verbose: true, 115 | cleanStaleWebpackAssets: true, 116 | }), 117 | // expose and write the allowed env vars on the compiled bundle 118 | new webpack.EnvironmentPlugin(['NODE_ENV']), 119 | new CopyWebpackPlugin({ 120 | patterns: [ 121 | { 122 | from: 'src/manifest.json', 123 | to: path.join(__dirname, 'build'), 124 | force: true, 125 | transform: function (content, path) { 126 | // generates the manifest file using the package.json informations 127 | return Buffer.from( 128 | JSON.stringify({ 129 | description: process.env.npm_package_description, 130 | version: process.env.npm_package_version, 131 | ...JSON.parse(content.toString()), 132 | }) 133 | ) 134 | }, 135 | }, 136 | ], 137 | }), 138 | new CopyWebpackPlugin({ 139 | patterns: [ 140 | { 141 | from: 'src/pages/Content/content.styles.css', 142 | to: path.join(__dirname, 'build'), 143 | force: true, 144 | }, 145 | ], 146 | }), 147 | new CopyWebpackPlugin({ 148 | patterns: [ 149 | { 150 | context: 'src', 151 | from: 'assets', 152 | to: path.join(__dirname, 'build'), 153 | force: true, 154 | }, 155 | ], 156 | }), 157 | new HtmlWebpackPlugin({ 158 | template: path.join(__dirname, 'src', 'pages', 'Options', 'index.html'), 159 | filename: 'options.html', 160 | chunks: ['options'], 161 | cache: false, 162 | }), 163 | new HtmlWebpackPlugin({ 164 | template: path.join(__dirname, 'src', 'pages', 'Popup', 'index.html'), 165 | filename: 'popup.html', 166 | chunks: ['popup'], 167 | cache: false, 168 | }), 169 | new HtmlWebpackPlugin({ 170 | template: path.join( 171 | __dirname, 172 | 'src', 173 | 'pages', 174 | 'Background', 175 | 'index.html' 176 | ), 177 | filename: 'background.html', 178 | chunks: ['background'], 179 | cache: false, 180 | }), 181 | ], 182 | infrastructureLogging: { 183 | level: 'info', 184 | }, 185 | } 186 | 187 | if (env.NODE_ENV === 'development') { 188 | options.devtool = 'inline-source-map' 189 | } else { 190 | options.optimization = { 191 | minimize: true, 192 | minimizer: [ 193 | new TerserPlugin({ 194 | extractComments: false, 195 | }), 196 | ], 197 | } 198 | } 199 | 200 | module.exports = options 201 | -------------------------------------------------------------------------------- /src/pages/Content/ContentApp.jsx: -------------------------------------------------------------------------------- 1 | import create from 'zustand' 2 | import { useQuery } from 'react-query' 3 | import { useEffect, useMemo, useCallback, Fragment } from 'react' 4 | 5 | import store, { searchFromSelection } from '../../lib/store' 6 | import { fetchWord, search } from './api' 7 | import useRect from '../../lib/hooks/useRect' 8 | import useEvent from '../../lib/hooks/useEvent' 9 | import useSelectionRect from '../../lib/hooks/useSelectionRect' 10 | 11 | const useStore = create(store) 12 | 13 | export const ContentApp = () => { 14 | const [showCard, setShowCard] = useStore((state) => [ 15 | state.showCard, 16 | state.setShowCard, 17 | ]) 18 | const searchKeyword = useStore((state) => state.searchKeyword) 19 | 20 | const canShowCard = showCard && searchKeyword 21 | 22 | const [cardRect, cardContainerRef] = useRect() 23 | const [selectionRect, updateSelectionReact] = useSelectionRect() 24 | 25 | useEvent('resize', updateSelectionReact) 26 | useEvent('scroll', updateSelectionReact, true) 27 | useEvent('dblclick', () => { 28 | searchFromSelection() 29 | updateSelectionReact() 30 | }) 31 | 32 | const style = useMemo(() => { 33 | if (selectionRect && canShowCard) { 34 | let translateX, translateY 35 | const { right, left, top, bottom, height, width } = selectionRect 36 | const { width: cardWidth = 300, height: cardHeight = 300 } = cardRect 37 | 38 | if (left + width + cardWidth > window.innerWidth) { 39 | translateX = right - window.pageXOffset - cardWidth 40 | } else { 41 | translateX = left + window.pageXOffset 42 | } 43 | 44 | if (top + cardHeight + height > window.innerHeight) { 45 | translateY = bottom + window.pageYOffset - height - 5 - cardHeight 46 | } else { 47 | translateY = top + window.pageYOffset + height + 5 48 | } 49 | 50 | return { 51 | transform: `translate(${translateX}px, ${translateY}px`, 52 | } 53 | } else { 54 | return {} 55 | } 56 | }, [selectionRect, cardRect, canShowCard]) 57 | 58 | const { data = null, isLoading } = useQuery( 59 | ['searchKeyword', searchKeyword], 60 | async () => { 61 | try { 62 | const res = await search(searchKeyword) 63 | 64 | if ( 65 | (res?.result?.searchResults || []).filter((r) => r.type === 0) 66 | .length > 0 67 | ) { 68 | const wordId = res.result.searchResults[0].tarId 69 | 70 | const result = await fetchWord(wordId).then((r) => r?.result) 71 | 72 | return { 73 | ...result, 74 | wordId, 75 | } 76 | } 77 | } catch { 78 | return null 79 | } 80 | }, 81 | { 82 | enabled: !!searchKeyword, 83 | } 84 | ) 85 | 86 | useEffect(() => { 87 | if (cardContainerRef.current && showCard) { 88 | updateSelectionReact() 89 | } 90 | }, [cardContainerRef, updateSelectionReact, showCard]) 91 | 92 | const onClickOutside = useCallback((e) => { 93 | if (!cardContainerRef.current?.contains(e.target)) { 94 | setShowCard(false) 95 | } 96 | // eslint-disable-next-line react-hooks/exhaustive-deps 97 | }, []) 98 | 99 | useEffect(() => { 100 | document.addEventListener('click', onClickOutside) 101 | 102 | return () => { 103 | document.removeEventListener('click', onClickOutside) 104 | } 105 | // eslint-disable-next-line react-hooks/exhaustive-deps 106 | }, []) 107 | 108 | const renderDetails = useCallback(() => { 109 | if (!data) { 110 | return null 111 | } 112 | 113 | const { details, subdetails } = data 114 | 115 | return details.map((detail) => { 116 | const subDetails = subdetails.filter( 117 | (sub) => sub.detailsId === detail.objectId 118 | ) 119 | 120 | return ( 121 | 122 | 123 | {detail.title} 124 | 125 | {subDetails.map((subdetail, index) => ( 126 |

127 | {index + 1}. {subdetail.title} 128 |

129 | ))} 130 |
131 | ) 132 | }) 133 | }, [data]) 134 | 135 | const word = data && data.word 136 | 137 | if (!canShowCard) { 138 | return null 139 | } 140 | 141 | return ( 142 |
143 |
setShowCard(false)} /> 144 | 145 | {word && ( 146 |
147 |
148 | {searchKeyword} 149 |
150 |
151 | {word.spell} | {word.pron} {word.accent} 152 |
153 | 154 |
{renderDetails()}
155 | 156 | 176 |
177 | )} 178 | 179 | {!word && ( 180 |
181 |
182 |
183 | {searchKeyword} 184 |
185 |
186 | 187 | {isLoading ? ( 188 |
196 | ) : ( 197 |
沒有結果
198 | )} 199 |
200 | )} 201 |
202 | ) 203 | } 204 | 205 | export default ContentApp 206 | --------------------------------------------------------------------------------