├── .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 |

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 | 
4 |
5 | 只要輕點兩下網頁中的任意文字,MOJiDict Helper 就會跳出小視窗搜尋單字,讓你快速記憶查詢日文單字!
6 |
7 | 背後利用了「MOJi辞書」mojidict.com 的 API。
8 |
9 | 目前為 Google Chrome 限定的瀏覽器擴充功能,Chromium 系的 Opera 和 Edge 瀏覽器也都能使用。
10 |
11 | ## Screenshots
12 |
13 | 
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 |
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 |
--------------------------------------------------------------------------------