├── .babelrc ├── .editorconfig ├── .gitattributes ├── .gitignore ├── .jshintrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app ├── _locales │ └── zh_CN │ │ └── messages.json ├── fonts │ └── .gitkeep ├── images │ ├── bg.png │ ├── icon-128-link.png │ └── icon-128.png ├── manifest.json ├── pages │ ├── options.html │ └── popup.html ├── scripts │ ├── app │ │ ├── index.js │ │ └── migrate-options.js │ ├── background.js │ ├── components │ │ ├── FormGroup.vue │ │ ├── Loader.vue │ │ ├── OptionsApp.vue │ │ ├── PopupApp.vue │ │ ├── RadiosGroup.vue │ │ ├── Result.vue │ │ ├── ResultList.vue │ │ ├── ResultToast.vue │ │ ├── RuleList.vue │ │ └── slider.vue │ ├── config │ │ └── defaults.js │ ├── helpers │ │ ├── message.js │ │ ├── selection.js │ │ ├── tabs.js │ │ ├── utils.js │ │ └── wait.js │ ├── inspector.js │ ├── mixins │ │ └── options-loader.js │ ├── options.js │ ├── page-dict.js │ ├── page-fanyi.js │ ├── page.js │ ├── popup.js │ └── translator │ │ ├── dict.js │ │ ├── fanyi.js │ │ └── index.js └── styles │ ├── components │ ├── form-group.scss │ ├── link-inspect-mode.scss │ ├── loader.scss │ ├── radio-group.scss │ ├── result-list.scss │ ├── result-toast.scss │ ├── result.scss │ └── rule-list.scss │ ├── mixins │ ├── fade.scss │ └── reset.scss │ ├── options.scss │ ├── page.scss │ └── popup.scss ├── gulpfile.babel.js ├── package-lock.json ├── package.json └── tasks ├── build.js ├── chromereload.js ├── clean.js ├── default.js ├── fonts.js ├── images.js ├── lib ├── applyBrowserPrefixesFor.js └── args.js ├── locales.js ├── manifest.js ├── pack.js ├── pages.js ├── scripts.js ├── styles.js └── version.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": "last 2 versions" 6 | }, 7 | "useBuiltIns": true, 8 | "debug": false 9 | }] 10 | ], 11 | "ignore": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | 4 | node_modules 5 | npm-debug.log 6 | 7 | dist/ 8 | packages/ 9 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esversion": 6 3 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新记录 2 | 3 | V1.6.1 - 2016-12-03 4 | 5 | - 解决了有道词典调用限制的问题 6 | - 解决翻译中的浮层无法关闭的问题 7 | 8 | V1.6 - 2016-06-17 9 | 10 | - 支持必应翻译 11 | 12 | V1.5.4 - 2016-05-28 13 | 14 | - 解决链接划词模式劫持全局快捷键导致页面链接点击异常的问题 15 | - 链接划词的快捷键更新为 Ctrl+Shift+L 16 | 17 | V1.5.3 - 2016-05-26 18 | 19 | - 解决有时页面冒出一大堆之前的翻译结果的问题 #58 20 | 21 | V1.5.1 - 2016-03-06 22 | 23 | - 解决开启链接划词模式会破坏某些网页布局的问题 24 | 25 | V1.5 - 2015-12-27 26 | 27 | - 使用黑科技重新实现百度词典翻译 28 | 29 | V1.4.2 - 2015-12-19 30 | 31 | - 百度词典API已经停止服务,移除百度翻译 32 | 33 | V1.4 - 2015-12-19 34 | 35 | - 可以手动关闭页面划词翻译的结果面板 36 | - 扩展更新时显示功能更新 37 | 38 | V1.3 - 2015-03-11 39 | 40 | - 添加独立的偏好设定页面,并精简弹出窗口 41 | - 鼠标移上页面翻译结果时,结果面板不消失,移出后重新计时 42 | - 解决页面翻译结果被页内查找框遮盖的问题 43 | - 页面划词翻译结果分为两种显示形式,就近和窗口边缘 44 | - 将链接划词的激活快捷键修改为敲击两次 Caps Lock 45 | - 弹出窗口中支持长文本的翻译 46 | - 添加百度翻译的服务,可以在有道和百度之间切换了 47 | 48 | V1.2 - 2014-05-30 49 | 50 | - 支持对 IFRAME 中的内容进行划词翻译 51 | - 支持对使用了 Turbolinks 技术的网站进行划词翻译 52 | - 支持在划词内容附近显示翻译结果 53 | - 限于有道翻译 API 的使用协议,移除缓存功能 54 | 55 | V1.1 - 2014-02-16 56 | 57 | - 页面划词时,如果一个单词的翻译还未消失,再次对该词划词,不会重复翻译 58 | - 修正部分单词没有查找到翻译仍然显示翻译结果的问题 59 | - 页面划词翻译结果增加透明和淡入淡出效果 60 | - 为翻译结果添加音标(如果有) 61 | - 更新链接文本划词翻译的描述 62 | - 解决频繁划词导致翻译结果跑出屏幕的问题(支持使用滚轮滚动) 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, GDG Xi'an 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | Neither the name of the {organization} nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

穆译 ( Smooth Translator )

2 | 3 |

让你沉浸在阅读中的划词翻译利器

4 |

5 | 6 | ### 穆译的信条 7 | 8 | > 不要让划词翻译干扰阅读状态 9 | 10 | 所以页面划词翻译的结果不会显示在词语附近遮挡住你的阅读材料,而是显示在页面的右上角,余光扫一眼便立即回到阅读,稍后它便会自己关闭。 11 | 12 | ### 如何使用穆译 13 | 14 | - 如果要查询页面中的某个单词,双击选中,单词的释义会显示在页面右上角,然后自动在几秒后消失(你可以在设置页面调整时间长短)。 15 | - 要前往扩展设置页面,可以点击扩展图标打开翻译窗口,然后点击窗口中的齿轮图标。 16 | - 在翻译窗口中也可以进行翻译,输入要翻译的内容,翻译结果会自动呈现。在翻译窗口的输入框中按下 `ESC`,会清除当前的输入内容, 17 | 方便查询新的单词。如果输入框没有内容,`ESC` 会关闭翻译窗口。 18 | - 如果想在某个网站上禁用划词翻译,可以在翻译窗口中取消勾选「在 xxx 启用划词翻译」, 19 | 如果想在全部页面禁用划词翻译,请在设置页面中的取消勾选「默认」条目。 20 | - 如果你想对页面中的某个链接中的单词划词翻译,可以使用快捷键 `Ctrl + Shift + L` 进入链接划词模式, 21 | 此时所有的链接的跳转会失效,你可以对其进行划词翻译,划词后,会自动退出链接划词模式。 22 | 这个快捷键你可以在 Chrome 的快捷键设置里面更改(可以在扩展设置页面点击「更改快捷键」按钮前往设置)。 23 | - 扩展的翻译窗口打开时会自动显示上一次翻译(包括页面划词)的内容,即便你关闭了页面划词翻译, 24 | 仍然可以在页面中选中文本内容后,点击扩展图标打开翻译窗口查看选中内容的翻译结果。 25 | 页面划词翻译只会显示单个英语单词的翻译结果,所以如果你想查看页面上某个句子(包括中文)的翻译结果, 26 | 这个特性会是一个很好的补充。 27 | 28 | ---- 29 | 30 | 穆译使用[有道翻译](http://translate.youdao.com/)提供词典和翻译服务。 31 | 32 | 穆译是一个开源应用,如果你想审查代码或者参与开发,请访问 33 | 34 | https://github.com/greatghoul/smooth-translator 35 | -------------------------------------------------------------------------------- /app/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "穆译", 4 | "description": "The name of the application" 5 | }, 6 | "appShortName": { 7 | "message": "smooth_trans", 8 | "description": "The short_name (maximum of 12 characters recommended) is a short version of the app's name." 9 | }, 10 | "appDescription": { 11 | "message": "让你专注于阅读的划词翻译工具", 12 | "description": "The description of the application" 13 | }, 14 | "browserActionTitle": { 15 | "message": "穆译", 16 | "description": "The title of the browser action button" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/fonts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greatghoul/smooth-translator/2e97fb7fada0b0b5602dae2676eee154bbd5ee2c/app/fonts/.gitkeep -------------------------------------------------------------------------------- /app/images/bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greatghoul/smooth-translator/2e97fb7fada0b0b5602dae2676eee154bbd5ee2c/app/images/bg.png -------------------------------------------------------------------------------- /app/images/icon-128-link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greatghoul/smooth-translator/2e97fb7fada0b0b5602dae2676eee154bbd5ee2c/app/images/icon-128-link.png -------------------------------------------------------------------------------- /app/images/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greatghoul/smooth-translator/2e97fb7fada0b0b5602dae2676eee154bbd5ee2c/app/images/icon-128.png -------------------------------------------------------------------------------- /app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "default_locale": "zh_CN", 4 | 5 | "name": "__MSG_appName__", 6 | "short_name": "__MSG_appShortName__", 7 | "description": "__MSG_appDescription__", 8 | "version": "0.2.0", 9 | 10 | "icons": { 11 | "128": "images/icon-128.png" 12 | }, 13 | 14 | "background": { 15 | "scripts": [ 16 | "scripts/background.js" 17 | ], 18 | "persistent": true 19 | }, 20 | 21 | "browser_action": { 22 | "default_icon": "images/icon-128.png", 23 | "default_title": "__MSG_browserActionTitle__", 24 | "default_popup": "pages/popup.html" 25 | }, 26 | 27 | "options_page": "pages/options.html", 28 | 29 | "content_scripts": [ 30 | { 31 | "matches": [""], 32 | "js": ["scripts/page.js"], 33 | "css": ["styles/page.css"] 34 | }, 35 | { 36 | "matches": [""], 37 | "js": ["scripts/inspector.js"], 38 | "all_frames": true 39 | }, 40 | { 41 | "matches": [ "*://fanyi.youdao.com/*" ], 42 | "js": [ "scripts/page-fanyi.js" ], 43 | "all_frames": true 44 | }, 45 | { 46 | "matches": [ "*://dict.youdao.com/*" ], 47 | "js": [ "scripts/page-dict.js" ], 48 | "all_frames": true 49 | } 50 | ], 51 | 52 | "commands": { 53 | "toggle-link-inspect": { 54 | "suggested_key": { 55 | "default": "Ctrl+Shift+L", 56 | "mac": "MacCtrl+Shift+L" 57 | }, 58 | "description": "打开/关闭链接划词模式" 59 | } 60 | }, 61 | 62 | "permissions": [ 63 | "tabs", 64 | "storage", 65 | "notifications", 66 | "webRequest", 67 | "webRequestBlocking", 68 | "*://fanyi.youdao.com/*", 69 | "*://dict.youdao.com/*" 70 | ], 71 | 72 | "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'" 73 | } 74 | -------------------------------------------------------------------------------- /app/pages/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 偏好设定 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/pages/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/scripts/app/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import storage from 'chrome-storage-wrapper' 3 | import migrateOptions from './migrate-options' 4 | 5 | const defaults = { 6 | notifyTimeout: 5, 7 | siteRules: { 8 | '*': true 9 | }, 10 | } 11 | 12 | let options = _.clone(defaults); 13 | 14 | function isSiteEnabled(site) { 15 | const { siteRules } = options; 16 | if (site in siteRules) { 17 | return siteRules[site] 18 | } else { 19 | return siteRules['*'] 20 | } 21 | } 22 | 23 | function setOptions(newOptions) { 24 | storage.set(newOptions) 25 | options = newOptions 26 | } 27 | 28 | function getOptions() { 29 | if (_.empty(options)) { 30 | return Promise.resolve(options) 31 | } else { 32 | return storage.getAll() 33 | } 34 | } 35 | 36 | function prepareOptions() { 37 | storage.getAll() 38 | .then(options => migrateOptions(options)) 39 | .then(options => _.defaults(options, defaults)) 40 | .then(options => setOptions(options)) 41 | chrome.storage.onChanged.addListener(() => { 42 | options = getOptions() 43 | }) 44 | } 45 | 46 | export default { 47 | isSiteEnabled, 48 | prepareOptions, 49 | } 50 | -------------------------------------------------------------------------------- /app/scripts/app/migrate-options.js: -------------------------------------------------------------------------------- 1 | // Migrate site rules from array to object. 2 | export default function(options) { 3 | if (options.siteRules instanceof Array) { 4 | const rules = {} 5 | options.siteRules.forEach(x => { 6 | rules[x.site] = x.enabled 7 | }) 8 | 9 | options.siteRules = rules 10 | } 11 | 12 | return options; 13 | } 14 | -------------------------------------------------------------------------------- /app/scripts/background.js: -------------------------------------------------------------------------------- 1 | import storage from 'chrome-storage-wrapper' 2 | import { dispatchMessage } from './helpers/message' 3 | import { getActiveTab } from './helpers/tabs' 4 | import defaults from './config/defaults' 5 | import lscache from 'lscache' 6 | import translator from './translator' 7 | import { trim } from 'lodash' 8 | import app from './app' 9 | 10 | const PAT_WORD = /^[a-z]+('|'s)?$/i 11 | 12 | function translateText (text) { 13 | const sourceText = trim(text) 14 | const cacheKey = `text:v2:${sourceText}` 15 | let result = lscache.get(cacheKey) 16 | return result ? Promise.resolve(result) : translator.translate(sourceText) 17 | } 18 | 19 | function isWord(text) { 20 | return PAT_WORD.test(text) 21 | } 22 | 23 | dispatchMessage({ 24 | translate (message, sender, sendResponse) { 25 | storage.get('notifyTimeout').then(options => { 26 | translateText(message.text).then(result => { 27 | if (message.from === 'page') { 28 | result.timeout = options.notifyTimeout 29 | } else { 30 | window.localStorage.setItem('current', message.text) 31 | } 32 | 33 | sendResponse(result) 34 | }) 35 | }) 36 | }, 37 | 38 | selection (message, sender, sendResponse) { 39 | window.localStorage.setItem('current', message.text) 40 | 41 | if (isWord(message.text)) { 42 | getActiveTab(tab => { 43 | if (app.isSiteEnabled(tab.hostname)) { 44 | chrome.tabs.sendMessage(sender.tab.id, { 45 | type: 'translate', 46 | text: message.text 47 | }) 48 | } 49 | }) 50 | } 51 | }, 52 | 53 | current (message, sender, sendResponse) { 54 | sendResponse(window.localStorage.getItem('current')) 55 | }, 56 | 57 | linkInspect (message, sender, sendResponse) { 58 | if (message.enabled) { 59 | chrome.browserAction.setIcon({ path: 'images/icon-128-link.png' }) 60 | } else { 61 | chrome.browserAction.setIcon({ path: 'images/icon-128.png' }) 62 | } 63 | } 64 | }) 65 | 66 | // Register command for quick link inspect switch 67 | chrome.commands.onCommand.addListener(command => { 68 | if (command === 'toggle-link-inspect') { 69 | getActiveTab(tab => chrome.tabs.sendMessage(tab.id, { type: 'toggleLink' })) 70 | } 71 | }) 72 | 73 | app.prepareOptions() 74 | -------------------------------------------------------------------------------- /app/scripts/components/FormGroup.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 16 | -------------------------------------------------------------------------------- /app/scripts/components/Loader.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /app/scripts/components/OptionsApp.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 78 | -------------------------------------------------------------------------------- /app/scripts/components/PopupApp.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 129 | -------------------------------------------------------------------------------- /app/scripts/components/RadiosGroup.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /app/scripts/components/Result.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /app/scripts/components/ResultList.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 46 | -------------------------------------------------------------------------------- /app/scripts/components/ResultToast.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 51 | 52 | -------------------------------------------------------------------------------- /app/scripts/components/RuleList.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /app/scripts/components/slider.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /app/scripts/config/defaults.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // 页面划词结果显示时间 3 | notifyTimeout: 5, 4 | 5 | // 划词翻译在各个网站上是否开启 6 | siteRules: { 7 | '*': true 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /app/scripts/helpers/message.js: -------------------------------------------------------------------------------- 1 | export function dispatchMessage(mapping) { 2 | chrome.runtime.onMessage.addListener( 3 | function(message, sender, sendResponse) { 4 | const handler = mapping[message.type]; 5 | if (typeof handler == 'function') { 6 | handler(message, sender, sendResponse); 7 | } 8 | 9 | return true; 10 | } 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/scripts/helpers/selection.js: -------------------------------------------------------------------------------- 1 | import { trim } from 'lodash'; 2 | 3 | export function getSelection(evt) { 4 | const selection = window.getSelection() 5 | 6 | return trim(selection.toString()) 7 | } 8 | 9 | export function clearSelection() { 10 | const selection = window.getSelection() 11 | 12 | if (selection) { 13 | selection.empty() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/scripts/helpers/tabs.js: -------------------------------------------------------------------------------- 1 | import URL from 'url-parse'; 2 | 3 | function getHostname(url) { 4 | const parsed = new URL(url); 5 | 6 | if (parsed) { 7 | return parsed.hostname; 8 | } else { 9 | return '*'; 10 | } 11 | } 12 | 13 | export function getActiveTab(callback) { 14 | chrome.tabs.query({ active: true }, tabs => { 15 | const tab = tabs[0]; 16 | const hostname = getHostname(tab.url); 17 | 18 | callback(Object.assign({}, tab, { hostname })); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /app/scripts/helpers/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import $ from 'jquery' 3 | 4 | export function openExtensionPage(filename) { 5 | var optionsUrl = chrome.extension.getURL(filename) 6 | 7 | chrome.tabs.query({}, function(tabs) { 8 | var optionTab = _.find(tabs, { url: optionsUrl }) 9 | 10 | if (optionTab) { 11 | chrome.tabs.reload(optionTab.id) 12 | chrome.tabs.update(optionTab.id, { highlighted: true }) 13 | } else { 14 | chrome.tabs.create({ url: optionsUrl }) 15 | } 16 | }) 17 | } 18 | 19 | export function renderTranslation(query, result) { 20 | let phonetic = '' 21 | let translation = '未找到释义' 22 | let className = 'cst-warning' 23 | 24 | if (result) { 25 | phonetic = result.phonetic 26 | translation = result.translation 27 | className = 'cst-success' 28 | } 29 | 30 | return ` 31 |
32 |
${query}
33 | ${phonetic || ''} 34 |
${translation}
35 |
36 | ` 37 | } 38 | 39 | function getClientHeight() { 40 | const bodyHeight = document.body.clientHeight 41 | const docHeight = document.documentElement.clientHeight 42 | 43 | let clientHeight = bodyHeight < docHeight ? bodyHeight : docHeight 44 | if (clientHeight === 0) { 45 | clientHeight = docHeight 46 | } 47 | 48 | return clientHeight 49 | } 50 | 51 | function getPosition(evt, selection) { 52 | let rect = selection.getRangeAt(0).getBoundingClientRect() 53 | 54 | // Use mouse position if selection range position invalid (in text field) 55 | if (rect.left === 0 && rect.top === 0) { 56 | rect = { left: evt.clientX, top: evt.clientY, height: 15 } 57 | } 58 | 59 | const left = rect.left + document.body.scrollLeft 60 | const top = rect.top + document.body.scrollTop 61 | const position = { left } 62 | 63 | if (rect.top >= 150) { 64 | position.bottom = getClientHeight() - top 65 | } else { 66 | position.top = top + rect.height + 5 67 | } 68 | 69 | return position 70 | } 71 | 72 | export function stopPropagation(event) { 73 | event.stopPropagation() 74 | } 75 | 76 | // TODO: Move toggleLinkInspectMode function to a proper place 77 | export function toggleLinkInspectMode (flag) { 78 | $('body').toggleClass('cst-link-inspect-mode', flag) 79 | const enabled = $('body').is('.cst-link-inspect-mode') 80 | chrome.runtime.sendMessage({ type: 'linkInspect', enabled }) 81 | } -------------------------------------------------------------------------------- /app/scripts/helpers/wait.js: -------------------------------------------------------------------------------- 1 | import waitUntil from 'wait-until-promise' 2 | 3 | export default function (escapeFunction) { 4 | return waitUntil(escapeFunction, 1000 * 10, 1) 5 | } 6 | -------------------------------------------------------------------------------- /app/scripts/inspector.js: -------------------------------------------------------------------------------- 1 | import { dispatchMessage } from './helpers/message' 2 | import { getSelection } from './helpers/selection' 3 | import { toggleLinkInspectMode } from './helpers/utils' 4 | 5 | function selectionHandler(evt) { 6 | toggleLinkInspectMode(false) 7 | 8 | const text = getSelection() 9 | 10 | if (text) { 11 | chrome.runtime.sendMessage({ type: 'selection', text: text }) 12 | } 13 | } 14 | 15 | document.addEventListener('mouseup', selectionHandler) 16 | -------------------------------------------------------------------------------- /app/scripts/mixins/options-loader.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import storage from 'chrome-storage-wrapper'; 3 | import defaults from '../config/defaults'; 4 | 5 | export default { 6 | data() { 7 | return { 8 | options: Object.assign({}, defaults), 9 | }; 10 | }, 11 | methods: { 12 | initOptions() { 13 | storage.addChangeListener(() => this.loadOptions()); 14 | storage.getAll().then(options => console.log(options)); 15 | return this.loadOptions(); 16 | }, 17 | loadOptions() { 18 | return storage.getAll().then(options => this.options = options); 19 | }, 20 | updateOption(name, value) { 21 | this.options[name] = value; 22 | storage.set(name, value); 23 | }, 24 | saveRule(rule) { 25 | this.options.siteRules[rule.site] = rule.enabled 26 | this.updateOption('siteRules', this.options.siteRules); 27 | }, 28 | removeRule(rule) { 29 | _.remove(this.options.siteRules, { site: rule.site }); 30 | this.updateOption('siteRules', this.options.siteRules); 31 | } 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /app/scripts/options.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import OptionsApp from './components/OptionsApp.vue'; 3 | 4 | new Vue({ 5 | el: '#app', 6 | render: h => h(OptionsApp), 7 | }); 8 | -------------------------------------------------------------------------------- /app/scripts/page-dict.js: -------------------------------------------------------------------------------- 1 | import { trim } from 'lodash' 2 | import wait from './helpers/wait' 3 | 4 | function fetchResult () { 5 | const elemTrans = document.querySelector('.trans-container') 6 | if (elemTrans && !elemTrans.getAttribute('id')) { 7 | const result = { 8 | status: 'success', 9 | translation: trim(elemTrans.innerHTML) 10 | } 11 | 12 | const elemPhon = document.querySelector('.baav') 13 | if (elemPhon) { 14 | result.phonetic = trim(elemPhon.innerText) 15 | } 16 | 17 | return result 18 | } 19 | } 20 | 21 | function onMessage (event) { 22 | const { data } = event 23 | console.log('[dict] iframe received message:', JSON.stringify(data)) 24 | if (data.type === 'fetch-result') { 25 | wait(fetchResult).then(result => { 26 | event.source.postMessage({ 27 | type: 'result', 28 | url: data.url, 29 | result: result 30 | }, '*') 31 | }) 32 | } 33 | } 34 | 35 | window.addEventListener('message', onMessage, false) 36 | -------------------------------------------------------------------------------- /app/scripts/page-fanyi.js: -------------------------------------------------------------------------------- 1 | import { trim } from 'lodash' 2 | import wait from './helpers/wait' 3 | 4 | function inputSource (text) { 5 | document.querySelector('#inputOriginal').value = text 6 | } 7 | 8 | function clickSubmit () { 9 | const event = new window.MouseEvent('click', { 10 | bubbles: true, 11 | cancelable: true, 12 | view: window 13 | }) 14 | 15 | document.querySelector('#transMachine').dispatchEvent(event) 16 | } 17 | 18 | function fetchResult () { 19 | const elem = document.querySelector('#transTarget') 20 | return trim(elem.innerText) 21 | } 22 | 23 | function onMessage (event) { 24 | const { data } = event 25 | if (data.type === 'fetch-result') { 26 | console.log('[fanyi] iframe received message', JSON.stringify(data)) 27 | inputSource(data.text) 28 | clickSubmit() 29 | 30 | wait(fetchResult).then(result => { 31 | event.source.postMessage({ 32 | type: 'result', 33 | token: data.token, 34 | result: { translation: result, status: 'success' } 35 | }, '*') 36 | }) 37 | } 38 | } 39 | 40 | window.addEventListener('message', onMessage, false) 41 | -------------------------------------------------------------------------------- /app/scripts/page.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery' 2 | import Vue from 'vue' 3 | import { dispatchMessage } from './helpers/message' 4 | import { toggleLinkInspectMode } from './helpers/utils' 5 | import ResultList from './components/ResultList.vue' 6 | 7 | let app = null; 8 | 9 | function getApp () { 10 | if ($('#cst-list').length == 0) { 11 | $('
').appendTo('body') 12 | app = new Vue({ 13 | el: '#cst-list', 14 | render: h => h(ResultList), 15 | }); 16 | } 17 | 18 | return app.$children[0] 19 | } 20 | 21 | function translate (message, sender, sendResponse) { 22 | getApp().translate(message.text) 23 | } 24 | 25 | function toggleLink (message, sender, sendResponse) { 26 | toggleLinkInspectMode() 27 | } 28 | 29 | dispatchMessage({ translate, toggleLink }) 30 | -------------------------------------------------------------------------------- /app/scripts/popup.js: -------------------------------------------------------------------------------- 1 | import 'vue-awesome/icons/cog' 2 | 3 | import Vue from 'vue' 4 | import Icon from 'vue-awesome/components/Icon' 5 | import PopupApp from './components/PopupApp.vue' 6 | 7 | Vue.component('icon', Icon) 8 | 9 | new Vue({ 10 | el: '#app', 11 | render: h => h(PopupApp), 12 | }) 13 | -------------------------------------------------------------------------------- /app/scripts/translator/dict.js: -------------------------------------------------------------------------------- 1 | import wait from '../helpers/wait' 2 | 3 | const URL = 'http://dict.youdao.com' 4 | 5 | export default class Dict { 6 | destroy () { 7 | window.removeEventListener('mousedown', this.onMessage, false) 8 | this.iframe.remove() 9 | } 10 | 11 | register () { 12 | window.addEventListener('message', this.onMessage, false) 13 | this.iwindow.postMessage({ type: 'fetch-result', url: this.url }, '*') 14 | } 15 | 16 | receive (event) { 17 | const { data } = event 18 | console.log('[dict] event received message:', JSON.stringify(data)) 19 | if (data.type === 'result' && data.url === this.url) { 20 | this.result = data.result 21 | } 22 | } 23 | 24 | get iwindow () { 25 | return this.iframe.contentWindow 26 | } 27 | 28 | fetchResult () { 29 | return this.result 30 | } 31 | 32 | translate (text) { 33 | this.url = `${URL}/w/${encodeURIComponent(text)}/#keyfrom=dict2.top` 34 | this.iframe = document.createElement('iframe') 35 | document.body.appendChild(this.iframe) 36 | this.iframe.onload = () => { 37 | this.register() 38 | } 39 | this.onMessage = this.receive.bind(this) 40 | this.result = null 41 | this.iframe.src = this.url 42 | return wait(() => this.fetchResult()) 43 | } 44 | } 45 | 46 | Dict.translate = function (text) { 47 | let dict = new Dict() 48 | return dict.translate(text).finally(() => { 49 | dict.destroy() 50 | dict = null 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /app/scripts/translator/fanyi.js: -------------------------------------------------------------------------------- 1 | import wait from '../helpers/wait' 2 | 3 | const URL = 'http://fanyi.youdao.com/?keyfrom=dict2.index' 4 | 5 | export default class Fanyi { 6 | destroy () { 7 | window.removeEventListener('mousedown', this.onMessage, false) 8 | this.iframe.remove() 9 | } 10 | 11 | register () { 12 | window.addEventListener('message', this.onMessage, false) 13 | this.iwindow.postMessage({ 14 | type: 'fetch-result', 15 | token: this.token, 16 | text: this.text 17 | }, '*') 18 | } 19 | 20 | receive (event) { 21 | const { data } = event 22 | if (data.type === 'result' && data.token === this.token) { 23 | console.log('[fanyi] event received message:', JSON.stringify(data)) 24 | this.result = data.result 25 | } 26 | } 27 | 28 | get iwindow () { 29 | return this.iframe.contentWindow 30 | } 31 | 32 | fetchResult () { 33 | return this.result 34 | } 35 | 36 | translate (text) { 37 | this.text = text 38 | this.token = 'fanyi-' + Date.now() 39 | this.iframe = document.createElement('iframe') 40 | document.body.appendChild(this.iframe) 41 | this.iframe.onload = () => { 42 | this.register() 43 | } 44 | this.onMessage = this.receive.bind(this) 45 | this.result = null 46 | this.iframe.src = URL 47 | return wait(() => this.fetchResult()) 48 | } 49 | } 50 | 51 | Fanyi.translate = function (text) { 52 | let fanyi = new Fanyi() 53 | return fanyi.translate(text).finally(() => { 54 | fanyi.destroy() 55 | fanyi = null 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /app/scripts/translator/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import lscache from 'lscache' 3 | import Dict from './dict' 4 | import Fanyi from './fanyi' 5 | import { words } from 'lodash' 6 | 7 | const PAT_WORD = /^([a-z]+-?)+$/i 8 | const RESULT_FAILURE = { 9 | translation: '未找到释义', 10 | status: 'failure' 11 | } 12 | 13 | function isWord (text) { 14 | return text.match(PAT_WORD) 15 | } 16 | 17 | function smartText (text) { 18 | return isWord(text) ? words(text).join(' ') : text 19 | } 20 | 21 | function cacheResult(text, result) { 22 | const key = `text:v2:${_.trim(text)}` 23 | lscache.set(key, result, 60 * 24 * 7) 24 | return result 25 | } 26 | 27 | function translate (text) { 28 | const sourceText = smartText(text) 29 | 30 | if (!sourceText) { 31 | Promise.resolve(RESULT_FAILURE) 32 | } else if (isWord(sourceText)) { 33 | return Dict.translate(sourceText) 34 | .then(result => cacheResult(text, result)) 35 | .catch(() => RESULT_FAILURE) 36 | } else { 37 | return Fanyi.translate(sourceText).catch(() => RESULT_FAILURE) 38 | } 39 | } 40 | 41 | export default { translate } 42 | -------------------------------------------------------------------------------- /app/styles/components/form-group.scss: -------------------------------------------------------------------------------- 1 | .form-group { 2 | margin-bottom: 15px; 3 | padding-bottom: 15px; 4 | border-bottom: 1px solid #f0f0f0; 5 | &:last-child { 6 | border-bottom: 0; 7 | margin-bottom: 0; 8 | padding-bottom: 0; 9 | } 10 | .control-label { 11 | margin: 3px 0; 12 | float: left; 13 | width: 200px; 14 | font-weight: bold; 15 | font-size: 1.1em; 16 | } 17 | .controls { 18 | margin-left: 200px; 19 | .hint { 20 | color: #aaaaaa; 21 | margin-top: 10px; 22 | margin-bottom: 0; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/styles/components/link-inspect-mode.scss: -------------------------------------------------------------------------------- 1 | body.cst-link-inspect-mode a { 2 | text-decoration: none !important; 3 | pointer-events: none !important; 4 | } 5 | -------------------------------------------------------------------------------- /app/styles/components/loader.scss: -------------------------------------------------------------------------------- 1 | $color_loader_dark: #8fc2e4; 2 | $color_loader_light: #eeeeee; 3 | 4 | @keyframes progress { 5 | 0% { 6 | transform: translateX(-240px); 7 | opacity: 0; 8 | } 9 | 10 | 50% { 11 | opacity: 1; 12 | } 13 | 14 | 100% { 15 | transform: translateX(240px); 16 | opacity: 0; 17 | } 18 | } 19 | 20 | .loader { 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | height: 2px; 25 | 26 | .progress { 27 | position: absolute; 28 | width: 120px; 29 | height: 2px; 30 | line-height: 2px; 31 | background-image: linear-gradient(-45deg, $color_loader_light, $color_loader_dark ); 32 | background-size: 120px 2px; 33 | animation: progress 1.8s ease-in-out 0s infinite; 34 | } 35 | } -------------------------------------------------------------------------------- /app/styles/components/radio-group.scss: -------------------------------------------------------------------------------- 1 | .radio-inline { 2 | display: inline-block; 3 | margin-right: 5px; 4 | } 5 | -------------------------------------------------------------------------------- /app/styles/components/result-list.scss: -------------------------------------------------------------------------------- 1 | .cst-list { 2 | @include reset; 3 | } 4 | 5 | #cst-list { 6 | position: fixed; 7 | z-index: 2147483647; 8 | width: 250px; 9 | right: 15px; 10 | top: 35px; 11 | max-height: calc(100% - 40px); 12 | overflow: auto; 13 | 14 | &::-webkit-scrollbar { 15 | display: none; 16 | width: 0px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/styles/components/result-toast.scss: -------------------------------------------------------------------------------- 1 | #cst-list .cst-result-toast { 2 | @include fade; 3 | 4 | z-index: 2147483647; 5 | max-width: 250px; 6 | min-width: 150px; 7 | line-height: 1.5; 8 | font-size: 14px; 9 | margin-bottom: 5px; 10 | border-radius: 5px; 11 | box-shadow: 3px 3px 3px #000000; 12 | opacity: 0.9; 13 | 14 | a.close { 15 | float: right; 16 | text-decoration: none; 17 | margin-right: 5px; 18 | font-size: .8em; 19 | color: #cecece; 20 | 21 | &:hover { 22 | color: #ffffff; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /app/styles/components/result.scss: -------------------------------------------------------------------------------- 1 | #cst-list .cst-result, 2 | .cst-result { 3 | padding: 3px 5px; 4 | 5 | .cst-result-phonetic { 6 | margin: 0 0 5px 0; 7 | } 8 | 9 | h6, code, pre { 10 | border: none; 11 | background: none; 12 | color: inherit; 13 | padding: 0; 14 | margin: 0; 15 | text-transform: none; 16 | font-size: 14px; 17 | line-height: 20px; 18 | white-space: normal; 19 | } 20 | 21 | .cst-result-text { 22 | margin-bottom: 5px; 23 | font-weight: 600; 24 | } 25 | 26 | .cst-result-phonetic { 27 | padding-top: 5px !important; 28 | padding-bottom: 10px !important; 29 | color: #58afb1; 30 | } 31 | 32 | .cst-result-translation { 33 | overflow-y: auto; 34 | max-height: 200px; 35 | 36 | .additional { 37 | color: #aaa !important; 38 | font-size: 0.9em !important; 39 | margin-top: 5px !important; 40 | padding-bottom: 5px !important; 41 | } 42 | } 43 | } 44 | 45 | .cst-result[data-cst-theme="dark"] { 46 | &[data-cst-status="success"], 47 | &[data-cst-status="pending"] { 48 | background: #336721; 49 | color: #EDF8ED; 50 | } 51 | 52 | &[data-cst-status="failure"] { 53 | background: #FFF299; 54 | color: #888888; 55 | } 56 | } 57 | 58 | .cst-result[data-cst-theme="light"] { 59 | &[data-cst-status="success"], 60 | &[data-cst-status="pending"] { 61 | background: #DDEADD; 62 | color: #2B3F29; 63 | } 64 | 65 | &[data-cst-status="failure"] { 66 | background: #fff3c8; 67 | color: #888888; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /app/styles/components/rule-list.scss: -------------------------------------------------------------------------------- 1 | table.table-rules { 2 | width: 100%; 3 | border: 2px solid #ddd; 4 | 5 | th, td { 6 | padding: 5px 10px; 7 | text-align: center; 8 | 9 | &:first-child { 10 | text-align: left; 11 | } 12 | } 13 | 14 | th { 15 | background-color: #ddd; 16 | } 17 | 18 | tbody tr:not(:first-child) td { 19 | border-top: 1px solid #ddd; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/styles/mixins/fade.scss: -------------------------------------------------------------------------------- 1 | @mixin fade { 2 | &.fade-enter-active, &.fade-leave-active { 3 | transition: opacity .5s; 4 | } 5 | &.fade-enter, &.fade-leave-active { 6 | opacity: 0; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /app/styles/mixins/reset.scss: -------------------------------------------------------------------------------- 1 | @mixin reset { 2 | &, & * { 3 | margin: 0; 4 | padding: 0; 5 | border: 0; 6 | font-size: 100%; 7 | font-family: "Helvetica Neue", "Luxi Sans", "DejaVu Sans", Tahoma, "Hiragino Sans GB", "Microsoft Yahei", sans-serif; 8 | vertical-align: baseline; 9 | width: auto; 10 | height: auto; 11 | background: none; 12 | border-radius: 0; 13 | text-align: left; 14 | } 15 | } -------------------------------------------------------------------------------- /app/styles/options.scss: -------------------------------------------------------------------------------- 1 | @import 'components/form-group'; 2 | @import 'components/rule-list'; 3 | 4 | body { 5 | background: #efefe9; 6 | margin: 0; 7 | padding: 0; 8 | color: #555; 9 | font-size: 13px; 10 | } 11 | 12 | .board { 13 | width: 65%; 14 | min-width: 800px; 15 | margin: 60px auto; 16 | background: #fff; 17 | 18 | .board-header { 19 | background: #fafafa url('../../images/bg.png'); 20 | background-size: 30%; 21 | border-bottom: 1px solid #eeeeee; 22 | padding: 10px 30px; 23 | 24 | .title { 25 | margin-top: 10px; 26 | margin-bottom: 10px; 27 | font-size: 30px; 28 | line-height: 48px; 29 | display: inline-block; 30 | } 31 | 32 | img { 33 | vertical-align: middle; 34 | } 35 | } 36 | } 37 | 38 | .board-content { 39 | padding: 30px; 40 | } 41 | 42 | input[type=range] { 43 | vertical-align: middle; 44 | } 45 | 46 | .command { 47 | padding: 2px 4px; 48 | margin: 0 5px; 49 | font-size: 1.2em; 50 | color: #c7254e; 51 | background-color: #f9f2f4; 52 | border-radius: 4px; 53 | } -------------------------------------------------------------------------------- /app/styles/page.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins/reset'; 2 | @import 'mixins/fade'; 3 | @import 'components/result-list'; 4 | @import 'components/result-toast'; 5 | @import 'components/result'; 6 | @import 'components/link-inspect-mode'; 7 | 8 | -------------------------------------------------------------------------------- /app/styles/popup.scss: -------------------------------------------------------------------------------- 1 | $font-color: #555; 2 | 3 | @import 'mixins/reset'; 4 | @import 'components/loader'; 5 | @import 'components/result'; 6 | 7 | body { 8 | width: 240px; 9 | min-height: 85px; 10 | margin: 0; 11 | font-size: 14px; 12 | color: $font-color; 13 | background-color: #F2F5F6; 14 | } 15 | 16 | [v-cloak] { 17 | visibility: hidden; 18 | } 19 | 20 | .translator { 21 | .input-box { 22 | padding: 5px 5px 2px 5px;; 23 | 24 | textarea { 25 | -webkit-appearance: textfield; 26 | border: 1px inset #e0e0e0; 27 | background-color: #fefbf5; 28 | resize: none; 29 | font-size: 12px; 30 | line-height: 1.2em; 31 | color: #888; 32 | width: 100%; 33 | margin: 0; 34 | font-weight: bold; 35 | box-sizing: border-box; 36 | 37 | &:active, &:focus { 38 | outline: none; 39 | background-color: #ffffd4; 40 | } 41 | } 42 | } 43 | 44 | .result-wrapper { 45 | @include reset; 46 | padding: 0px 5px !important; 47 | 48 | .cst-result { 49 | padding: 0 5px; 50 | } 51 | } 52 | 53 | footer { 54 | height: 24px; 55 | line-height: 24px; 56 | padding: 0 5px; 57 | 58 | .btn-settings { 59 | float: right; 60 | 61 | .fa-icon { 62 | fill: $font-color; 63 | margin-top: 5px; 64 | vertical-align: top; 65 | width: auto; 66 | height: 1em; 67 | max-width: 100%; 68 | max-height: 100%; 69 | } 70 | } 71 | 72 | label { 73 | .site { 74 | font-style: italic; 75 | font-weight: bold; 76 | font-size: .9em; 77 | display: inline-block; 78 | vertical-align: bottom; 79 | max-width: 50px; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | white-space: nowrap; 83 | } 84 | } 85 | } 86 | } 87 | 88 | label { 89 | font-size: 0.9em; 90 | color: gray; 91 | user-select: none; 92 | 93 | &.enabled { 94 | color: green; 95 | } 96 | 97 | input[type="checkbox"] { 98 | margin: 0; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import requireDir from 'require-dir' 2 | 3 | // Check out the tasks directory 4 | // if you want to modify tasks! 5 | requireDir('./tasks') 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smooth-translator", 3 | "private": true, 4 | "version": "0.2.0", 5 | "scripts": { 6 | "start": "npm run dev:chrome", 7 | "build": "npm run build:chrome", 8 | "build:chrome": "gulp pack --production --vendor=chrome", 9 | "build:firefox": "gulp pack --production --vendor=firefox", 10 | "build:opera": "gulp pack --production --vendor=opera", 11 | "build:edge": "gulp pack --production --vendor=edge", 12 | "dev": "npm run dev:chrome", 13 | "dev:chrome": "gulp --watch --vendor=chrome", 14 | "dev:firefox": "gulp --watch --vendor=firefox", 15 | "dev:opera": "gulp --watch --vendor=opera", 16 | "dev:edge": "gulp --watch --vendor=edge", 17 | "lint": "standard" 18 | }, 19 | "standard": { 20 | "globals": [ 21 | "chrome" 22 | ] 23 | }, 24 | "devDependencies": { 25 | "babel-cli": "^6.26.0", 26 | "babel-core": "^6.26.3", 27 | "babel-loader": "7.x.x", 28 | "babel-preset-env": "^1.7.0", 29 | "chai": "4.x.x", 30 | "chromereload": "0.x.x", 31 | "debounce": "1.x.x", 32 | "del": "3.x.x", 33 | "gulp": "3.x.x", 34 | "gulp-bump": "2.x.x", 35 | "gulp-cache": "0.x.x", 36 | "gulp-clean-css": "^3.x.x", 37 | "gulp-filter": "^5.x.x", 38 | "gulp-git": "^2.x.x", 39 | "gulp-if": "2.x.x", 40 | "gulp-imagemin": "3.x.x", 41 | "gulp-json-transform": "0.x.x", 42 | "gulp-less": "3.x.x", 43 | "gulp-livereload": "3.x.x", 44 | "gulp-plumber": "1.x.x", 45 | "gulp-sass": "^3.x.x", 46 | "gulp-sequence": "0.x.x", 47 | "gulp-sourcemaps": "^2.x.x", 48 | "gulp-tag-version": "1.x.x", 49 | "gulp-util": "3.x.x", 50 | "gulp-zip": "^4.x.x", 51 | "require-dir": "1.x.x", 52 | "standard": "^10.0.2", 53 | "vinyl-named": "1.x.x", 54 | "vue-loader": "^13.3.0", 55 | "vue-template-compiler": "^2.5.2", 56 | "webpack": "3.x.x", 57 | "webpack-stream": "3.x.x", 58 | "yargs": "^8.x.x" 59 | }, 60 | "dependencies": { 61 | "babel-preset-es2015": "^6.24.1", 62 | "chrome-storage-wrapper": "^0.1.4", 63 | "css-loader": "^1.0.0", 64 | "jquery": "^3.3.1", 65 | "lodash": "^4.17.10", 66 | "lscache": "^1.1.0", 67 | "url-parse": "^1.4.3", 68 | "vue": "^2.5.2", 69 | "vue-awesome": "^2.3.3", 70 | "wait-until-promise": "^1.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tasks/build.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpSequence from 'gulp-sequence' 3 | 4 | gulp.task('build', gulpSequence( 5 | 'clean', [ 6 | 'manifest', 7 | 'scripts', 8 | 'styles', 9 | 'pages', 10 | 'locales', 11 | 'images', 12 | 'fonts', 13 | 'chromereload' 14 | ] 15 | )) 16 | -------------------------------------------------------------------------------- /tasks/chromereload.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gutil from 'gulp-util' 3 | import livereload from 'gulp-livereload' 4 | import args from './lib/args' 5 | 6 | // In order to make chromereload work you'll need to include 7 | // the following line in your `scipts/background.js` file. 8 | // 9 | // import 'chromereload/devonly'; 10 | // 11 | // This will reload your extension everytime a file changes. 12 | // If you just want to reload a specific context of your extension 13 | // (e.g. `pages/options.html`) include the script in that context 14 | // (e.g. `scripts/options.js`). 15 | // 16 | // Please note that you'll have to restart the gulp task if you 17 | // create new file. We'll fix that when gulp 4 comes out. 18 | 19 | gulp.task('chromereload', (cb) => { 20 | // This task runs only if the 21 | // watch argument is present! 22 | if (!args.watch) return cb() 23 | 24 | // Start livereload server 25 | livereload.listen({ 26 | reloadPage: 'Extension', 27 | quiet: !args.verbose 28 | }) 29 | 30 | gutil.log('Starting', gutil.colors.cyan('\'livereload-server\'')) 31 | 32 | // The watching for javascript files is done by webpack 33 | // Check out ./tasks/scripts.js for further info. 34 | gulp.watch('app/manifest.json', ['manifest']) 35 | gulp.watch('app/styles/**/*.css', ['styles:css']) 36 | gulp.watch('app/styles/**/*.less', ['styles:less']) 37 | gulp.watch('app/styles/**/*.scss', ['styles:sass']) 38 | gulp.watch('app/pages/**/*.html', ['pages']) 39 | gulp.watch('app/_locales/**/*', ['locales']) 40 | gulp.watch('app/images/**/*', ['images']) 41 | gulp.watch('app/fonts/**/*.{woff,ttf,eot,svg}', ['fonts']) 42 | }) 43 | -------------------------------------------------------------------------------- /tasks/clean.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import del from 'del' 3 | import args from './lib/args' 4 | 5 | gulp.task('clean', () => { 6 | return del(`dist/${args.vendor}/**/*`) 7 | }) 8 | -------------------------------------------------------------------------------- /tasks/default.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | 3 | gulp.task('default', ['build']) 4 | -------------------------------------------------------------------------------- /tasks/fonts.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import livereload from 'gulp-livereload' 4 | import args from './lib/args' 5 | 6 | gulp.task('fonts', () => { 7 | return gulp.src('app/fonts/**/*.{woff,woff2,ttf,eot,svg}') 8 | .pipe(gulp.dest(`dist/${args.vendor}/fonts`)) 9 | .pipe(gulpif(args.watch, livereload())) 10 | }) 11 | -------------------------------------------------------------------------------- /tasks/images.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import imagemin from 'gulp-imagemin' 4 | import livereload from 'gulp-livereload' 5 | import args from './lib/args' 6 | 7 | gulp.task('images', () => { 8 | return gulp.src('app/images/**/*') 9 | .pipe(gulpif(args.production, imagemin())) 10 | .pipe(gulp.dest(`dist/${args.vendor}/images`)) 11 | .pipe(gulpif(args.watch, livereload())) 12 | }) 13 | -------------------------------------------------------------------------------- /tasks/lib/applyBrowserPrefixesFor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts and removes keys with a 3 | * browser prefix to the key without prefix 4 | * 5 | * Example: 6 | * 7 | * __chrome__keyName 8 | * __firefox__keyName 9 | * __opera__keyName 10 | * __edge__keyName 11 | * 12 | * to `keyName`. 13 | * This way we can write one manifest thats valid 14 | * for all browsers 15 | * 16 | * @param {Object} manifest 17 | * @return {Object} 18 | */ 19 | export default function applyBrowserPrefixesFor (_vendor) { 20 | vendor = _vendor 21 | return iterator 22 | }; 23 | 24 | /** 25 | * Vendor key 26 | * @type {String} 27 | */ 28 | var vendor = '' 29 | 30 | /** 31 | * Recursive iterator over all object keys 32 | * @param {Object} obj Object to iterate over 33 | * @return {Object} Processed object 34 | */ 35 | function iterator (obj) { 36 | Object.keys(obj).forEach((key) => { 37 | let match = key.match(/^__(chrome|firefox|opera|edge)__(.*)/) 38 | if (match) { 39 | // Swap key with non prefixed name 40 | if (match[1] === vendor) { 41 | obj[match[2]] = obj[key] 42 | } 43 | 44 | // Remove the prefixed key 45 | // so it won't cause warings 46 | delete obj[key] 47 | } else { // no match? try deeper 48 | // Recurse over object's inner keys 49 | if (typeof (obj[key]) === 'object') iterator(obj[key]) 50 | } 51 | }) 52 | return obj 53 | } 54 | -------------------------------------------------------------------------------- /tasks/lib/args.js: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs' 2 | 3 | const args = yargs 4 | 5 | .option('production', { 6 | boolean: true, 7 | default: false, 8 | describe: 'Minify all scripts and assets' 9 | }) 10 | 11 | .option('watch', { 12 | boolean: true, 13 | default: false, 14 | describe: 'Watch all files and start a livereload server' 15 | }) 16 | 17 | .option('verbose', { 18 | boolean: true, 19 | default: false, 20 | describe: 'Log additional data' 21 | }) 22 | 23 | .option('vendor', { 24 | string: true, 25 | default: 'chrome', 26 | describe: 'Compile the extension for different vendors', 27 | choices: ['chrome', 'firefox', 'opera', 'edge'] 28 | }) 29 | 30 | .option('sourcemaps', { 31 | describe: 'Force the creation of sourcemaps' 32 | }) 33 | 34 | .argv 35 | 36 | // Use production flag for sourcemaps 37 | // as a fallback 38 | if (typeof args.sourcemaps === 'undefined') { 39 | args.sourcemaps = !args.production 40 | } 41 | 42 | export default args 43 | -------------------------------------------------------------------------------- /tasks/locales.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import livereload from 'gulp-livereload' 4 | import args from './lib/args' 5 | 6 | gulp.task('locales', () => { 7 | return gulp.src('app/_locales/**/*.json') 8 | .pipe(gulp.dest(`dist/${args.vendor}/_locales`)) 9 | .pipe(gulpif(args.watch, livereload())) 10 | }) 11 | -------------------------------------------------------------------------------- /tasks/manifest.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import { colors, log } from 'gulp-util' 4 | import livereload from 'gulp-livereload' 5 | import jsonTransform from 'gulp-json-transform' 6 | import plumber from 'gulp-plumber' 7 | import applyBrowserPrefixesFor from './lib/applyBrowserPrefixesFor' 8 | import args from './lib/args' 9 | 10 | gulp.task('manifest', () => { 11 | return gulp.src('app/manifest.json') 12 | .pipe(plumber({ 13 | errorHandler: error => { 14 | if (error) { 15 | log('manifest:', colors.red('Invalid manifest.json')) 16 | } 17 | } 18 | })) 19 | .pipe( 20 | jsonTransform( 21 | applyBrowserPrefixesFor(args.vendor), 22 | 2 /* whitespace */ 23 | ) 24 | ) 25 | .pipe(gulp.dest(`dist/${args.vendor}`)) 26 | .pipe(gulpif(args.watch, livereload())) 27 | }) 28 | -------------------------------------------------------------------------------- /tasks/pack.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import { colors, log } from 'gulp-util' 3 | import zip from 'gulp-zip' 4 | import packageDetails from '../package.json' 5 | import args from './lib/args' 6 | 7 | function getPackFileType () { 8 | switch (args.vendor) { 9 | case 'firefox': 10 | return '.xpi' 11 | case 'opera': 12 | return '.crx' 13 | default: 14 | return '.zip' 15 | } 16 | } 17 | 18 | gulp.task('pack', ['build'], () => { 19 | let name = packageDetails.name 20 | let version = packageDetails.version 21 | let filetype = getPackFileType() 22 | let filename = `${name}-${version}-${args.vendor}${filetype}` 23 | return gulp.src(`dist/${args.vendor}/**/*`) 24 | .pipe(zip(filename)) 25 | .pipe(gulp.dest('./packages')) 26 | .on('end', () => { 27 | let distStyled = colors.magenta(`dist/${args.vendor}`) 28 | let filenameStyled = colors.magenta(`./packages/${filename}`) 29 | log(`Packed ${distStyled} to ${filenameStyled}`) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /tasks/pages.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import livereload from 'gulp-livereload' 4 | import args from './lib/args' 5 | 6 | gulp.task('pages', () => { 7 | return gulp.src('app/pages/**/*.html') 8 | .pipe(gulp.dest(`dist/${args.vendor}/pages`)) 9 | .pipe(gulpif(args.watch, livereload())) 10 | }) 11 | -------------------------------------------------------------------------------- /tasks/scripts.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import { log, colors } from 'gulp-util' 4 | import named from 'vinyl-named' 5 | import webpack from 'webpack' 6 | import gulpWebpack from 'webpack-stream' 7 | import plumber from 'gulp-plumber' 8 | import livereload from 'gulp-livereload' 9 | import args from './lib/args' 10 | 11 | const path = require('path') 12 | const ENV = args.production ? 'production' : 'development' 13 | 14 | gulp.task('scripts', (cb) => { 15 | return gulp.src('app/scripts/*.js') 16 | .pipe(plumber({ 17 | // Webpack will log the errors 18 | errorHandler () {} 19 | })) 20 | .pipe(named()) 21 | .pipe(gulpWebpack({ 22 | devtool: args.sourcemaps ? 'inline-source-map' : false, 23 | watch: args.watch, 24 | plugins: [ 25 | new webpack.DefinePlugin({ 26 | 'process.env.NODE_ENV': JSON.stringify(ENV), 27 | 'process.env.VENDOR': JSON.stringify(args.vendor) 28 | }) 29 | ].concat(args.production ? [ 30 | new webpack.optimize.UglifyJsPlugin() 31 | ] : []), 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.js$/, 36 | loader: 'babel-loader', 37 | }, 38 | { 39 | test: /\.vue$/, 40 | loader: 'vue-loader', 41 | options: { 42 | transformToRequire: { 43 | image: 'xlink:href' 44 | } 45 | } 46 | }, 47 | ] 48 | }, 49 | resolve: { 50 | alias: { 51 | helpers: path.resolve(__dirname, 'app/scripts/helpers/'), 52 | mixins: path.resolve(__dirname, 'app/scripts/mixins/') 53 | }, 54 | extensions: ['.js', '.json', '.vue'] 55 | } 56 | }, 57 | webpack, 58 | (err, stats) => { 59 | if (err) return 60 | log(`Finished '${colors.cyan('scripts')}'`, stats.toString({ 61 | chunks: false, 62 | colors: true, 63 | cached: false, 64 | children: false 65 | })) 66 | })) 67 | .pipe(gulp.dest(`dist/${args.vendor}/scripts`)) 68 | .pipe(gulpif(args.watch, livereload())) 69 | }) 70 | -------------------------------------------------------------------------------- /tasks/styles.js: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp' 2 | import gulpif from 'gulp-if' 3 | import gutil from 'gulp-util' 4 | import sourcemaps from 'gulp-sourcemaps' 5 | import less from 'gulp-less' 6 | import sass from 'gulp-sass' 7 | import cleanCSS from 'gulp-clean-css' 8 | import livereload from 'gulp-livereload' 9 | import args from './lib/args' 10 | 11 | gulp.task('styles:css', function () { 12 | return gulp.src('app/styles/*.css') 13 | .pipe(gulpif(args.sourcemaps, sourcemaps.init())) 14 | .pipe(gulpif(args.production, cleanCSS())) 15 | .pipe(gulpif(args.sourcemaps, sourcemaps.write())) 16 | .pipe(gulp.dest(`dist/${args.vendor}/styles`)) 17 | .pipe(gulpif(args.watch, livereload())) 18 | }) 19 | 20 | gulp.task('styles:less', function () { 21 | return gulp.src('app/styles/*.less') 22 | .pipe(gulpif(args.sourcemaps, sourcemaps.init())) 23 | .pipe(less({ paths: ['./app'] }).on('error', function (error) { 24 | gutil.log(gutil.colors.red('Error (' + error.plugin + '): ' + error.message)) 25 | this.emit('end') 26 | })) 27 | .pipe(gulpif(args.production, cleanCSS())) 28 | .pipe(gulpif(args.sourcemaps, sourcemaps.write())) 29 | .pipe(gulp.dest(`dist/${args.vendor}/styles`)) 30 | .pipe(gulpif(args.watch, livereload())) 31 | }) 32 | 33 | gulp.task('styles:sass', function () { 34 | return gulp.src('app/styles/*.scss') 35 | .pipe(gulpif(args.sourcemaps, sourcemaps.init())) 36 | .pipe(sass({ includePaths: ['./app'] }).on('error', function (error) { 37 | gutil.log(gutil.colors.red('Error (' + error.plugin + '): ' + error.message)) 38 | this.emit('end') 39 | })) 40 | .pipe(gulpif(args.production, cleanCSS())) 41 | .pipe(gulpif(args.sourcemaps, sourcemaps.write())) 42 | .pipe(gulp.dest(`dist/${args.vendor}/styles`)) 43 | .pipe(gulpif(args.watch, livereload())) 44 | }) 45 | 46 | gulp.task('styles', [ 47 | 'styles:css', 48 | 'styles:less', 49 | 'styles:sass' 50 | ]) 51 | -------------------------------------------------------------------------------- /tasks/version.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bumping version number and tagging the repository with it. 3 | * Please read http://semver.org/ 4 | * 5 | * You can use the commands 6 | * 7 | * gulp patch # makes v0.1.0 → v0.1.1 8 | * gulp feature # makes v0.1.1 → v0.2.0 9 | * gulp release # makes v0.2.1 → v1.0.0 10 | * 11 | * To bump the version numbers accordingly after you did a patch, 12 | * introduced a feature or made a backwards-incompatible release. 13 | */ 14 | 15 | import gulp from 'gulp' 16 | import git from 'gulp-git' 17 | import bump from 'gulp-bump' 18 | import filter from 'gulp-filter' 19 | import tagVersion from 'gulp-tag-version' 20 | 21 | function inc (importance) { 22 | // get all the files to bump version in 23 | return gulp.src([ 24 | 'package.json', 25 | 'app/manifest.json' 26 | ], { 27 | base: './' 28 | }) 29 | // bump the version number in those files 30 | .pipe(bump({ 31 | type: importance 32 | })) 33 | // save it back to filesystem 34 | .pipe(gulp.dest('./')) 35 | // commit the changed version number 36 | .pipe(git.commit('bump package version')) 37 | // read only one file to get the version number 38 | .pipe(filter('package.json')) 39 | // **tag it in the repository** 40 | .pipe(tagVersion()) 41 | } 42 | 43 | gulp.task('patch', () => { 44 | return inc('patch') 45 | }) 46 | 47 | gulp.task('feature', () => { 48 | return inc('minor') 49 | }) 50 | 51 | gulp.task('release', () => { 52 | return inc('major') 53 | }) 54 | --------------------------------------------------------------------------------