├── .babelrc ├── .envrc.template ├── .eslintrc ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .nycrc ├── .stylelintrc ├── .travis.yml ├── LICENSE ├── README.md ├── _locales ├── en │ └── messages.json ├── ja │ └── messages.json ├── ru │ └── messages.json └── ua │ └── messages.json ├── bebop.png ├── demo.gif ├── icons ├── icon-16.png ├── icon-32.png ├── icon-64.png ├── icon-dark-16.png ├── icon-dark-32.png └── icon-dark-64.png ├── images ├── action.png ├── back.png ├── blank_page.png ├── bookmark.png ├── click.png ├── command.png ├── content.png ├── cookie.png ├── delete.png ├── drag.png ├── forward.png ├── hatebu.png ├── history.png ├── link.png ├── open.png ├── options.png ├── parent.png ├── private.png ├── reload.png ├── root.png ├── search.png ├── session.png ├── tab.png ├── window.png └── zoom.png ├── index.js ├── manifest.json ├── options_ui ├── index.html └── style.css ├── package.json ├── popup ├── index.html ├── normalize.css └── style.css ├── src ├── actions.js ├── background.js ├── candidates.js ├── components │ └── Candidate.jsx ├── config.js ├── containers │ ├── Options.jsx │ └── Popup.jsx ├── content_popup.js ├── content_script.js ├── cursor.js ├── key_sequences.js ├── link.js ├── options_ui.jsx ├── popup.jsx ├── popup_window.js ├── reducers │ ├── options.js │ └── popup.js ├── sagas │ ├── key_sequence.js │ ├── options.js │ └── popup.js ├── sources │ ├── bookmark.js │ ├── command.js │ ├── hatena_bookmark.js │ ├── history.js │ ├── link.js │ ├── search.js │ ├── session.js │ └── tab.js └── utils │ ├── app.js │ ├── args.js │ ├── cookies.js │ ├── hatebu.js │ ├── http.js │ ├── i18n.js │ ├── indexedDB.js │ ├── model.js │ ├── options_migrator.js │ ├── port.js │ ├── sessions.js │ ├── string.js │ ├── tabs.js │ └── url.js ├── test ├── .eslintrc ├── actions.test.js ├── background.test.js ├── browser_mock.js ├── candidates.test.js ├── components │ └── Candidate.test.js ├── containers │ ├── Options.test.js │ └── Popup.test.js ├── content_popup.test.js ├── content_script.test.js ├── create_port.js ├── cursor.test.js ├── key_sequences.test.js ├── link.test.js ├── options_ui.test.js ├── popup.test.js ├── popup_window.test.js ├── sagas │ ├── key_sequence.test.js │ └── popup.test.js ├── setup.js ├── sources │ ├── bookmark.test.js │ ├── history.test.js │ ├── link.test.js │ ├── search.test.js │ └── tab.test.js └── utils │ ├── args.test.js │ ├── cookies.test.js │ ├── hatebu.test.js │ ├── port.test.js │ ├── sessions.test.js │ ├── string.test.js │ ├── tabs.test.js │ └── url.test.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "browsers": ["last 2 versions"] 6 | }, 7 | "useBuiltIns": false, 8 | "exclude": ["transform-regenerator"] 9 | }], 10 | "@babel/preset-react" 11 | ], 12 | "compact": false, 13 | "env": { 14 | "test": { 15 | "plugins": ["istanbul"] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.envrc.template: -------------------------------------------------------------------------------- 1 | export NODE_ENV=development 2 | export IGNORE_FILES="web-ext-artifacts/ src/ test/ coverage/" 3 | export FIREFOX_BINARY=/Applications/FirefoxDeveloperEdition.app/ 4 | export FIREFOX_PROFILE=~/Library/Application\ Support/Firefox/Profiles/xxxxxx.default 5 | export API_KEY=user:00000000 6 | export API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 7 | export EXTENSION_ID=xxxxxxxxxxx 8 | export CLIENT_ID=xxxxxxxxxxxxxxx 9 | export CLIENT_SECRET=xxxxxxxx 10 | export REFRESH_TOKEN=xxxxxxxxx 11 | export VERSION=x.x.x 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "browser": true, 5 | "mocha": true 6 | }, 7 | "rules": { 8 | "no-multi-spaces": ["error", { 9 | "exceptions": { 10 | "VariableDeclarator": true, 11 | "ImportDeclaration": true, 12 | "Property": true, 13 | "AssignmentExpression": true, 14 | } 15 | }], 16 | "key-spacing": ["error", { align: "value" }], 17 | "react/require-extension": ["off", { "extensions": [".jsx", ".js"] }], 18 | "react/destructuring-assignment": ["off", "always", { "ignoreClassFields": true }], 19 | "react/prefer-stateless-function": 0, 20 | "import/no-extraneous-dependencies": ["error", { 21 | "devDependencies": true, 22 | "optionalDependencies": false, 23 | "peerDependencies": false 24 | }] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: build 10 | run: | 11 | npm install 12 | npm run test 13 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: release 13 | env: 14 | EXTENSION_ID: ${{ secrets.EXTENSION_ID }} 15 | API_KEY: ${{ secrets.API_KEY }} 16 | API_SECRET: ${{ secrets.API_SECRET }} 17 | run: | 18 | npm install 19 | npm run release:firefox || true 20 | - uses: actions/upload-artifact@v1 21 | with: 22 | name: web-ext-artifacts 23 | path: web-ext-artifacts/ 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # mac 36 | .DS_Store 37 | 38 | # emacs 39 | *~ 40 | 41 | # webpack output 42 | bundle.js 43 | bundle.js.map 44 | 45 | # web-ext 46 | web-ext-artifacts/ 47 | *.zip 48 | 49 | # nyc 50 | .nyc_output/ 51 | 52 | # env 53 | .envrc 54 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ 3 | "./test/setup.js", 4 | "@babel/register", 5 | "@babel/polyfill" 6 | ], 7 | "include": ["src/**/*.{js,jsx}"], 8 | "exclude": ["test/**/*.test.js"], 9 | "reporter": [ 10 | "text-summary", 11 | "clover", 12 | "html" 13 | ], 14 | "sourceMap": false, 15 | "instrument": false 16 | } -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"] 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '11' 4 | before_install: 5 | - curl -o- -L https://yarnpkg.com/install.sh | bash 6 | - export PATH="$HOME/.yarn/bin:$PATH" 7 | - yarn global add greenkeeper-lockfile@1 8 | before_script: greenkeeper-lockfile-update 9 | after_script: greenkeeper-lockfile-upload 10 | after_success: 11 | - npm run coverage 12 | - npm run build 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Hiroki Kumamoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | bebop 2 | ===== 3 | 4 | [![Build Status](https://travis-ci.org/kumabook/bebop.svg?branch=master)](https://travis-ci.org/kumabook/bebop) 5 | [![Greenkeeper badge](https://badges.greenkeeper.io/kumabook/bebop.svg)](https://greenkeeper.io/) 6 | [![Coverage Status](https://coveralls.io/repos/github/kumabook/bebop/badge.svg?branch=master)](https://coveralls.io/github/kumabook/bebop?branch=master) 7 | [![Maintainability](https://api.codeclimate.com/v1/badges/739ecb729336efef52b8/maintainability)](https://codeclimate.com/github/kumabook/bebop/maintainability) 8 | 9 | 10 | 11 | bebop is a WebExtensions that makes your browsing groovy. 12 | 13 | bebop is available on [Firefox Add-ons][] and [Chrome web store][] 14 | 15 | Enjoy and swing your browsing! 16 | 17 | About 18 | ----- 19 | 20 | bebop is a WebExtensions that offers command line interface like 21 | [emacs helm](https://github.com/emacs-helm/helm) for browsing. 22 | 23 | - bebop retrieves candidates from various sources such as `tabs`, `history`, `bookmark`, etc. 24 | - you can narrows down them with a pattern. 25 | - bebop provides actions on candidates such as `open url`, `activate tab`, `remove bookmark`, etc. 26 | - you can choose a candidate and execute an action on it. 27 | - you can also marks multiple candidates to execute chosen action on this set of candidates. 28 | - ex: close multiple tabs at once. 29 | 30 | ![Demo](./demo.gif) 31 | 32 | 33 | Usage 34 | ----- 35 | 36 | ### 1. Show popup 37 | 38 | `Click icon on toolbar` or `Ctrl+Comma` ... show popup that has command input 39 | 40 | On Vivaldi, `_execute_browser_action` keyboard shortcut doesn't work. 41 | So, bebop offers two alternatives to it. 42 | 43 | | command name | description | 44 | |:---------------------|:-------------------------------- | 45 | | toggle_popup_window | show popup as a window | 46 | | toggle_content_popup | show popup in a content document | 47 | 48 | To use these alternatives, you need to set shortcut key. 49 | See [Customize shortcut](#change-shortcut-key). 50 | 51 | NOTE: `toggle_content_popup` doesn't work completely. 52 | 53 | ex: 54 | 55 | - it doesn't work in some pages 56 | - it can't focus automatically from browser ui. 57 | 58 | ### 2. Narrow down candidates 59 | 60 | Input a query to narrow down the candidates. 61 | 62 | NOTE: On Windows, you need to press a tab-key to focus to a query input 63 | 64 | | type | shorthand | description | 65 | |:------------|:---------:|:------------------------------- | 66 | | search | | open new tab with google search | 67 | | link | l | click a link in current page | 68 | | tab | t | active selected tab | 69 | | history | h | open a history | 70 | | bookmark | b | open a bookmark | 71 | | session | s | restore a session | 72 | | command | c | execute a command | 73 | 74 | - `:type` narrows down to the candidates whose type is the specified type 75 | - `x (shorthand letter)` also narrows down to the candidates whose shorthand is the specified type 76 | - ex. 77 | - `阿部寛` narrows down to the all candidates searched with `阿部寛` 78 | - `:link` or `l` narrow down to link candidates 79 | - `:link 阿部寛` or `l 阿部寛` narrow down to link candidates searched with `阿部寛` 80 | 81 | You can use these key-bindings in command input: 82 | 83 | | key-binding | command | 84 | |:------------|:-------------------- | 85 | | C-f | forward-char | 86 | | C-b | backward-char | 87 | | C-a | beginning-of-line | 88 | | C-e | end-of-line | 89 | | C-h | delete-backward-char | 90 | | C-k | kill-line | 91 | | C-g | quit | 92 | 93 | 94 | ### 3. Select the candidates 95 | 96 | You can change the selected candidate with shortcut keys: 97 | 98 | | key-binding | command | 99 | |:---------------|:-------------------- | 100 | | tab | next-candidate | 101 | | S-tab | previous-candidate | 102 | | C-n (only mac) | next-candidate | 103 | | C-p | previous-candidate | 104 | | C-j (opt-in) | next-candidate | 105 | | C-k (opt-in) | previous-candidate | 106 | | C-SPC | mark-candidate | 107 | 108 | `C-j`, `C-k` are opt-in key-bindings. You can enable them from options page. 109 | 110 | 111 | You can mark multiple candidates with `C-SPC`. 112 | Some action can handle multiple candidates. 113 | For example, `close-tab` command closes multiple tabs. 114 | 115 | 116 | ### 4. Execute action 117 | 118 | A candidate can be executed by various actions. 119 | You can execute default action by pressing `return` or click a candidate. 120 | You can also execute another action by these shortcuts. 121 | 122 | | key-binding | action | 123 | |:------------|:------------------------ | 124 | | return | runs the first action | 125 | | S-return | runs the second action | 126 | | C-i | lists available actions | 127 | 128 | 129 | You can change shortcut key from `Ctrl+Comma`. 130 | See [Customize shortcut](#change-shortcut-key) 131 | 132 | Customize 133 | --------- 134 | 135 | ### Options page 136 | 137 | - `open-options` command opens options page 138 | - Firefox: `Add-ons` -> `Extensions` -> `Preferences` of `bebop` 139 | - Chrome: `Window` -> `Extensions` -> `Options` of `bebop` 140 | 141 | 142 | ### Change shortcut key 143 | 144 | #### Chromium based browser: Extensions page 145 | 146 | 1. Open `chrome://extensions/` 147 | 2. Click `Keyboard shortcuts` at thxe bottom of the page 148 | 3. Set `Activate the extension` as your favorite shortcut key 149 | - Vivaldi user should use `toggle_popup_window` and change `In vivaldi` to `Global`. 150 | 151 | #### Firefox: Change shortcut key with your own private addon 152 | 153 | You can change the shourcut key if you build and upload the addon as your own private addon. 154 | 155 | 1. Clone this repository 156 | 157 | ```sh 158 | git clone https://github.com/kumabook/bebop.git 159 | 160 | ``` 161 | 162 | 2. Edit manifest.json: 163 | 164 | - Edit `commands._execute_browser_action.suggested_key` with your favorite shortcut key. 165 | - Edit `applications.gecko.id` with your id 166 | 167 | 3. Signup [developer hub](https://addons.mozilla.org/en-US/developers/addon/) 168 | 4. Get api key and api secret and set them to environment variables: 169 | 170 | ``` 171 | export NODE_ENV=production 172 | export API_KEY=user:00000000:000 173 | export API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 174 | ``` 175 | 176 | 5. Build and upload package as your private addon: 177 | 178 | ``` 179 | npm run sign 180 | ``` 181 | 182 | [Firefox Add-ons]: https://addons.mozilla.org/ja/firefox/addon/bebop/ 183 | [Chrome web store]: https://chrome.google.com/webstore/detail/bebop/idiejicnogeolaeacihfjleoakggbdid 184 | 185 | Supported browsers 186 | ------------------ 187 | 188 | - Firefox 189 | - Chrome 190 | - Vivaldi 191 | - (Opera) 192 | -------------------------------------------------------------------------------- /_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "commandInput_placeholder": { 3 | "message": "Input query" 4 | }, 5 | "key_info": { 6 | "message": "[return] ... runs the 1st command, [S-return] ... runs the 2nd command, [C-i] ... lists commands" 7 | }, 8 | "search": { 9 | "message": "Search" 10 | }, 11 | "search_placeholder": { 12 | "message": "$QUERY$Search", 13 | "placeholders": { 14 | "query": { 15 | "content": "$1" 16 | } 17 | } 18 | }, 19 | "links": { 20 | "message": "Links" 21 | }, 22 | "tabs": { 23 | "message": "Tabs" 24 | }, 25 | "histories": { 26 | "message": "Histories" 27 | }, 28 | "bookmarks": { 29 | "message": "Bookmarks" 30 | }, 31 | "sessions": { 32 | "message": "Sessions" 33 | }, 34 | "commands": { 35 | "message": "Commands" 36 | }, 37 | "hatena_bookmarks": { 38 | "message": "Hatena Bookmarks" 39 | }, 40 | "hatena_bookmarks_hint": { 41 | "message": "Hatena Bookmarks: Please specify username at options ui" 42 | }, 43 | "hatena_options_hint": { 44 | "message": "Set hatena user name" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /_locales/ja/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "commandInput_placeholder": { 3 | "message": "クエリを入力" 4 | }, 5 | "key_info": { 6 | "message": "[return] ... 最初のコマンドを実行, [S-return] ... 2つ目のコマンドを実行, [C-i] ... コマンド一覧を表示" 7 | }, 8 | "search": { 9 | "message": "検索" 10 | }, 11 | "search_placeholder": { 12 | "message": "$QUERY$検索", 13 | "placeholders": { 14 | "query": { 15 | "content": "$1" 16 | } 17 | } 18 | }, 19 | "links": { 20 | "message": "リンク" 21 | }, 22 | "tabs": { 23 | "message": "タブ" 24 | }, 25 | "histories": { 26 | "message": "履歴" 27 | }, 28 | "bookmarks": { 29 | "message": "ブックマーク" 30 | }, 31 | "sessions": { 32 | "message": "セッション" 33 | }, 34 | "commands": { 35 | "message": "コマンド" 36 | }, 37 | "hatena_bookmarks": { 38 | "message": "はてなブックマーク" 39 | }, 40 | "hatena_bookmarks_hint": { 41 | "message": "はてなブックマーク: オプションページからはてなユーザをセットしてください" 42 | }, 43 | "hatena_options_hint": { 44 | "message": "はてなユーザをセットします" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "commandInput_placeholder": { 3 | "message": "Введите запрос" 4 | }, 5 | "key_info": { 6 | "message": "[return] ... запускает 1ую команду, [S-return] ... запускает 2ую команду, [C-i] ... список команд" 7 | }, 8 | "search": { 9 | "message": "Поиск" 10 | }, 11 | "search_placeholder": { 12 | "message": "$QUERY$Искать", 13 | "placeholders": { 14 | "query": { 15 | "content": "$1" 16 | } 17 | } 18 | }, 19 | "links": { 20 | "message": "Ссылки" 21 | }, 22 | "tabs": { 23 | "message": "Вкладки" 24 | }, 25 | "histories": { 26 | "message": "История" 27 | }, 28 | "bookmarks": { 29 | "message": "Закладки" 30 | }, 31 | "sessions": { 32 | "message": "Сессии" 33 | }, 34 | "commands": { 35 | "message": "Команды" 36 | }, 37 | "hatena_bookmarks": { 38 | "message": "Hatena Закладки" 39 | }, 40 | "hatena_bookmarks_hint": { 41 | "message": "Hatena Закладки: Пожалуйста, укажите имя пользователя в настройках" 42 | }, 43 | "hatena_options_hint": { 44 | "message": "Указать имя пользователя hatena" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /_locales/ua/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "commandInput_placeholder": { 3 | "message": "Введіть запит" 4 | }, 5 | "key_info": { 6 | "message": "[return] ... запускає першу команду, [S-return] ... запускає другу команду, [C-i] ... список команд" 7 | }, 8 | "search": { 9 | "message": "Пошук" 10 | }, 11 | "search_placeholder": { 12 | "message": "$QUERY$Шукати", 13 | "placeholders": { 14 | "query": { 15 | "content": "$1" 16 | } 17 | } 18 | }, 19 | "links": { 20 | "message": "Посилання" 21 | }, 22 | "tabs": { 23 | "message": "Вкладки" 24 | }, 25 | "histories": { 26 | "message": "Історія" 27 | }, 28 | "bookmarks": { 29 | "message": "Закладки" 30 | }, 31 | "sessions": { 32 | "message": "Сесії" 33 | }, 34 | "commands": { 35 | "message": "Команди" 36 | }, 37 | "hatena_bookmarks": { 38 | "message": "Hatena Закладки" 39 | }, 40 | "hatena_bookmarks_hint": { 41 | "message": "Hatena Закладки: Будь ласка, вкажіть ім'я користувача в настройках" 42 | }, 43 | "hatena_options_hint": { 44 | "message": "Вказати ім'я користувача hatena" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bebop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/bebop.png -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/demo.gif -------------------------------------------------------------------------------- /icons/icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-16.png -------------------------------------------------------------------------------- /icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-32.png -------------------------------------------------------------------------------- /icons/icon-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-64.png -------------------------------------------------------------------------------- /icons/icon-dark-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-dark-16.png -------------------------------------------------------------------------------- /icons/icon-dark-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-dark-32.png -------------------------------------------------------------------------------- /icons/icon-dark-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/icons/icon-dark-64.png -------------------------------------------------------------------------------- /images/action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/action.png -------------------------------------------------------------------------------- /images/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/back.png -------------------------------------------------------------------------------- /images/blank_page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/blank_page.png -------------------------------------------------------------------------------- /images/bookmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/bookmark.png -------------------------------------------------------------------------------- /images/click.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/click.png -------------------------------------------------------------------------------- /images/command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/command.png -------------------------------------------------------------------------------- /images/content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/content.png -------------------------------------------------------------------------------- /images/cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/cookie.png -------------------------------------------------------------------------------- /images/delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/delete.png -------------------------------------------------------------------------------- /images/drag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/drag.png -------------------------------------------------------------------------------- /images/forward.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/forward.png -------------------------------------------------------------------------------- /images/hatebu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/hatebu.png -------------------------------------------------------------------------------- /images/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/history.png -------------------------------------------------------------------------------- /images/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/link.png -------------------------------------------------------------------------------- /images/open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/open.png -------------------------------------------------------------------------------- /images/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/options.png -------------------------------------------------------------------------------- /images/parent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/parent.png -------------------------------------------------------------------------------- /images/private.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/private.png -------------------------------------------------------------------------------- /images/reload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/reload.png -------------------------------------------------------------------------------- /images/root.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/root.png -------------------------------------------------------------------------------- /images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/search.png -------------------------------------------------------------------------------- /images/session.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/session.png -------------------------------------------------------------------------------- /images/tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/tab.png -------------------------------------------------------------------------------- /images/window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/window.png -------------------------------------------------------------------------------- /images/zoom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kumabook/bebop/554a0e267c366f5d490d2db8c4ae69cbe42e0ac1/images/zoom.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webExt = require('web-ext').default; 3 | const config = require('./webpack.config'); 4 | 5 | const compiler = webpack(config); 6 | const watching = compiler.watch({ aggregateTimeout: 1000 }, (err) => { 7 | /* eslint-disable no-console */ 8 | if (err) { 9 | console.log('\nFailed to webpack build'); 10 | } else { 11 | console.log('\nweback built'); 12 | } 13 | }); 14 | 15 | webExt.cmd.run({ 16 | firefox: process.env.FIREFOX_BINARY, 17 | sourceDir: process.cwd(), 18 | ignoreFiles: process.env.IGNORE_FILES.split(' ').concat('**/*~'), 19 | browserConsole: true, 20 | firefoxProfile: process.env.FIREFOX_PROFILE, 21 | }, { 22 | shouldExitProgram: false, 23 | }).then(runner => runner.registerCleanup(() => watching.close())); 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bebop", 3 | "version": "0.2.2", 4 | "manifest_version": 2, 5 | "description": "Offer emacs key-bindings and command line interface like helm", 6 | "author": "Hiroki Kumamoto", 7 | "applications": { 8 | "gecko": { 9 | "id": "bebop@kumabook", 10 | "strict_min_version": "45.0" 11 | } 12 | }, 13 | "icons": { 14 | "16": "icons/icon-16.png", 15 | "32": "icons/icon-32.png", 16 | "64": "icons/icon-64.png" 17 | }, 18 | "permissions": [ 19 | "tabs", 20 | "activeTab", 21 | "history", 22 | "bookmarks", 23 | "storage", 24 | "cookies", 25 | "sessions", 26 | "system.display", 27 | "search", 28 | "" 29 | ], 30 | "background": { 31 | "scripts": ["background/bundle.js"] 32 | }, 33 | "default_locale": "en", 34 | "content_scripts": [ 35 | { 36 | "matches": [""], 37 | "js": ["content_scripts/bundle.js"], 38 | "run_at": "document_start", 39 | "match_about_blank": true 40 | } 41 | ], 42 | "browser_action": { 43 | "browser_style": true, 44 | "default_icon": { 45 | "16": "icons/icon-16.png", 46 | "32": "icons/icon-32.png", 47 | "64": "icons/icon-64.png" 48 | }, 49 | "theme_icons": [{ 50 | "light": "icons/icon-dark-16.png", 51 | "dark": "icons/icon-16.png", 52 | "size": 16 53 | }, { 54 | "light": "icons/icon-dark-32.png", 55 | "dark": "icons/icon-32.png", 56 | "size": 32 57 | }, { 58 | "light": "icons/icon-dark-64.png", 59 | "dark": "icons/icon-64.png", 60 | "size": 64 61 | }], 62 | "default_title": "button label", 63 | "default_popup": "popup/index.html" 64 | }, 65 | "commands": { 66 | "_execute_browser_action": { 67 | "suggested_key": { 68 | "default": "Ctrl+Comma", 69 | "mac" : "MacCtrl+Comma", 70 | "linux" : "Ctrl+Comma" 71 | } 72 | }, 73 | "toggle_popup_window": { 74 | "description": "Toggle popup window" 75 | }, 76 | "toggle_content_popup": { 77 | "description": "Toggle popup in current content" 78 | } 79 | }, 80 | "options_ui": { 81 | "page": "options_ui/index.html" 82 | }, 83 | "web_accessible_resources": ["images/*.png", "popup/*"] 84 | } 85 | -------------------------------------------------------------------------------- /options_ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | menus 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /options_ui/style.css: -------------------------------------------------------------------------------- 1 | .options { 2 | display: absolute; 3 | height: 100px; 4 | width: 100%; 5 | } 6 | 7 | .optionsTitle { 8 | font-size: 2em; 9 | } 10 | 11 | .optionsLabel { 12 | font-size: 1.4em; 13 | } 14 | 15 | .optionsDescription { 16 | margin-left: 40px; 17 | } 18 | 19 | .optionsValue { 20 | margin: 8px; 21 | } 22 | 23 | .optionsValueInput { 24 | display: inline-block; 25 | margin-left: 40px; 26 | width: 60px; 27 | } 28 | 29 | .optionsValueTextInput { 30 | display: inline-block; 31 | margin-left: 40px; 32 | width: 150px; 33 | } 34 | 35 | .sortableList { 36 | list-style-type: none; 37 | } 38 | 39 | .sortableListItem { 40 | display: block; 41 | padding: 6px; 42 | margin: 2px; 43 | width: 200px; 44 | list-style-type: none; 45 | border: solid 1px; 46 | vertical-align: middle; 47 | } 48 | 49 | .dragIcon { 50 | width: 18px; 51 | height: 18px; 52 | vertical-align: middle; 53 | margin-right: 4px; 54 | pointer-events: none; 55 | } 56 | 57 | .maxResultsInputLabel { 58 | display: inline-block; 59 | margin-left: 40px; 60 | width: 200px; 61 | } 62 | 63 | .maxResultsInput { 64 | width: 40px; 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bebop", 3 | "version": "0.2.2", 4 | "main": "index.js", 5 | "repository": "https://github.com/kumabook/bebop", 6 | "author": "Hiroki Kumamoto ", 7 | "license": "MIT", 8 | "scripts": { 9 | "build": "webpack && web-ext build --overwrite-dest -i web-ext-artifacts/ -i test/ -i coverage/ -i \"**/*~\"", 10 | "build:release": "cross-env NODE_ENV=production npm run build", 11 | "start": "node index.js", 12 | "sign": "cross-env NODE_ENV=production web-ext sign -i web-ext-artifacts/ -i test/ -i coverage/ -i \"**/*~\" --api-key $API_KEY --api-secret $API_SECRET", 13 | "webstore": "webstore upload --source web-ext-artifacts/bebop-$VERSION.zip --extension-id $EXTENSION_ID --client-id $CLIENT_ID --client-secret $CLIENT_SECRET --refresh-token $REFRESH_TOKEN --auto-publish", 14 | "release:firefox": "npm run build:release && npm run sign", 15 | "release:chrome": "npm run build:release && npm run webstore", 16 | "pack": "git archive HEAD --output=bebop.zip", 17 | "watch": "webpack --watch", 18 | "lint": "npm run eslint && npm run stylelint", 19 | "eslint": "eslint \"src/**/*.js?(x)\" \"test/**/*.{js,jsx}\"", 20 | "eslint:fix": "eslint \"src/**/*.js?(x)\" \"test/**/*.{js,jsx}\" --fix", 21 | "stylelint": "stylelint \"./{popup,options_ui}/*.css\" --ignore-pattern normalize.css --fix", 22 | "pretest": "npm run lint", 23 | "test": "cross-env NODE_ENV=test nyc ava \"test/**/*.test.{js,jsx}\"", 24 | "test:watch": "npm test -- --watch", 25 | "coverage": "cross-env NODE_ENV=test nyc report --reporter=text-lcov | coveralls" 26 | }, 27 | "devDependencies": { 28 | "ava": "1.4.0", 29 | "chrome-webstore-upload-cli": "^1.1.1", 30 | "coveralls": "^3.0.0", 31 | "webpack-cli": "^3.1.2" 32 | }, 33 | "permissions": { 34 | "multiprocess": true 35 | }, 36 | "dependencies": { 37 | "@babel/core": "^7.0.0-beta.47", 38 | "@babel/polyfill": "^7.0.0-beta.47", 39 | "@babel/preset-env": "^7.0.0-beta.47", 40 | "@babel/preset-react": "^7.0.0-beta.47", 41 | "@babel/register": "^7.0.0-beta.47", 42 | "babel-loader": "^8.0.0-beta", 43 | "babel-plugin-istanbul": "^5.1.0", 44 | "connected-react-router": "^6.1.0", 45 | "cross-env": "^5.1.1", 46 | "enzyme": "^3.2.0", 47 | "enzyme-adapter-react-16": "^1.1.0", 48 | "eslint": "^5.15.1", 49 | "eslint-config-airbnb": "^17.1.0", 50 | "eslint-plugin-import": "^2.16.0", 51 | "eslint-plugin-jsx-a11y": "^6.2.1", 52 | "eslint-plugin-react": "^7.12.4", 53 | "fake-indexeddb": "^2.0.4", 54 | "history": "^4.7.2", 55 | "is-url": "^1.2.2", 56 | "jsdom": "^15.0.0", 57 | "kiroku": "^0.0.4", 58 | "nisemono": "^0.0.3", 59 | "nyc": "^13.1.0", 60 | "prop-types": "^15.6.0", 61 | "raf": "^3.4.0", 62 | "react": "^16.0.0", 63 | "react-dom": "^16.0.0", 64 | "react-redux": "^7.0.0", 65 | "react-router-dom": "^5.0.0", 66 | "react-sortable-hoc": "^1.8.3", 67 | "react-treeview": "^0.4.7", 68 | "redux": "^4.0.1", 69 | "redux-saga": "^0.16.0", 70 | "redux-saga-router": "^2.1.1", 71 | "stylelint": "^10.0.0", 72 | "stylelint-config-standard": "^18.0.0", 73 | "uuid": "^3.1.0", 74 | "web-ext": "^3.0.0", 75 | "webextension-polyfill": "kumabook/webextension-polyfill", 76 | "webpack": "^4.8.3" 77 | }, 78 | "ava": { 79 | "babel": { 80 | "testOptions": {} 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | menus 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /popup/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in 9 | * IE on Windows Phone and in iOS. 10 | */ 11 | 12 | html { 13 | line-height: 1.15; /* 1 */ 14 | -ms-text-size-adjust: 100%; /* 2 */ 15 | -webkit-text-size-adjust: 100%; /* 2 */ 16 | } 17 | 18 | /* Sections 19 | ========================================================================== */ 20 | 21 | /** 22 | * Remove the margin in all browsers (opinionated). 23 | */ 24 | 25 | body { 26 | margin: 0; 27 | } 28 | 29 | /** 30 | * Add the correct display in IE 9-. 31 | */ 32 | 33 | article, 34 | aside, 35 | footer, 36 | header, 37 | nav, 38 | section { 39 | display: block; 40 | } 41 | 42 | /** 43 | * Correct the font size and margin on `h1` elements within `section` and 44 | * `article` contexts in Chrome, Firefox, and Safari. 45 | */ 46 | 47 | h1 { 48 | font-size: 2em; 49 | margin: 0.67em 0; 50 | } 51 | 52 | /* Grouping content 53 | ========================================================================== */ 54 | 55 | /** 56 | * Add the correct display in IE 9-. 57 | * 1. Add the correct display in IE. 58 | */ 59 | 60 | figcaption, 61 | figure, 62 | main { /* 1 */ 63 | display: block; 64 | } 65 | 66 | /** 67 | * Add the correct margin in IE 8. 68 | */ 69 | 70 | figure { 71 | margin: 1em 40px; 72 | } 73 | 74 | /** 75 | * 1. Add the correct box sizing in Firefox. 76 | * 2. Show the overflow in Edge and IE. 77 | */ 78 | 79 | hr { 80 | box-sizing: content-box; /* 1 */ 81 | height: 0; /* 1 */ 82 | overflow: visible; /* 2 */ 83 | } 84 | 85 | /** 86 | * 1. Correct the inheritance and scaling of font size in all browsers. 87 | * 2. Correct the odd `em` font sizing in all browsers. 88 | */ 89 | 90 | pre { 91 | font-family: monospace, monospace; /* 1 */ 92 | font-size: 1em; /* 2 */ 93 | } 94 | 95 | /* Text-level semantics 96 | ========================================================================== */ 97 | 98 | /** 99 | * 1. Remove the gray background on active links in IE 10. 100 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+. 101 | */ 102 | 103 | a { 104 | background-color: transparent; /* 1 */ 105 | -webkit-text-decoration-skip: objects; /* 2 */ 106 | } 107 | 108 | /** 109 | * 1. Remove the bottom border in Chrome 57- and Firefox 39-. 110 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 111 | */ 112 | 113 | abbr[title] { 114 | border-bottom: none; /* 1 */ 115 | text-decoration: underline; /* 2 */ 116 | text-decoration: underline dotted; /* 2 */ 117 | } 118 | 119 | /** 120 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6. 121 | */ 122 | 123 | b, 124 | strong { 125 | font-weight: inherit; 126 | } 127 | 128 | /** 129 | * Add the correct font weight in Chrome, Edge, and Safari. 130 | */ 131 | 132 | b, 133 | strong { 134 | font-weight: bolder; 135 | } 136 | 137 | /** 138 | * 1. Correct the inheritance and scaling of font size in all browsers. 139 | * 2. Correct the odd `em` font sizing in all browsers. 140 | */ 141 | 142 | code, 143 | kbd, 144 | samp { 145 | font-family: monospace, monospace; /* 1 */ 146 | font-size: 1em; /* 2 */ 147 | } 148 | 149 | /** 150 | * Add the correct font style in Android 4.3-. 151 | */ 152 | 153 | dfn { 154 | font-style: italic; 155 | } 156 | 157 | /** 158 | * Add the correct background and color in IE 9-. 159 | */ 160 | 161 | mark { 162 | background-color: #ff0; 163 | color: #000; 164 | } 165 | 166 | /** 167 | * Add the correct font size in all browsers. 168 | */ 169 | 170 | small { 171 | font-size: 80%; 172 | } 173 | 174 | /** 175 | * Prevent `sub` and `sup` elements from affecting the line height in 176 | * all browsers. 177 | */ 178 | 179 | sub, 180 | sup { 181 | font-size: 75%; 182 | line-height: 0; 183 | position: relative; 184 | vertical-align: baseline; 185 | } 186 | 187 | sub { 188 | bottom: -0.25em; 189 | } 190 | 191 | sup { 192 | top: -0.5em; 193 | } 194 | 195 | /* Embedded content 196 | ========================================================================== */ 197 | 198 | /** 199 | * Add the correct display in IE 9-. 200 | */ 201 | 202 | audio, 203 | video { 204 | display: inline-block; 205 | } 206 | 207 | /** 208 | * Add the correct display in iOS 4-7. 209 | */ 210 | 211 | audio:not([controls]) { 212 | display: none; 213 | height: 0; 214 | } 215 | 216 | /** 217 | * Remove the border on images inside links in IE 10-. 218 | */ 219 | 220 | img { 221 | border-style: none; 222 | } 223 | 224 | /** 225 | * Hide the overflow in IE. 226 | */ 227 | 228 | svg:not(:root) { 229 | overflow: hidden; 230 | } 231 | 232 | /* Forms 233 | ========================================================================== */ 234 | 235 | /** 236 | * 1. Change the font styles in all browsers (opinionated). 237 | * 2. Remove the margin in Firefox and Safari. 238 | */ 239 | 240 | button, 241 | input, 242 | optgroup, 243 | select, 244 | textarea { 245 | font-family: sans-serif; /* 1 */ 246 | font-size: 100%; /* 1 */ 247 | line-height: 1.15; /* 1 */ 248 | margin: 0; /* 2 */ 249 | } 250 | 251 | /** 252 | * Show the overflow in IE. 253 | * 1. Show the overflow in Edge. 254 | */ 255 | 256 | button, 257 | input { /* 1 */ 258 | overflow: visible; 259 | } 260 | 261 | /** 262 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 263 | * 1. Remove the inheritance of text transform in Firefox. 264 | */ 265 | 266 | button, 267 | select { /* 1 */ 268 | text-transform: none; 269 | } 270 | 271 | /** 272 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video` 273 | * controls in Android 4. 274 | * 2. Correct the inability to style clickable types in iOS and Safari. 275 | */ 276 | 277 | button, 278 | html [type="button"], /* 1 */ 279 | [type="reset"], 280 | [type="submit"] { 281 | -webkit-appearance: button; /* 2 */ 282 | } 283 | 284 | /** 285 | * Remove the inner border and padding in Firefox. 286 | */ 287 | 288 | button::-moz-focus-inner, 289 | [type="button"]::-moz-focus-inner, 290 | [type="reset"]::-moz-focus-inner, 291 | [type="submit"]::-moz-focus-inner { 292 | border-style: none; 293 | padding: 0; 294 | } 295 | 296 | /** 297 | * Restore the focus styles unset by the previous rule. 298 | */ 299 | 300 | button:-moz-focusring, 301 | [type="button"]:-moz-focusring, 302 | [type="reset"]:-moz-focusring, 303 | [type="submit"]:-moz-focusring { 304 | outline: 1px dotted ButtonText; 305 | } 306 | 307 | /** 308 | * Correct the padding in Firefox. 309 | */ 310 | 311 | fieldset { 312 | padding: 0.35em 0.75em 0.625em; 313 | } 314 | 315 | /** 316 | * 1. Correct the text wrapping in Edge and IE. 317 | * 2. Correct the color inheritance from `fieldset` elements in IE. 318 | * 3. Remove the padding so developers are not caught out when they zero out 319 | * `fieldset` elements in all browsers. 320 | */ 321 | 322 | legend { 323 | box-sizing: border-box; /* 1 */ 324 | color: inherit; /* 2 */ 325 | display: table; /* 1 */ 326 | max-width: 100%; /* 1 */ 327 | padding: 0; /* 3 */ 328 | white-space: normal; /* 1 */ 329 | } 330 | 331 | /** 332 | * 1. Add the correct display in IE 9-. 333 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera. 334 | */ 335 | 336 | progress { 337 | display: inline-block; /* 1 */ 338 | vertical-align: baseline; /* 2 */ 339 | } 340 | 341 | /** 342 | * Remove the default vertical scrollbar in IE. 343 | */ 344 | 345 | textarea { 346 | overflow: auto; 347 | } 348 | 349 | /** 350 | * 1. Add the correct box sizing in IE 10-. 351 | * 2. Remove the padding in IE 10-. 352 | */ 353 | 354 | [type="checkbox"], 355 | [type="radio"] { 356 | box-sizing: border-box; /* 1 */ 357 | padding: 0; /* 2 */ 358 | } 359 | 360 | /** 361 | * Correct the cursor style of increment and decrement buttons in Chrome. 362 | */ 363 | 364 | [type="number"]::-webkit-inner-spin-button, 365 | [type="number"]::-webkit-outer-spin-button { 366 | height: auto; 367 | } 368 | 369 | /** 370 | * 1. Correct the odd appearance in Chrome and Safari. 371 | * 2. Correct the outline style in Safari. 372 | */ 373 | 374 | [type="search"] { 375 | -webkit-appearance: textfield; /* 1 */ 376 | outline-offset: -2px; /* 2 */ 377 | } 378 | 379 | /** 380 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS. 381 | */ 382 | 383 | [type="search"]::-webkit-search-cancel-button, 384 | [type="search"]::-webkit-search-decoration { 385 | -webkit-appearance: none; 386 | } 387 | 388 | /** 389 | * 1. Correct the inability to style clickable types in iOS and Safari. 390 | * 2. Change font properties to `inherit` in Safari. 391 | */ 392 | 393 | ::-webkit-file-upload-button { 394 | -webkit-appearance: button; /* 1 */ 395 | font: inherit; /* 2 */ 396 | } 397 | 398 | /* Interactive 399 | ========================================================================== */ 400 | 401 | /* 402 | * Add the correct display in IE 9-. 403 | * 1. Add the correct display in Edge, IE, and Firefox. 404 | */ 405 | 406 | details, /* 1 */ 407 | menu { 408 | display: block; 409 | } 410 | 411 | /* 412 | * Add the correct display in all browsers. 413 | */ 414 | 415 | summary { 416 | display: list-item; 417 | } 418 | 419 | /* Scripting 420 | ========================================================================== */ 421 | 422 | /** 423 | * Add the correct display in IE 9-. 424 | */ 425 | 426 | canvas { 427 | display: inline-block; 428 | } 429 | 430 | /** 431 | * Add the correct display in IE. 432 | */ 433 | 434 | template { 435 | display: none; 436 | } 437 | 438 | /* Hidden 439 | ========================================================================== */ 440 | 441 | /** 442 | * Add the correct display in IE 10-. 443 | */ 444 | 445 | [hidden] { 446 | display: none; 447 | } 448 | -------------------------------------------------------------------------------- /popup/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --command-input-border: 2px solid #fc6; 3 | --scroll-bar-thumb-color: #d8d8d8; 4 | --scroll-bar-background-color: #eee; 5 | --background-color: #fff; 6 | --text-color: #000; 7 | --text-color-selected: #000; 8 | --text-color-marked: #000; 9 | --footer-background-color: #dda0dd; 10 | --footer-text-color: #000; 11 | --candidate-text-color-hover: #000; 12 | --candidate-background-color-hover: #d8d8d8; 13 | --candidate-background-color-selected: #87cefa; 14 | --candidate-background-color-selected-hover: #6ca5c8; 15 | --candidate-background-color-marked: #00bfff; 16 | --candidate-background-color-marked-hover: #09c; 17 | --candidate-background-color-selected-marked: #00a2d9; 18 | --candidate-background-color-selected-marked-hover: #007ca6; 19 | --separator-background-color: #fc6; 20 | --separator-text-color: #000; 21 | --image-invert-percent: 0%; 22 | --image-hover-invert-percent: 0%; 23 | --image-marked-invert-percent: 0%; 24 | --ext-image-invert-percent: 0%; 25 | --ext-image-invert-percent-selected: 0%; 26 | } 27 | 28 | [data-theme="simple-dark"] { 29 | --command-input-border: 1px solid #00adee; 30 | --scroll-bar-thumb-color: #535353; 31 | --scroll-bar-background-color: #282828; 32 | --background-color: #353535; 33 | --text-color: #cecece; 34 | --text-color-selected: #ffe6b0; 35 | --text-color-marked: #353535; 36 | --footer-background-color: #383838; 37 | --footer-text-color: #769262; 38 | --candidate-text-color-hover: #353535; 39 | --candidate-background-color-hover: #d8d8d8; 40 | --candidate-background-color-selected: #2b2b2b; 41 | --candidate-background-color-selected-hover: #2b2b2b; 42 | --candidate-background-color-marked: #00bfff; 43 | --candidate-background-color-marked-hover: #09c; 44 | --candidate-background-color-selected-marked: #00a2d9; 45 | --candidate-background-color-selected-marked-hover: #007ca6; 46 | --separator-background-color: #282828; 47 | --separator-text-color: #00adee; 48 | --image-invert-percent: 30%; 49 | --image-hover-invert-percent: 0%; 50 | --image-marked-invert-percent: 0%; 51 | --ext-image-invert-percent: 60%; 52 | --ext-image-invert-percent-selected: 100%; 53 | } 54 | 55 | @media screen and (-webkit-min-device-pixel-ratio: 0) { 56 | /* only chrome */ 57 | body { 58 | height: 500px; 59 | width: 700px; 60 | } 61 | } 62 | 63 | body, 64 | #container, 65 | *[data-theme] { 66 | color: var(--text-color); 67 | background-color: var(--background-color); 68 | } 69 | 70 | *[data-theme] { 71 | /* firefox only */ 72 | scrollbar-width: thin; 73 | scrollbar-color: var(--scroll-bar-thumb-color) var(--scroll-bar-background-color); 74 | } 75 | 76 | *[data-theme]::-webkit-scrollbar { 77 | width: 5px; 78 | height: 8px; 79 | background-color: var(--scroll-bar-background-color); 80 | } 81 | 82 | /* Add a thumb */ 83 | *[data-theme]::-webkit-scrollbar-thumb { 84 | background: var(--scroll-bar-thumb-color); 85 | } 86 | 87 | .commandForm { 88 | margin: 0; 89 | width: 100%; 90 | height: 100%; 91 | overflow: visible; 92 | } 93 | 94 | .commandInput { 95 | position: fixed; 96 | margin: 0; 97 | width: 100%; 98 | padding: 8px; 99 | top: 0; 100 | height: 36px; 101 | box-sizing: border-box; 102 | z-index: 100; 103 | color: var(--text-color); 104 | background-color: var(--background-color); 105 | border: var(--command-input-border); 106 | } 107 | 108 | .footer { 109 | position: fixed; 110 | padding: 0 8px; 111 | bottom: 0; 112 | height: 18px; 113 | width: 100%; 114 | box-sizing: border-box; 115 | font-size: small; 116 | background-color: var(--footer-background-color); 117 | color: var(--footer-text-color); 118 | text-overflow: ellipsis; 119 | white-space: nowrap; 120 | overflow: hidden; 121 | } 122 | 123 | .candidatesList { 124 | list-style-type: none; 125 | margin: 0; 126 | padding: 36px 0 18px 0; 127 | background-color: var(--background-color); 128 | color: var(--text-color); 129 | } 130 | 131 | .candidatesList-no-footer { 132 | list-style-type: none; 133 | margin: 0; 134 | padding: 36px 0 0; 135 | } 136 | 137 | .candidate { 138 | display: block; 139 | padding: 8px; 140 | text-overflow: ellipsis; 141 | white-space: nowrap; 142 | overflow: hidden; 143 | } 144 | 145 | .candidate:hover { 146 | color: var(--candidate-text-color-hover); 147 | background-color: var(--candidate-background-color-hover); 148 | } 149 | 150 | .candidate.selected { 151 | color: var(--text-color-selected); 152 | background-color: var(--candidate-background-color-selected); 153 | } 154 | 155 | .candidate.selected:hover { 156 | background-color: var(--candidate-background-color-selected-hover); 157 | } 158 | 159 | .candidate.marked { 160 | color: var(--text-color-marked); 161 | background-color: var(--candidate-background-color-marked); 162 | } 163 | 164 | .candidate.marked:hover { 165 | background-color: var(--candidate-background-color-marked-hover); 166 | } 167 | 168 | .candidate.selected.marked { 169 | background-color: var(--candidate-background-color-selected-marked); 170 | } 171 | 172 | .candidate.selected.marked:hover { 173 | background-color: var(--candidate-background-color-selected-marked-hover); 174 | } 175 | 176 | .candidate-label { 177 | display: inline; 178 | margin: 8px; 179 | margin-left: 0; 180 | width: 100%; 181 | vertical-align: middle; 182 | white-space: nowrap; 183 | overflow: hidden; 184 | } 185 | 186 | .candidate-icon { 187 | display: inline; 188 | vertical-align: middle; 189 | width: 16px; 190 | height: 16px; 191 | margin-right: 4px; 192 | padding: 0; 193 | } 194 | 195 | .candidate .candidate-icon { 196 | filter: invert(var(--image-invert-percent)); 197 | } 198 | 199 | .candidate .candidate-icon.candidate-icon-ext { 200 | filter: invert(var(--ext-image-invert-percent)); 201 | } 202 | 203 | .candidate:hover .candidate-icon.candidate-icon-ext { 204 | filter: invert(var(--image-hover-invert-percent)); 205 | } 206 | 207 | .candidate.marked .candidate-icon.candidate-icon-ext { 208 | filter: invert(var(--image-marked-invert-percent)); 209 | } 210 | 211 | .candidate.selected .candidate-icon.candidate-icon-ext { 212 | filter: invert(var(--ext-image-invert-percent-selected)); 213 | } 214 | 215 | .candidate-icon-dummy { 216 | display: inline-block; 217 | vertical-align: middle; 218 | width: 16px; 219 | height: 16px; 220 | margin-right: 4px; 221 | padding: 0; 222 | } 223 | 224 | .separator { 225 | padding: 1px 8px; 226 | font-size: small; 227 | background-color: var(--separator-background-color); 228 | color: var(--separator-text-color); 229 | text-overflow: ellipsis; 230 | white-space: nowrap; 231 | overflow: hidden; 232 | } 233 | -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import logger from 'kiroku'; 3 | import search, { init as candidateInit } from './candidates'; 4 | import { init as actionInit, find as findAction } from './actions'; 5 | import { 6 | toggle as togglePopupWindow, 7 | onWindowFocusChanged, 8 | onTabActivated, 9 | onTabRemoved, 10 | } from './popup_window'; 11 | import { getActiveContentTab } from './utils/tabs'; 12 | import idb from './utils/indexedDB'; 13 | import { getArgListener, setPostMessageFunction } from './utils/args'; 14 | import { 15 | createObjectStore, 16 | needDownload, 17 | downloadBookmarks, 18 | } from './utils/hatebu'; 19 | import migrateOptions from './utils/options_migrator'; 20 | import config from './config'; 21 | 22 | let contentScriptPorts = {}; 23 | let popupPorts = {}; 24 | 25 | if (process.env.NODE_ENV === 'production') { 26 | logger.setLevel('FATAL'); 27 | } 28 | logger.info(`bebop starts initialization. log level ${logger.getLevel()}`); 29 | 30 | export function getContentScriptPorts() { 31 | return Object.values(contentScriptPorts); 32 | } 33 | 34 | export function getPopupPorts() { 35 | return Object.values(popupPorts); 36 | } 37 | 38 | function postMessageToContentScript(type, payload) { 39 | const currentWindow = true; 40 | const active = true; 41 | return browser.tabs.query({ currentWindow, active }).then((tabs) => { 42 | if (tabs.length > 0) { 43 | const targetUrl = tabs[0].url; 44 | getContentScriptPorts().forEach(p => p.postMessage({ 45 | type, 46 | payload, 47 | targetUrl, 48 | })); 49 | } 50 | }); 51 | } 52 | 53 | export function postMessageToPopup(type, payload) { 54 | getPopupPorts().forEach(p => p.postMessage({ 55 | type, 56 | payload, 57 | })); 58 | } 59 | 60 | export function executeAction(actionId, candidates) { 61 | const action = findAction(actionId); 62 | if (action && action.handler) { 63 | const f = action.handler; 64 | return f.call(this, candidates); 65 | } 66 | return Promise.resolve(); 67 | } 68 | 69 | function toggleContentPopup() { 70 | const msg = { type: 'TOGGLE_POPUP' }; 71 | return getActiveContentTab().then(t => browser.tabs.sendMessage(t.id, msg)); 72 | } 73 | 74 | function handleContentScriptMessage() {} 75 | 76 | function connectListener(port) { 77 | const { name } = port; 78 | logger.info(`connect channel: ${name}`); 79 | if (name.startsWith('content-script')) { 80 | contentScriptPorts[name] = port; 81 | port.onDisconnect.addListener(() => { 82 | delete contentScriptPorts[name]; 83 | port.onMessage.removeListener(handleContentScriptMessage); 84 | }); 85 | port.onMessage.addListener(handleContentScriptMessage); 86 | } else if (name.startsWith('popup')) { 87 | popupPorts[name] = port; 88 | port.onDisconnect.addListener(() => { 89 | delete popupPorts[name]; 90 | postMessageToContentScript('POPUP_CLOSE'); 91 | }); 92 | } 93 | logger.info(`There are ${Object.values(contentScriptPorts).length} channel`); 94 | } 95 | 96 | function activatedListener(payload) { 97 | getPopupPorts().forEach(p => p.postMessage({ 98 | type: 'TAB_CHANGED', 99 | payload, 100 | })); 101 | setTimeout(() => onTabActivated(payload), 10); 102 | } 103 | 104 | function downloadHatebu(userName) { 105 | try { 106 | if (needDownload()) { 107 | downloadBookmarks(userName); 108 | } 109 | } catch (e) { 110 | logger.trace(e); 111 | } 112 | } 113 | 114 | export function messageListener(request) { 115 | switch (request.type) { 116 | case 'SEND_MESSAGE_TO_ACTIVE_CONTENT_TAB': { 117 | return getActiveContentTab().then((tab) => { 118 | if (tab.url.startsWith('chrome://')) { 119 | return Promise.resolve(); 120 | } 121 | return browser.tabs.sendMessage(tab.id, request.payload); 122 | }); 123 | } 124 | case 'SEARCH_CANDIDATES': { 125 | const query = request.payload; 126 | return search(query); 127 | } 128 | case 'EXECUTE_ACTION': { 129 | const { actionId, candidates } = request.payload; 130 | return executeAction(actionId, candidates); 131 | } 132 | case 'RESPONSE_ARG': { 133 | const listener = getArgListener(); 134 | listener(request.payload); 135 | break; 136 | } 137 | case 'DOWNLOAD_HATEBU': { 138 | downloadHatebu(request.payload); 139 | break; 140 | } 141 | default: 142 | break; 143 | } 144 | return null; 145 | } 146 | 147 | export function commandListener(command) { 148 | switch (command) { 149 | case 'toggle_popup_window': 150 | togglePopupWindow(); 151 | break; 152 | case 'toggle_content_popup': 153 | toggleContentPopup(); 154 | break; 155 | default: 156 | break; 157 | } 158 | } 159 | 160 | async function loadOptions() { 161 | const state = await browser.storage.local.get(); 162 | migrateOptions(state); 163 | candidateInit(state); 164 | actionInit(); 165 | } 166 | 167 | export async function storageChangedListener() { 168 | await loadOptions(); 169 | } 170 | 171 | export async function init() { 172 | setPostMessageFunction(postMessageToPopup); 173 | contentScriptPorts = {}; 174 | popupPorts = {}; 175 | 176 | await loadOptions(); 177 | try { 178 | await idb.upgrade(config.dbName, config.dbVersion, db => createObjectStore(db)); 179 | logger.info('create indexedDB'); 180 | } catch (e) { 181 | logger.info(e); 182 | } 183 | 184 | browser.windows.onFocusChanged.addListener(onWindowFocusChanged); 185 | browser.runtime.onConnect.addListener(connectListener); 186 | browser.tabs.onActivated.addListener(activatedListener); 187 | browser.tabs.onRemoved.addListener(onTabRemoved); 188 | browser.runtime.onMessage.addListener(messageListener); 189 | browser.commands.onCommand.addListener(commandListener); 190 | browser.storage.onChanged.addListener(storageChangedListener); 191 | 192 | logger.info('bebop is initialized.'); 193 | } 194 | 195 | init(); 196 | -------------------------------------------------------------------------------- /src/candidates.js: -------------------------------------------------------------------------------- 1 | import searchCandidates from './sources/search'; 2 | import linkCandidates from './sources/link'; 3 | import tabCandidates from './sources/tab'; 4 | import historyCandidates from './sources/history'; 5 | import bookmarkCandidates from './sources/bookmark'; 6 | import hatenaBookmarkCandidates from './sources/hatena_bookmark'; 7 | import sessionCandidates from './sources/session'; 8 | import commandCandidates from './sources/command'; 9 | 10 | export const MAX_RESULTS = 20; 11 | export const MAX_RESULTS_FOR_SOLO = 100; 12 | export const MAX_RESULTS_FOR_EMPTY = 5; 13 | let sources = []; 14 | let maxResultsForEmpty = {}; 15 | 16 | function getType(t) { 17 | const items = sources.filter(s => s.shorthand === t); 18 | if (items.length > 0) { 19 | return items[0].type; 20 | } 21 | return null; 22 | } 23 | 24 | function parseAsHasType(query) { 25 | const found = query.match(/^:(\w*)\s*(.*)/); 26 | if (found) { 27 | const [, type, value] = found; 28 | return { type, value }; 29 | } 30 | return null; 31 | } 32 | 33 | function parseAsHasShorthand(query) { 34 | const found = query.match(/^(\w\w?)\s+(.*)/); 35 | let type = null; 36 | let value = ''; 37 | if (found) { 38 | const [, t, v] = found; 39 | type = getType(t); 40 | value = v; 41 | } else if (query.length === 1 || query === 'hb') { 42 | type = getType(query); 43 | } 44 | if (type) { 45 | return { type, value }; 46 | } 47 | return null; 48 | } 49 | 50 | export function parse(query) { 51 | const hasType = parseAsHasType(query); 52 | if (hasType) { 53 | return hasType; 54 | } 55 | const hasShorthand = parseAsHasShorthand(query); 56 | if (hasShorthand) { 57 | return hasShorthand; 58 | } 59 | return { type: null, value: query }; 60 | } 61 | 62 | function getSources(type) { 63 | if (type === null || type === '') { 64 | return sources; 65 | } 66 | return sources.filter(s => s.type === type); 67 | } 68 | 69 | function options({ type }, isEmpty, isSolo) { 70 | if (isSolo) { 71 | return { maxResults: MAX_RESULTS_FOR_SOLO }; 72 | } 73 | if (isEmpty) { 74 | return { maxResults: MAX_RESULTS }; 75 | } 76 | return { maxResults: maxResultsForEmpty[type] || MAX_RESULTS_FOR_EMPTY }; 77 | } 78 | 79 | export default function search(query) { 80 | const { type, value } = parse(query.toLowerCase()); 81 | const ss = getSources(type); 82 | const isEmpty = query.length > 0; 83 | const isSolo = ss.length === 1; 84 | return Promise.all(ss.map(s => s.f(value, options(s, isEmpty, isSolo)))) 85 | .then(a => a.reduce((acc, v) => { 86 | if (v.items.length === 0) { 87 | return acc; 88 | } 89 | const { items, separators } = acc; 90 | return { 91 | items: items.concat(v.items), 92 | separators: separators.concat({ label: v.label, index: items.length }), 93 | }; 94 | }, { items: [], separators: [] })); 95 | } 96 | 97 | export function init({ orderOfCandidates: order, maxResultsForEmpty: nums } = {}) { 98 | sources = [{ type: 'search', shorthand: null, f: searchCandidates }]; 99 | /* eslint-disable no-multi-spaces, comma-spacing */ 100 | const items = [ 101 | { type: 'link' , shorthand: 'l' , f: linkCandidates }, 102 | { type: 'tab' , shorthand: 't' , f: tabCandidates }, 103 | { type: 'history' , shorthand: 'h' , f: historyCandidates }, 104 | { type: 'bookmark', shorthand: 'b' , f: bookmarkCandidates }, 105 | { type: 'hatebu' , shorthand: 'hb', f: hatenaBookmarkCandidates }, 106 | { type: 'session' , shorthand: 's' , f: sessionCandidates }, 107 | { type: 'command' , shorthand: 'c' , f: commandCandidates }, 108 | ]; 109 | if (order) { 110 | sources = sources.concat(order.map(n => items.find(i => i.type === n))); 111 | } else { 112 | sources = sources.concat(items); 113 | } 114 | if (nums) { 115 | maxResultsForEmpty = nums; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/Candidate.jsx: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import { isExtensionUrl } from '../utils/url'; 6 | 7 | function noop() {} 8 | 9 | function imageURL(type) { 10 | return browser.extension.getURL(`images/${type}.png`); 11 | } 12 | 13 | function typeImg(type) { 14 | const alt = type[0].toUpperCase(); 15 | switch (type) { 16 | case 'search': 17 | return ; 18 | default: 19 | return {alt}; 20 | } 21 | } 22 | 23 | function faviconImg(url) { 24 | let src = url; 25 | let classes = 'candidate-icon'; 26 | if (!src) { 27 | src = browser.extension.getURL('images/blank_page.png'); 28 | classes += ' candidate-icon-ext'; 29 | } else if (isExtensionUrl(url)) { 30 | classes += ' candidate-icon-ext'; 31 | } 32 | return favicon; 33 | } 34 | 35 | function className(isSelected, isMarked) { 36 | const classes = ['candidate']; 37 | if (isMarked) { 38 | classes.push('marked'); 39 | } 40 | if (isSelected) { 41 | classes.push('selected'); 42 | } 43 | return classes.join(' '); 44 | } 45 | 46 | /* eslint-disable object-curly-newline */ 47 | const Candidate = ({ item, isSelected, isMarked, onClick }) => ( 48 |
55 | {typeImg(item.type)} 56 | {faviconImg(item.faviconUrl)} 57 | {item.label} 58 |
59 | ); 60 | 61 | Candidate.propTypes = { 62 | item: PropTypes.shape({ 63 | id: PropTypes.string.isRequired, 64 | label: PropTypes.string.isRequired, 65 | type: PropTypes.string.isRequired, 66 | faviconUrl: PropTypes.string, 67 | }).isRequired, 68 | isSelected: PropTypes.bool.isRequired, 69 | isMarked: PropTypes.bool.isRequired, 70 | onClick: PropTypes.func.isRequired, 71 | }; 72 | 73 | export default Candidate; 74 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | dbName: 'bebop', 3 | dbVersion: 1, 4 | }; 5 | -------------------------------------------------------------------------------- /src/containers/Options.jsx: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { connect } from 'react-redux'; 5 | import { SortableContainer, SortableElement } from 'react-sortable-hoc'; 6 | import getMessage from '../utils/i18n'; 7 | import { defaultOrder } from '../reducers/options'; 8 | 9 | const dragIcon = browser.extension.getURL('images/drag.png'); 10 | 11 | const SortableItem = SortableElement(({ value }) => (( 12 |
  • 13 | drag 14 | {value} 15 |
  • 16 | ))); 17 | 18 | const SortableList = SortableContainer(({ items }) => (( 19 |
      20 | {items.map((value, index) => ( 21 | 22 | ))} 23 |
    24 | ))); 25 | 26 | class Options extends React.Component { 27 | static get propTypes() { 28 | return { 29 | popupWidth: PropTypes.number.isRequired, 30 | orderOfCandidates: PropTypes.arrayOf(PropTypes.string).isRequired, 31 | maxResultsForEmpty: PropTypes.objectOf(PropTypes.number).isRequired, 32 | enabledCJKMove: PropTypes.bool.isRequired, 33 | hatenaUserName: PropTypes.string.isRequired, 34 | theme: PropTypes.string.isRequired, 35 | handlePopupWidthChange: PropTypes.func.isRequired, 36 | handleMaxResultsForEmptyChange: PropTypes.func.isRequired, 37 | handleCJKMoveChange: PropTypes.func.isRequired, 38 | handleSortEnd: PropTypes.func.isRequired, 39 | handleHatenaUserNameChange: PropTypes.func.isRequired, 40 | handleThemeChange: PropTypes.func.isRequired, 41 | }; 42 | } 43 | 44 | handlePopupWidthChange(e) { 45 | if (!Number.isNaN(e.target.valueAsNumber)) { 46 | this.props.handlePopupWidthChange(e.target.valueAsNumber); 47 | } 48 | } 49 | 50 | renderInputsOfCandidates() { 51 | return defaultOrder.map((type) => { 52 | const n = this.props.maxResultsForEmpty[type]; 53 | return ( 54 |
    55 | {type} 56 | this.props.handleMaxResultsForEmptyChange({ 64 | [type]: parseInt(e.target.value, 10), 65 | })} 66 | /> 67 |
    68 | ); 69 | }); 70 | } 71 | 72 | renderPopupWidthInput() { 73 | return ( 74 |
    75 |

    Popup width

    76 | this.handlePopupWidthChange(e)} 85 | /> 86 |
    87 | ); 88 | } 89 | 90 | renderOrderOfCandidates() { 91 | return ( 92 |
    93 |

    Order of candidates

    94 |

    You can change order by drag

    95 | 96 |
    97 | ); 98 | } 99 | 100 | renderMaxResultsForEmpty() { 101 | return ( 102 |
    103 |

    Max results of candidates for empty query

    104 |
    105 | {this.renderInputsOfCandidates()} 106 |
    107 |
    108 | ); 109 | } 110 | 111 | renderKeyBindings() { 112 | return ( 113 |
    114 |

    key-bindings

    115 | this.props.handleCJKMoveChange(e.target.checked)} 120 | /> 121 | C-j ... next-candidate, C-k ... previous-candidate 122 |
    123 | ); 124 | } 125 | 126 | renderHatenaUserName() { 127 | return ( 128 |
    129 |

    Hatena User Name

    130 | this.props.handleHatenaUserNameChange(e.target.value)} 135 | /> 136 |
    137 | ); 138 | } 139 | 140 | renderTheme() { 141 | return ( 142 |
    143 |

    Select theme

    144 | 152 |
    153 | ); 154 | } 155 | 156 | render() { 157 | return ( 158 |
    159 |

    Options

    160 | {this.renderPopupWidthInput()} 161 | {this.renderOrderOfCandidates()} 162 | {this.renderMaxResultsForEmpty()} 163 | {this.renderKeyBindings()} 164 | {this.renderHatenaUserName()} 165 | {this.renderTheme()} 166 |
    167 | ); 168 | } 169 | } 170 | 171 | function mapStateToProps(state) { 172 | return { 173 | popupWidth: state.popupWidth, 174 | orderOfCandidates: state.orderOfCandidates, 175 | maxResultsForEmpty: state.maxResultsForEmpty, 176 | enabledCJKMove: state.enabledCJKMove, 177 | hatenaUserName: state.hatenaUserName, 178 | theme: state.theme, 179 | }; 180 | } 181 | 182 | function mapDispatchToProps(dispatch) { 183 | return { 184 | handlePopupWidthChange: payload => dispatch({ type: 'POPUP_WIDTH', payload }), 185 | handleSortEnd: payload => dispatch({ type: 'CHANGE_ORDER', payload }), 186 | handleMaxResultsForEmptyChange: payload => dispatch({ 187 | type: 'UPDATE_MAX_RESULTS_FOR_EMPTY', 188 | payload, 189 | }), 190 | handleCJKMoveChange: payload => dispatch({ 191 | type: 'ENABLE_CJK_MOVE', 192 | payload, 193 | }), 194 | handleHatenaUserNameChange: payload => dispatch({ type: 'HATENA_USER_NAME', payload }), 195 | handleThemeChange: payload => dispatch({ type: 'SET_THEME', payload }), 196 | }; 197 | } 198 | 199 | export default connect(mapStateToProps, mapDispatchToProps)(Options); 200 | -------------------------------------------------------------------------------- /src/containers/Popup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import { withRouter } from 'react-router-dom'; 5 | import getMessage from '../utils/i18n'; 6 | import Candidate from '../components/Candidate'; 7 | import keySequence from '../key_sequences'; 8 | import { commandOfSeq } from '../sagas/key_sequence'; 9 | 10 | class Popup extends React.Component { 11 | static get propTypes() { 12 | return { 13 | query: PropTypes.string.isRequired, 14 | candidates: PropTypes.arrayOf(PropTypes.object).isRequired, 15 | separators: PropTypes.arrayOf(PropTypes.object).isRequired, 16 | index: PropTypes.number, 17 | markedCandidateIds: PropTypes.shape({ id: PropTypes.bool }).isRequired, 18 | mode: PropTypes.string.isRequired, 19 | handleSelectCandidate: PropTypes.func.isRequired, 20 | handleInputChange: PropTypes.func.isRequired, 21 | handleKeyDown: PropTypes.func.isRequired, 22 | dispatchQuit: PropTypes.func.isRequired, 23 | scheme: PropTypes.shape({ 24 | type: PropTypes.string, 25 | title: PropTypes.string, 26 | minimum: PropTypes.number, 27 | maximum: PropTypes.number, 28 | }).isRequired, 29 | }; 30 | } 31 | 32 | static get defaultProps() { 33 | return { 34 | index: null, 35 | }; 36 | } 37 | 38 | constructor() { 39 | super(); 40 | this.focusInput = () => this.input.focus(); 41 | } 42 | 43 | componentDidMount() { 44 | window.addEventListener('focus', this.focusInput); 45 | window.addEventListener('blur', this.props.dispatchQuit); 46 | this.timer = setTimeout(() => { 47 | this.input.focus(); 48 | if (document.scrollingElement) { 49 | document.scrollingElement.scrollTo(0, 0); 50 | } 51 | }, 100); 52 | } 53 | 54 | componentDidUpdate() { 55 | if (this.selectedCandidate && document.scrollingElement) { 56 | const container = document.scrollingElement; 57 | const containerHeight = container.clientHeight; 58 | const { bottom, top, height } = this.selectedCandidate.getBoundingClientRect(); 59 | const b = containerHeight - height - 18 - container.scrollTop; 60 | if (bottom > containerHeight || top < 0) { 61 | container.scrollTop = top - b; 62 | } 63 | } 64 | } 65 | 66 | componentWillUnmount() { 67 | window.removeEventListener('focus', this.focusInput); 68 | window.removeEventListener('blur', this.props.dispatchQuit); 69 | clearTimeout(this.timer); 70 | } 71 | 72 | handleCandidateClick(index) { 73 | const candidate = this.props.candidates[index]; 74 | if (candidate !== null) { 75 | this.props.handleSelectCandidate(candidate); 76 | } 77 | } 78 | 79 | argMessage() { 80 | const { 81 | type, 82 | title, 83 | minimum, 84 | maximum, 85 | } = this.props.scheme; 86 | let message = `Enter argument ${title}: ${type}`; 87 | switch (type) { 88 | case 'number': { 89 | if (minimum !== undefined) { 90 | message += `(N >= ${minimum})`; 91 | } 92 | if (maximum !== undefined) { 93 | message += `(N <= ${maximum})`; 94 | } 95 | break; 96 | } 97 | default: 98 | break; 99 | } 100 | return message; 101 | } 102 | 103 | hasFooter() { 104 | return this.props.mode !== 'action'; 105 | } 106 | 107 | renderFooter() { 108 | switch (this.props.mode) { 109 | case 'candidate': 110 | return
    {getMessage('key_info')}
    ; 111 | case 'action': 112 | return null; 113 | case 'arg': 114 | return
    {this.argMessage()}
    ; 115 | default: 116 | return null; 117 | } 118 | } 119 | 120 | renderCandidateList() { 121 | const className = this.hasFooter() ? 'candidatesList' : 'candidatesList-no-footer'; 122 | return ( 123 |
      124 | {this.props.candidates.map((c, i) => ( 125 |
    • { 128 | if (i === this.props.index) { 129 | this.selectedCandidate = node; 130 | } 131 | }} 132 | > 133 | {this.renderSeparator(i)} 134 | this.handleCandidateClick(i)} 139 | /> 140 |
    • 141 | )) 142 | } 143 |
    144 | ); 145 | } 146 | 147 | renderSeparator(index) { 148 | return this.props.separators.filter(s => s.index === index && s.label).map(s => (( 149 |
    {s.label}
    150 | ))); 151 | } 152 | 153 | render() { 154 | return ( 155 |
    158 | { this.input = input; }} 161 | type="text" 162 | value={this.props.query} 163 | onChange={e => this.props.handleInputChange(e.target.value)} 164 | onKeyDown={this.props.handleKeyDown} 165 | placeholder={getMessage('commandInput_placeholder')} 166 | /> 167 | {this.renderCandidateList()} 168 | {this.renderFooter()} 169 |
    170 | ); 171 | } 172 | } 173 | 174 | function mapStateToProps(state) { 175 | return { 176 | query: state.query, 177 | candidates: state.candidates.items, 178 | index: state.candidates.index, 179 | separators: state.separators, 180 | markedCandidateIds: state.markedCandidateIds, 181 | mode: state.mode, 182 | scheme: state.scheme, 183 | }; 184 | } 185 | 186 | function mapDispatchToProps(dispatch) { 187 | return { 188 | handleSelectCandidate: payload => dispatch({ type: 'SELECT_CANDIDATE', payload }), 189 | handleInputChange: payload => dispatch({ type: 'QUERY', payload }), 190 | handleKeyDown: (e) => { 191 | const keySeq = keySequence(e); 192 | if (commandOfSeq[keySeq]) { 193 | e.preventDefault(); 194 | dispatch({ type: 'KEY_SEQUENCE', payload: keySeq }); 195 | } 196 | }, 197 | dispatchQuit: () => dispatch({ type: 'QUIT' }), 198 | }; 199 | } 200 | 201 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Popup)); 202 | -------------------------------------------------------------------------------- /src/content_popup.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { isExtensionUrl } from './utils/url'; 3 | 4 | export const POPUP_FRAME_ID = 'bebop-popup'; 5 | export const DEFAULT_POPUP_WIDTH = 700; 6 | const POPUP_OPACITY = 0.85; 7 | 8 | export function hasPopup() { 9 | return !!document.getElementById(POPUP_FRAME_ID); 10 | } 11 | 12 | function removePopup() { 13 | const previousPopup = document.getElementById(POPUP_FRAME_ID); 14 | if (previousPopup) { 15 | document.body.removeChild(previousPopup); 16 | } 17 | } 18 | 19 | export function messageListener(event) { 20 | if (isExtensionUrl(event.origin)) { 21 | const { type } = JSON.parse(event.data); 22 | if (type === 'CLOSE') { 23 | removePopup(); 24 | } 25 | } 26 | } 27 | 28 | async function createPopup() { 29 | const popup = document.createElement('iframe'); 30 | popup.src = browser.extension.getURL('popup/index.html'); 31 | const { popupWidth } = await browser.storage.local.get('popupWidth'); 32 | const w = window.innerWidth - 100; 33 | const width = Math.min(w, popupWidth || DEFAULT_POPUP_WIDTH); 34 | const height = window.innerHeight * 0.8; 35 | const left = Math.round((window.innerWidth - width) * 0.5); 36 | const top = Math.round((window.innerHeight - height) * 0.25); 37 | popup.id = POPUP_FRAME_ID; 38 | popup.style.position = 'fixed'; 39 | popup.style.top = `${top}px`; 40 | popup.style.left = `${left}px`; 41 | popup.style.width = `${width}px`; 42 | popup.style.height = `${height}px`; 43 | popup.style.zIndex = 10000000; 44 | popup.style.opacity = `${POPUP_OPACITY}`; 45 | popup.style.boxShadow = '0 0 1em'; 46 | return popup; 47 | } 48 | 49 | export async function toggle() { 50 | if (hasPopup()) { 51 | removePopup(); 52 | return; 53 | } 54 | const popup = await createPopup(); 55 | window.addEventListener('message', messageListener); 56 | if (document.activeElement) { 57 | document.activeElement.blur(); 58 | } 59 | document.body.appendChild(popup); 60 | } 61 | -------------------------------------------------------------------------------- /src/content_script.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import logger from 'kiroku'; 3 | import { toggle } from './content_popup'; 4 | import { init as actionInit, find as findAction } from './actions'; 5 | import { search, highlight, dehighlight } from './link'; 6 | 7 | const portName = `content-script-${window.location.href}`; 8 | let port = null; 9 | if (process.env.NODE_ENV === 'production') { 10 | logger.setLevel('FATAL'); 11 | } 12 | 13 | export function executeAction(actionId, candidates) { 14 | const action = findAction(actionId); 15 | if (action && action.contentHandler) { 16 | const f = action.contentHandler; 17 | return f.call(this, candidates); 18 | } 19 | return Promise.resolve(); 20 | } 21 | 22 | function handleExecuteAction({ actionId, candidates }) { 23 | return executeAction(actionId, candidates); 24 | } 25 | 26 | function handleCandidateChange(candidate) { 27 | dehighlight(); 28 | if (!candidate || candidate.type !== 'link') { 29 | highlight(); 30 | } else { 31 | highlight(candidate.args[0]); 32 | } 33 | return Promise.resolve(); 34 | } 35 | 36 | function handleClose() { 37 | dehighlight(); 38 | } 39 | 40 | async function handleTogglePopup() { 41 | await toggle(); 42 | } 43 | 44 | export function portMessageListener(msg) { 45 | const { type, payload } = msg; 46 | logger.trace(`Handle message ${type} ${JSON.stringify(payload)}`); 47 | switch (type) { 48 | case 'POPUP_CLOSE': 49 | handleClose(); 50 | break; 51 | default: 52 | break; 53 | } 54 | } 55 | 56 | export function messageListener(request) { 57 | switch (request.type) { 58 | case 'FETCH_LINKS': 59 | return Promise.resolve(search(request.payload)); 60 | case 'CHANGE_CANDIDATE': 61 | return handleCandidateChange(request.payload); 62 | case 'EXECUTE_ACTION': 63 | return handleExecuteAction(request.payload); 64 | case 'TOGGLE_POPUP': 65 | return handleTogglePopup(request.payload); 66 | default: 67 | return null; 68 | } 69 | } 70 | 71 | setTimeout(() => { 72 | port = browser.runtime.connect({ name: portName }); 73 | port.onMessage.addListener(portMessageListener); 74 | const disconnectListener = () => { 75 | port.onMessage.removeListener(portMessageListener); 76 | port.onDisconnect.removeListener(disconnectListener); 77 | }; 78 | port.onDisconnect.addListener(disconnectListener); 79 | browser.runtime.onMessage.addListener(messageListener); 80 | logger.info('bebop content_script is loaded'); 81 | }, 500); 82 | actionInit(); 83 | -------------------------------------------------------------------------------- /src/cursor.js: -------------------------------------------------------------------------------- 1 | import logger from 'kiroku'; 2 | 3 | export function cursor2position(lines, start) { 4 | let offset = 0; 5 | for (let i = 0; i < lines.length; i += 1) { 6 | if (start < offset + lines[i].length + 1) { 7 | return { x: start - offset, y: i }; 8 | } 9 | offset += lines[i].length + 1; 10 | } 11 | return { x: 0, y: 0 }; 12 | } 13 | 14 | export function position2cursor(lines, position) { 15 | const y = Math.max(0, Math.min(position.y, lines.length)); 16 | const x = Math.max(0, Math.min(position.x, lines[y].length)); 17 | let offset = 0; 18 | for (let i = 0; i < y; i += 1) { 19 | offset += lines[i].length + 1; 20 | } 21 | return offset + x; 22 | } 23 | 24 | export function forwardChar() { 25 | const elem = document.activeElement; 26 | if (!elem || !elem.value) { 27 | return; 28 | } 29 | const pos = elem.selectionStart + 1; 30 | elem.setSelectionRange(pos, pos); 31 | } 32 | 33 | export function backwardChar() { 34 | const elem = document.activeElement; 35 | if (!elem || !elem.value) { 36 | return; 37 | } 38 | const pos = elem.selectionStart - 1; 39 | elem.setSelectionRange(pos, pos); 40 | } 41 | 42 | export function nextLine() { 43 | const elem = document.activeElement; 44 | if (!elem || !elem.value) { 45 | return; 46 | } 47 | const lines = elem.value.split('\n'); 48 | const start = elem.selectionStart; 49 | const { x, y } = cursor2position(lines, start); 50 | const newPos = { x, y: y + 1 }; 51 | const newStart = position2cursor(lines, newPos); 52 | logger.trace(`change start ${start} (${x}, ${y}) to ${newStart} (${newPos.x}, ${newPos.y})`); 53 | elem.setSelectionRange(newStart, newStart); 54 | } 55 | 56 | export function previousLine() { 57 | const elem = document.activeElement; 58 | if (!elem || !elem.value) { 59 | return; 60 | } 61 | const lines = elem.value.split('\n'); 62 | const start = elem.selectionStart; 63 | const { x, y } = cursor2position(lines, start); 64 | const newPos = { x, y: y - 1 }; 65 | const newStart = position2cursor(lines, newPos); 66 | logger.trace(`change start ${start} (${x}, ${y}) to ${newStart} (${newPos.x}, ${newPos.y})`); 67 | elem.setSelectionRange(newStart, newStart); 68 | } 69 | 70 | export function endOfLine() { 71 | const elem = document.activeElement; 72 | if (!elem || !elem.value) { 73 | return; 74 | } 75 | const lines = elem.value.split('\n'); 76 | const start = elem.selectionStart; 77 | const { x, y } = cursor2position(lines, start); 78 | const newPos = { x: lines[y].length, y }; 79 | const newStart = position2cursor(lines, newPos); 80 | logger.trace(`change start ${start} (${x}, ${y}) to ${newStart} (${newPos.x}, ${newPos.y})`); 81 | elem.setSelectionRange(newStart, newStart); 82 | } 83 | 84 | export function beginningOfLine() { 85 | const elem = document.activeElement; 86 | if (!elem || !elem.value) { 87 | return; 88 | } 89 | const lines = elem.value.split('\n'); 90 | const start = elem.selectionStart; 91 | const { x, y } = cursor2position(lines, start); 92 | const newPos = { x: 0, y }; 93 | const newStart = position2cursor(lines, newPos); 94 | logger.trace(`change start ${start} (${x}, ${y}) to ${newStart} (${newPos.x}, ${newPos.y})`); 95 | elem.setSelectionRange(newStart, newStart); 96 | } 97 | 98 | export function endOfBuffer() { 99 | const elem = document.activeElement; 100 | if (elem && elem.value) { 101 | const end = elem.value.length - 1; 102 | elem.setSelectionRange(end, end); 103 | } 104 | } 105 | 106 | export function beginningOfBuffer() { 107 | const elem = document.activeElement; 108 | if (elem && elem.value) { 109 | elem.setSelectionRange(0, 0); 110 | } 111 | } 112 | 113 | export function deleteBackwardChar() { 114 | const elem = document.activeElement; 115 | if (!elem || !elem.value) { 116 | return; 117 | } 118 | const v = elem.value; 119 | const start = elem.selectionStart; 120 | elem.value = v.slice(0, start - 1) + v.slice(start, v.length); 121 | elem.setSelectionRange(start - 1, start - 1); 122 | } 123 | 124 | export function killLine() { 125 | const elem = document.activeElement; 126 | if (!elem || !elem.value) { 127 | return; 128 | } 129 | const lines = elem.value.split('\n'); 130 | const start = elem.selectionStart; 131 | const { x, y } = cursor2position(lines, start); 132 | lines[y] = lines[y].slice(0, x); 133 | elem.value = lines.join('\n'); 134 | const newPos = { x: lines[y].length, y }; 135 | const newStart = position2cursor(lines, newPos); 136 | elem.setSelectionRange(newStart, newStart); 137 | } 138 | 139 | export function activeElementValue() { 140 | const elem = document.activeElement; 141 | if (!elem || !elem.value) { 142 | return ''; 143 | } 144 | return elem.value; 145 | } 146 | -------------------------------------------------------------------------------- /src/key_sequences.js: -------------------------------------------------------------------------------- 1 | const characterMap = []; 2 | characterMap[192] = '~'; 3 | characterMap[49] = '!'; 4 | characterMap[50] = '@'; 5 | characterMap[51] = '#'; 6 | characterMap[52] = '$'; 7 | characterMap[53] = '%'; 8 | characterMap[54] = '^'; 9 | characterMap[55] = '&'; 10 | characterMap[56] = '*'; 11 | characterMap[57] = '('; 12 | characterMap[48] = ')'; 13 | characterMap[109] = '_'; 14 | characterMap[107] = '+'; 15 | characterMap[219] = '{'; 16 | characterMap[221] = '}'; 17 | characterMap[220] = '|'; 18 | characterMap[59] = ':'; 19 | characterMap[222] = '\''; 20 | characterMap[188] = '<'; 21 | characterMap[190] = '>'; 22 | characterMap[191] = '?'; 23 | characterMap[32] = 'SPC'; 24 | characterMap[38] = 'up'; 25 | characterMap[40] = 'down'; 26 | characterMap[9] = 'tab'; 27 | characterMap[13] = 'return'; 28 | characterMap[27] = 'ESC'; 29 | 30 | export default function keySequence(keyEvent) { 31 | let code = String.fromCharCode(keyEvent.keyCode).toLowerCase(); 32 | if (characterMap[keyEvent.keyCode]) { 33 | code = characterMap[keyEvent.keyCode]; 34 | } 35 | let prefix = ''; 36 | if (keyEvent.ctrlKey) { 37 | prefix += 'C-'; 38 | } 39 | if (keyEvent.metaKey) { 40 | prefix += 'M-'; 41 | } 42 | if (keyEvent.shiftKey) { 43 | prefix += 'S-'; 44 | } 45 | return `${prefix}${code}`; 46 | } 47 | -------------------------------------------------------------------------------- /src/link.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { isExtensionUrl } from './utils/url'; 3 | import { includes } from './utils/string'; 4 | 5 | export const HIGHLIGHTER_ID = 'bebop-highlighter'; 6 | export const LINK_MARKER_CLASS = 'bebop-link-marker'; 7 | const MARKER_SIZE = 12; 8 | 9 | const SELECTOR = [ 10 | 'a', 11 | 'button', 12 | 'input[type="button"]', 13 | 'input[type="submit"]', 14 | '[role="button"]', 15 | ].join(','); 16 | 17 | const dummyHrefs = [ 18 | '#', 19 | 'javascirpt:void(0);', 20 | './', 21 | ]; 22 | 23 | function reduce(method) { 24 | return Array.prototype.reduce.apply(window.frames, [ 25 | (acc, f) => { 26 | try { 27 | if (!isExtensionUrl(f.document.location.href)) { 28 | return acc.concat(method(f.document)); 29 | } 30 | } catch (e) { 31 | // do nothing 32 | } 33 | return acc; 34 | }, 35 | method(document), 36 | ]); 37 | } 38 | 39 | export function getTargetElements() { 40 | return reduce((doc) => { 41 | const elements = doc.querySelectorAll(SELECTOR); 42 | return Array.prototype.filter.call(elements, (e) => { 43 | const style = window.getComputedStyle(e); 44 | return style.display !== 'none' 45 | && style.visibility !== 'hidden' 46 | && e.type !== 'hidden' 47 | && e.offsetHeight > 0; 48 | }); 49 | }); 50 | } 51 | 52 | export function getButtonLabel(element) { 53 | const ariaLabel = element.getAttribute('aria-label'); 54 | if (ariaLabel) { 55 | return ariaLabel; 56 | } 57 | if (element.textContent) { 58 | return element.textContent; 59 | } 60 | return element.title; 61 | } 62 | 63 | export function getAnchorUrl(element) { 64 | const href = element.getAttribute('href'); 65 | if (dummyHrefs.includes(href)) { 66 | return ''; 67 | } 68 | return element.href; 69 | } 70 | 71 | export function getAnchorLabel(element) { 72 | const { text, title } = element; 73 | const txt = text.trim(); 74 | if (txt) { 75 | return txt; 76 | } 77 | const t = title.trim(); 78 | return t; 79 | } 80 | 81 | function buttonLink(element, id, index) { 82 | return { 83 | id, 84 | url: element.target || '', 85 | label: getButtonLabel(element), 86 | role: 'button', 87 | index, 88 | }; 89 | } 90 | 91 | function inputLink(element, id, index) { 92 | return { 93 | id, 94 | url: element.target || '', 95 | label: element.value, 96 | role: 'button', 97 | index, 98 | }; 99 | } 100 | 101 | function anchorLink(element, id, index) { 102 | const url = getAnchorUrl(element); 103 | return { 104 | id, 105 | url, 106 | label: getAnchorLabel(element), 107 | role: url ? 'link' : 'button', 108 | index, 109 | }; 110 | } 111 | 112 | export function elem2Link(element, index) { 113 | const id = `link-${index}`; 114 | const tagName = element.tagName.toLowerCase(); 115 | const role = element.getAttribute('role'); 116 | if (role === 'button' || tagName === 'button') { 117 | return buttonLink(element, id, index); 118 | } 119 | if (tagName === 'input') { 120 | return inputLink(element, id, index); 121 | } 122 | return anchorLink(element, id, index); 123 | } 124 | 125 | export function search({ query = '', maxResults = 20 } = {}) { 126 | return getTargetElements().map(elem2Link).filter((l) => { 127 | const url = l.url.toLowerCase(); 128 | const label = l.label.toLowerCase(); 129 | return includes(url, query) || includes(label, query); 130 | }).slice(0, maxResults); 131 | } 132 | 133 | export function click({ index, url } = {}) { 134 | const elements = getTargetElements(); 135 | for (let i = 0, len = elements.length; i < len; i += 1) { 136 | const e = elements[i]; 137 | const l = elem2Link(e, i); 138 | const selected = i === index && l.url === url; 139 | if (selected) { 140 | e.click(); 141 | return; 142 | } 143 | } 144 | } 145 | 146 | export function createHighlighter(rect) { 147 | const { 148 | left, 149 | top, 150 | width, 151 | height, 152 | } = rect; 153 | const e = document.createElement('div'); 154 | e.id = HIGHLIGHTER_ID; 155 | e.style.position = 'absolute'; 156 | e.style.top = `${top}px`; 157 | e.style.left = `${left}px`; 158 | e.style.width = `${width}px`; 159 | e.style.height = `${height}px`; 160 | e.style.border = 'dashed 2px #FF8C00'; 161 | e.style.zIndex = 1000000; 162 | e.style.backgroundColor = '#FF8C0055'; 163 | return e; 164 | } 165 | 166 | function createLinkMarker({ left, top }, selected) { 167 | const e = document.createElement('img'); 168 | e.src = browser.extension.getURL('images/link.png'); 169 | e.className = LINK_MARKER_CLASS; 170 | e.style.position = 'absolute'; 171 | e.style.display = 'block'; 172 | e.style.top = `${top - (MARKER_SIZE * 0.5)}px`; 173 | e.style.left = `${left - MARKER_SIZE}px`; 174 | e.style.width = `${MARKER_SIZE}px`; 175 | e.style.height = `${MARKER_SIZE}px`; 176 | e.style.zIndex = 1000000; 177 | e.style.padding = '2px'; 178 | e.style.backgroundColor = selected ? '#FF8C00' : '#ADFF2F'; 179 | e.borderRadius = '5px'; 180 | return e; 181 | } 182 | 183 | function removeHighlighter() { 184 | reduce((doc) => { 185 | const e = doc.getElementById(HIGHLIGHTER_ID); 186 | if (e) { 187 | e.parentNode.removeChild(e); 188 | } 189 | return []; 190 | }); 191 | } 192 | 193 | function removeLinkMarkers() { 194 | reduce((doc) => { 195 | const elements = doc.getElementsByClassName(LINK_MARKER_CLASS); 196 | for (let i = elements.length - 1; i >= 0; i -= 1) { 197 | elements[i].parentNode.removeChild(elements[i]); 198 | } 199 | return []; 200 | }); 201 | } 202 | 203 | function getContainerDisplayedRect() { 204 | const { 205 | pageXOffset, 206 | pageYOffset, 207 | innerHeight, 208 | innerWidth, 209 | } = window; 210 | return { 211 | left: pageXOffset, 212 | right: pageXOffset + innerWidth, 213 | top: pageYOffset, 214 | bottom: pageYOffset + innerHeight, 215 | }; 216 | } 217 | 218 | function getElementRect(element) { 219 | const rect = element.getBoundingClientRect(); 220 | const left = rect.left + window.pageXOffset; 221 | const top = rect.top + window.pageYOffset; 222 | return { 223 | left, 224 | top, 225 | width: rect.width, 226 | height: rect.height, 227 | }; 228 | } 229 | 230 | function isDisplayed(container, rect) { 231 | return container.left <= rect.left && rect.left <= container.right 232 | && container.top <= rect.top && rect.top <= container.bottom; 233 | } 234 | 235 | export function highlight({ index, url } = {}) { 236 | const containerRect = getContainerDisplayedRect(); 237 | const elements = getTargetElements(); 238 | elements.forEach((elem, i) => { 239 | const doc = elem.ownerDocument; 240 | const rect = getElementRect(elem); 241 | const link = elem2Link(elem, i); 242 | const selected = i === index && link.url === url; 243 | if (selected || isDisplayed(containerRect, rect)) { 244 | const marker = createLinkMarker(rect, selected); 245 | doc.body.appendChild(marker); 246 | } 247 | if (selected) { 248 | const highlighter = createHighlighter(rect); 249 | doc.body.appendChild(highlighter); 250 | elem.scrollIntoView({ block: 'end' }); 251 | } 252 | }); 253 | } 254 | 255 | export function dehighlight() { 256 | removeHighlighter(); 257 | removeLinkMarkers(); 258 | } 259 | -------------------------------------------------------------------------------- /src/options_ui.jsx: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import React from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | import { 6 | applyMiddleware, 7 | createStore, 8 | } from 'redux'; 9 | import logger from 'kiroku'; 10 | 11 | import Options from './containers/Options'; 12 | import reducers from './reducers/options'; 13 | import rootSaga from './sagas/options'; 14 | import { start as appStart, stop } from './utils/app'; 15 | import migrateOptions from './utils/options_migrator'; 16 | 17 | if (process.env.NODE_ENV === 'production') { 18 | logger.setLevel('FATAL'); 19 | } 20 | 21 | export function start() { 22 | return browser.storage.local.get().then((state) => { 23 | migrateOptions(state); 24 | const container = document.getElementById('container'); 25 | const sagaMiddleware = createSagaMiddleware(); 26 | const store = createStore(reducers, state, applyMiddleware(sagaMiddleware)); 27 | store.dispatch({ type: 'INIT' }); 28 | const element = ( 29 | 30 | 31 | 32 | ); 33 | return appStart(container, element, sagaMiddleware, rootSaga); 34 | }); 35 | } 36 | 37 | export { stop }; 38 | 39 | export default start(); 40 | -------------------------------------------------------------------------------- /src/popup.jsx: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import React from 'react'; 3 | import createHistory from 'history/createHashHistory'; 4 | import { Provider } from 'react-redux'; 5 | import createSagaMiddleware from 'redux-saga'; 6 | import { 7 | applyMiddleware, 8 | createStore, 9 | } from 'redux'; 10 | import { 11 | ConnectedRouter, 12 | routerMiddleware, 13 | } from 'connected-react-router'; 14 | import { 15 | Switch, 16 | Route, 17 | } from 'react-router-dom'; 18 | import logger from 'kiroku'; 19 | 20 | import Popup from './containers/Popup'; 21 | import reducers from './reducers/popup'; 22 | import rootSaga from './sagas/popup'; 23 | import { init as candidateInit } from './candidates'; 24 | import { init as actionInit } from './actions'; 25 | import { init as keySequenceInit } from './sagas/key_sequence'; 26 | import { start as appStart, stop } from './utils/app'; 27 | import migrateOptions from './utils/options_migrator'; 28 | 29 | if (process.env.NODE_ENV === 'production') { 30 | logger.setLevel('FATAL'); 31 | } 32 | 33 | function updateWidth({ popupWidth }) { 34 | const width = popupWidth || 700; 35 | document.body.style.width = `${width}px`; 36 | } 37 | 38 | function updateTheme({ theme = '' }) { 39 | document.documentElement.setAttribute('data-theme', theme); 40 | } 41 | 42 | export function start() { 43 | return browser.storage.local.get().then((state) => { 44 | migrateOptions(state); 45 | updateWidth(state); 46 | updateTheme(state); 47 | candidateInit(state); 48 | keySequenceInit(state); 49 | actionInit(); 50 | const history = createHistory(); 51 | const sagaMiddleware = createSagaMiddleware(); 52 | const middleware = applyMiddleware(sagaMiddleware, routerMiddleware(history)); 53 | const store = createStore(reducers(history), state, middleware); 54 | const container = document.getElementById('container'); 55 | const element = ( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | return appStart(container, element, sagaMiddleware, rootSaga); 65 | }); 66 | } 67 | 68 | export { stop }; 69 | 70 | export default start(); 71 | -------------------------------------------------------------------------------- /src/popup_window.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | let popupWindow = null; 4 | let activeTabId = null; 5 | 6 | export const defaultPopupWidth = 700; 7 | export async function getDisplay() { 8 | const displays = await new Promise(resolve => browser.system.display.getInfo(resolve)); 9 | if (displays.length > 0) { 10 | return displays[0]; 11 | } 12 | return null; 13 | } 14 | export async function toggle() { 15 | if (popupWindow) { 16 | browser.windows.remove(popupWindow.id); 17 | popupWindow = null; 18 | return; 19 | } 20 | const { bounds } = await getDisplay(); 21 | const url = browser.extension.getURL('popup/index.html'); 22 | const { popupWidth } = await browser.storage.local.get('popupWidth'); 23 | const width = popupWidth || defaultPopupWidth; 24 | const height = bounds.height * 0.5; 25 | const left = bounds.left + Math.round((bounds.width - width) * 0.5); 26 | const top = bounds.top + Math.round((bounds.height - height) * 0.5); 27 | popupWindow = await browser.windows.create({ 28 | left, 29 | top, 30 | width, 31 | height, 32 | url, 33 | focused: true, 34 | type: 'popup', 35 | }); 36 | } 37 | 38 | export function onTabRemoved(tabId, { windowId }) { 39 | if (popupWindow && popupWindow.id === windowId) { 40 | popupWindow = null; 41 | } 42 | if (activeTabId === tabId) { 43 | activeTabId = null; 44 | } 45 | } 46 | 47 | export async function onWindowFocusChanged(windowId) { 48 | if (!popupWindow) { 49 | return; 50 | } 51 | if (popupWindow.id !== windowId) { 52 | browser.windows.remove(popupWindow.id).catch(() => {}); 53 | } else { 54 | popupWindow.focused = true; 55 | } 56 | } 57 | 58 | export function onTabActivated({ tabId, windowId }) { 59 | if (popupWindow && popupWindow.id === windowId) { 60 | return; 61 | } 62 | activeTabId = tabId; 63 | } 64 | 65 | export function getPopupWindow() { 66 | return popupWindow; 67 | } 68 | 69 | export function getActiveTabId() { 70 | return activeTabId; 71 | } 72 | -------------------------------------------------------------------------------- /src/reducers/options.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { arrayMove } from 'react-sortable-hoc'; 3 | import { MAX_RESULTS_FOR_EMPTY } from '../candidates'; 4 | 5 | const defaultPopupWidth = 700; 6 | export const defaultOrder = [ 7 | 'link', 8 | 'tab', 9 | 'bookmark', 10 | 'hatebu', 11 | 'history', 12 | 'session', 13 | 'command', 14 | ]; 15 | const numbers = defaultOrder.reduce( 16 | (acc, t) => Object.assign(acc, { [t]: MAX_RESULTS_FOR_EMPTY }), 17 | {}, 18 | ); 19 | 20 | const popupWidth = (state = defaultPopupWidth, action) => { 21 | switch (action.type) { 22 | case 'POPUP_WIDTH': 23 | return action.payload || defaultPopupWidth; 24 | default: 25 | return state; 26 | } 27 | }; 28 | 29 | const orderOfCandidates = (state = defaultOrder, action) => { 30 | switch (action.type) { 31 | case 'CHANGE_ORDER': { 32 | const { oldIndex, newIndex } = action.payload; 33 | return arrayMove(state, oldIndex, newIndex); 34 | } 35 | default: 36 | return state; 37 | } 38 | }; 39 | 40 | const maxResultsForEmpty = (state = numbers, action) => { 41 | switch (action.type) { 42 | case 'UPDATE_MAX_RESULTS_FOR_EMPTY': 43 | return Object.assign({}, state, action.payload); 44 | default: 45 | return state; 46 | } 47 | }; 48 | 49 | const enabledCJKMove = (state = false, action) => { 50 | switch (action.type) { 51 | case 'ENABLE_CJK_MOVE': 52 | return action.payload; 53 | default: 54 | return state; 55 | } 56 | }; 57 | 58 | const hatenaUserName = (state = '', action) => { 59 | switch (action.type) { 60 | case 'HATENA_USER_NAME': 61 | return action.payload || ''; 62 | default: 63 | return state; 64 | } 65 | }; 66 | 67 | const theme = (state = '', action) => { 68 | switch (action.type) { 69 | case 'SET_THEME': 70 | return action.payload || ''; 71 | default: 72 | return state; 73 | } 74 | }; 75 | 76 | 77 | const rootReducer = combineReducers({ 78 | popupWidth, 79 | orderOfCandidates, 80 | maxResultsForEmpty, 81 | enabledCJKMove, 82 | hatenaUserName, 83 | theme, 84 | }); 85 | 86 | export default rootReducer; 87 | -------------------------------------------------------------------------------- /src/reducers/popup.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { connectRouter } from 'connected-react-router'; 3 | 4 | const defaultScheme = { type: 'object' }; 5 | 6 | const query = (state = '', action) => { 7 | switch (action.type) { 8 | case 'QUERY': 9 | return action.payload; 10 | case 'SAVE_CANDIDATES': 11 | return ''; 12 | case 'RESTORE_CANDIDATES': 13 | return action.payload.query; 14 | case 'REQUEST_ARG': 15 | return ''; 16 | default: 17 | return state; 18 | } 19 | }; 20 | 21 | function normalize({ index, items }) { 22 | return { index: (index + items.length) % items.length, items }; 23 | } 24 | 25 | const candidates = (state = { index: null, items: [] }, action) => { 26 | switch (action.type) { 27 | case 'CANDIDATES': { 28 | const { items } = action.payload; 29 | return normalize({ index: state.index, items }); 30 | } 31 | case 'NEXT_CANDIDATE': { 32 | const i = state.index; 33 | return normalize({ index: (Number.isNaN(i) ? -1 : i) + 1, items: state.items }); 34 | } 35 | case 'PREVIOUS_CANDIDATE': { 36 | const i = state.index; 37 | return normalize({ index: (Number.isNaN(i) ? 0 : i) - 1, items: state.items }); 38 | } 39 | case 'SAVE_CANDIDATES': 40 | return { index: null, items: state.items }; 41 | case 'RESTORE_CANDIDATES': { 42 | const { index, items } = action.payload; 43 | return normalize({ index, items }); 44 | } 45 | case 'CANDIDATE_MARKED': 46 | return normalize({ index: state.index + 1, items: state.items }); 47 | case 'REQUEST_ARG': { 48 | const { scheme } = action.payload; 49 | return { index: null, items: scheme.enum || [] }; 50 | } 51 | default: 52 | return state; 53 | } 54 | }; 55 | 56 | const separators = (state = [], action) => { 57 | switch (action.type) { 58 | case 'CANDIDATES': 59 | return action.payload.separators; 60 | case 'RESTORE_CANDIDATES': 61 | return action.payload.separators; 62 | case 'REQUEST_ARG': { 63 | return []; 64 | } 65 | default: 66 | return state; 67 | } 68 | }; 69 | 70 | const markedCandidateIds = (state = {}, action) => { 71 | switch (action.type) { 72 | case 'CANDIDATE_MARKED': { 73 | const { id } = action.payload; 74 | return Object.assign({}, state, { [id]: !state[id] }); 75 | } 76 | case 'CANDIDATES_MARKED': { 77 | const items = action.payload; 78 | return items.reduce((acc, { id }) => Object.assign(acc, { 79 | [id]: true, 80 | }), state); 81 | } 82 | case 'SAVE_CANDIDATES': 83 | return {}; 84 | case 'RESTORE_CANDIDATES': 85 | return action.payload.markedCandidateIds; 86 | case 'REQUEST_ARG': 87 | return {}; 88 | default: 89 | return state; 90 | } 91 | }; 92 | 93 | const prev = (state = {}, action) => { 94 | switch (action.type) { 95 | case 'SAVE_CANDIDATES': 96 | return action.payload; 97 | case 'RESTORE_CANDIDATES': 98 | return {}; 99 | default: 100 | return state; 101 | } 102 | }; 103 | 104 | const mode = (state = 'candidate', action) => { 105 | switch (action.type) { 106 | case 'SAVE_CANDIDATES': 107 | return 'action'; 108 | case 'RESTORE_CANDIDATES': 109 | return 'candidate'; 110 | case 'REQUEST_ARG': 111 | return 'arg'; 112 | default: 113 | return state; 114 | } 115 | }; 116 | 117 | const scheme = (state = defaultScheme, action) => { 118 | switch (action.type) { 119 | case 'REQUEST_ARG': { 120 | const { payload } = action; 121 | return payload.scheme || defaultScheme; 122 | } 123 | default: 124 | return state; 125 | } 126 | }; 127 | 128 | export default history => combineReducers({ 129 | router: connectRouter(history), 130 | query, 131 | candidates, 132 | separators, 133 | markedCandidateIds, 134 | prev, 135 | mode, 136 | scheme, 137 | }); 138 | -------------------------------------------------------------------------------- /src/sagas/key_sequence.js: -------------------------------------------------------------------------------- 1 | import { takeEvery, put } from 'redux-saga/effects'; 2 | import * as cursor from '../cursor'; 3 | 4 | export function dispatchAction(type, payload) { 5 | return function* dispatch() { 6 | yield put({ type, payload }); 7 | }; 8 | } 9 | 10 | /* eslint-disable quote-props */ 11 | export const commandOfSeq = { 12 | 'C-f': cursor.forwardChar, 13 | 'C-b': cursor.backwardChar, 14 | 'C-a': cursor.beginningOfLine, 15 | 'C-e': cursor.endOfLine, 16 | 'C-n': dispatchAction('NEXT_CANDIDATE'), 17 | 'C-p': dispatchAction('PREVIOUS_CANDIDATE'), 18 | 'C-h': cursor.deleteBackwardChar, 19 | 'C-k': cursor.killLine, 20 | up: dispatchAction('PREVIOUS_CANDIDATE'), 21 | down: dispatchAction('NEXT_CANDIDATE'), 22 | tab: dispatchAction('NEXT_CANDIDATE'), 23 | 'S-tab': dispatchAction('PREVIOUS_CANDIDATE'), 24 | 'return': dispatchAction('RETURN', { actionIndex: 0 }), 25 | 'S-return': dispatchAction('RETURN', { actionIndex: 1 }), 26 | 'C-i': dispatchAction('LIST_ACTIONS'), 27 | 'C-SPC': dispatchAction('MARK_CANDIDATE'), 28 | 'M-a': dispatchAction('MARK_ALL_CANDIDATES'), 29 | 'ESC': dispatchAction('QUIT'), 30 | 'C-g': dispatchAction('QUIT'), 31 | }; 32 | 33 | export function* handleKeySequece({ payload }) { 34 | const command = commandOfSeq[payload]; 35 | if (!command) { 36 | return; 37 | } 38 | yield command(); 39 | if (command === cursor.deleteBackwardChar) { 40 | yield put({ type: 'QUERY', payload: cursor.activeElementValue() }); 41 | } 42 | } 43 | 44 | export function* watchKeySequence() { 45 | yield takeEvery('KEY_SEQUENCE', handleKeySequece); 46 | } 47 | 48 | export function init({ enabledCJKMove }) { 49 | if (enabledCJKMove) { 50 | commandOfSeq['C-j'] = dispatchAction('NEXT_CANDIDATE'); 51 | commandOfSeq['C-k'] = dispatchAction('PREVIOUS_CANDIDATE'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/sagas/options.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { 3 | fork, 4 | takeEvery, 5 | select, 6 | call, 7 | put, 8 | all, 9 | } from 'redux-saga/effects'; 10 | 11 | export function sendMessageToBackground(message) { 12 | return browser.runtime.sendMessage(message); 13 | } 14 | 15 | function* dispatchPopupWidth() { 16 | const { popupWidth } = yield browser.storage.local.get('popupWidth'); 17 | yield put({ type: 'POPUP_WIDTH', payload: popupWidth }); 18 | } 19 | 20 | function* dispatchHatenaUserName() { 21 | const { hatenaUserName } = yield browser.storage.local.get('hatenaUserName'); 22 | yield put({ type: 'HATENA_USER_NAME', payload: hatenaUserName }); 23 | } 24 | 25 | function* watchWidth() { 26 | yield takeEvery('POPUP_WIDTH', function* h({ payload }) { 27 | yield browser.storage.local.set({ 28 | popupWidth: payload, 29 | }); 30 | }); 31 | } 32 | 33 | function* watchOrderOfCandidates() { 34 | yield takeEvery('CHANGE_ORDER', function* h() { 35 | const { orderOfCandidates } = yield select(state => state); 36 | yield browser.storage.local.set({ orderOfCandidates }); 37 | }); 38 | } 39 | 40 | function* watchDefaultNumberOfCandidates() { 41 | yield takeEvery('UPDATE_MAX_RESULTS_FOR_EMPTY', function* h() { 42 | const { maxResultsForEmpty } = yield select(state => state); 43 | yield browser.storage.local.set({ maxResultsForEmpty }); 44 | }); 45 | } 46 | 47 | function* watchEnableCJKMove() { 48 | yield takeEvery('ENABLE_CJK_MOVE', function* h() { 49 | const { enabledCJKMove } = yield select(state => state); 50 | yield browser.storage.local.set({ enabledCJKMove }); 51 | }); 52 | } 53 | 54 | function* watchHatenaUserName() { 55 | yield takeEvery('HATENA_USER_NAME', function* h() { 56 | const { hatenaUserName } = yield select(state => state); 57 | const message = { type: 'DOWNLOAD_HATEBU', payload: hatenaUserName }; 58 | yield browser.storage.local.set({ hatenaUserName }); 59 | yield call(sendMessageToBackground, message); 60 | }); 61 | } 62 | 63 | function* watchTheme() { 64 | yield takeEvery('SET_THEME', function* h() { 65 | const { theme } = yield select(state => state); 66 | yield browser.storage.local.set({ theme }); 67 | }); 68 | } 69 | 70 | export default function* root() { 71 | yield all([ 72 | fork(dispatchPopupWidth), 73 | fork(dispatchHatenaUserName), 74 | fork(watchWidth), 75 | fork(watchOrderOfCandidates), 76 | fork(watchDefaultNumberOfCandidates), 77 | fork(watchEnableCJKMove), 78 | fork(watchHatenaUserName), 79 | fork(watchTheme), 80 | ]); 81 | } 82 | -------------------------------------------------------------------------------- /src/sagas/popup.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import logger from 'kiroku'; 3 | import { delay } from 'redux-saga'; 4 | import { 5 | fork, 6 | take, 7 | takeEvery, 8 | takeLatest, 9 | call, 10 | put, 11 | select, 12 | all, 13 | } from 'redux-saga/effects'; 14 | import { 15 | router, 16 | createHashHistory, 17 | } from 'redux-saga-router'; 18 | import { 19 | getPort, 20 | createPortChannel, 21 | } from '../utils/port'; 22 | import { sendMessageToActiveContentTabViaBackground } from '../utils/tabs'; 23 | import { query as queryActions } from '../actions'; 24 | import { watchKeySequence } from './key_sequence'; 25 | import { beginningOfLine } from '../cursor'; 26 | 27 | const history = createHashHistory(); 28 | const portName = `popup-${Date.now()}`; 29 | export const port = getPort(portName); 30 | 31 | export const debounceDelayMs = 100; 32 | 33 | export const modeSelector = state => state.mode; 34 | export const candidateSelector = state => state.prev && state.prev.candidate; 35 | 36 | export function close() { 37 | if (window.parent !== window) { 38 | window.parent.postMessage(JSON.stringify({ type: 'CLOSE' }), '*'); 39 | } else { 40 | window.close(); 41 | } 42 | } 43 | 44 | export function sendMessageToBackground(message) { 45 | return browser.runtime.sendMessage(message); 46 | } 47 | 48 | export function* executeAction(action, candidates) { 49 | if (!action || candidates.length === 0) { 50 | return; 51 | } 52 | try { 53 | const payload = { actionId: action.id, candidates }; 54 | const message = { type: 'EXECUTE_ACTION', payload }; 55 | yield call(sendMessageToBackground, message); 56 | yield call(sendMessageToActiveContentTabViaBackground, message); 57 | } catch (e) { 58 | logger.error(e); 59 | } finally { 60 | close(); 61 | } 62 | } 63 | 64 | export function* responseArg(payload) { 65 | yield call(sendMessageToBackground, { type: 'RESPONSE_ARG', payload }); 66 | } 67 | 68 | export function* dispatchEmptyQuery() { 69 | yield put({ type: 'QUERY', payload: '' }); 70 | } 71 | 72 | export function* searchCandidates({ payload: query }) { 73 | yield call(delay, debounceDelayMs); 74 | const candidate = yield select(candidateSelector); 75 | const mode = yield select(modeSelector); 76 | switch (mode) { 77 | case 'candidate': { 78 | const payload = yield call(sendMessageToBackground, { 79 | type: 'SEARCH_CANDIDATES', 80 | payload: query, 81 | }); 82 | yield put({ type: 'CANDIDATES', payload }); 83 | break; 84 | } 85 | case 'action': { 86 | const separators = [{ label: `Actions for "${candidate.label}"`, index: 0 }]; 87 | const items = queryActions(candidate.type, query); 88 | yield put({ type: 'CANDIDATES', payload: { items, separators } }); 89 | break; 90 | } 91 | case 'arg': { 92 | const values = yield select(state => state.scheme.enum); 93 | const items = (values || []).filter(o => o.label.includes(query)); 94 | yield put({ 95 | type: 'CANDIDATES', 96 | payload: { items, separators: [] }, 97 | }); 98 | break; 99 | } 100 | default: 101 | break; 102 | } 103 | } 104 | 105 | function* watchQuery() { 106 | yield takeLatest('QUERY', searchCandidates); 107 | } 108 | 109 | function* watchPort() { 110 | const portChannel = yield call(createPortChannel, port); 111 | 112 | for (;;) { 113 | const { type, payload } = yield take(portChannel); 114 | yield put({ type, payload }); 115 | } 116 | } 117 | 118 | function* watchChangeCandidate() { 119 | const actions = ['QUERY', 'NEXT_CANDIDATE', 'PREVIOUS_CANDIDATE']; 120 | yield takeEvery(actions, function* handleChangeCandidate() { 121 | const { index, items } = yield select(state => state.candidates); 122 | const candidate = items[index]; 123 | sendMessageToActiveContentTabViaBackground({ type: 'CHANGE_CANDIDATE', payload: candidate }) 124 | .catch(() => {}); 125 | }); 126 | } 127 | 128 | export function* normalizeCandidate(candidate) { 129 | if (!candidate) { 130 | return null; 131 | } 132 | if (candidate.type === 'search') { 133 | const q = yield select(state => state.query); 134 | return Object.assign({}, candidate, { args: [q] }); 135 | } 136 | return Object.assign({}, candidate); 137 | } 138 | 139 | function getMarkedCandidates({ markedCandidateIds, items }) { 140 | return Object.entries(markedCandidateIds) 141 | .map(([k, v]) => v && items.find(i => i.id === k)) 142 | .filter(item => item); 143 | } 144 | 145 | export function* getTargetCandidates({ markedCandidateIds, items, index }, needNormalize = false) { 146 | const marked = getMarkedCandidates({ markedCandidateIds, items }); 147 | if (marked.length > 0) { 148 | return marked; 149 | } 150 | if (needNormalize) { 151 | return [yield normalizeCandidate(items[index])]; 152 | } 153 | return [items[index]]; 154 | } 155 | 156 | function* watchSelectCandidate() { 157 | yield takeEvery('SELECT_CANDIDATE', function* handleSelectCandidate({ payload }) { 158 | const { mode, prev } = yield select(state => state); 159 | let action; 160 | switch (mode) { 161 | case 'candidate': { 162 | const c = yield normalizeCandidate(payload); 163 | [action] = queryActions(c.type); 164 | yield executeAction(action, [c]); 165 | break; 166 | } 167 | case 'action': { 168 | action = payload; 169 | const candidates = yield getTargetCandidates(prev); 170 | yield executeAction(action, candidates); 171 | break; 172 | } 173 | case 'arg': { 174 | const c = yield normalizeCandidate(payload); 175 | yield responseArg([c]); 176 | break; 177 | } 178 | default: 179 | break; 180 | } 181 | }); 182 | } 183 | 184 | function* watchReturn() { 185 | yield takeEvery('RETURN', function* handleReturn({ payload: { actionIndex } }) { 186 | const { 187 | candidates: { index, items }, 188 | mode, markedCandidateIds, prev, 189 | } = yield select(state => state); 190 | switch (mode) { 191 | case 'candidate': { 192 | const candidates = yield getTargetCandidates({ index, items, markedCandidateIds }, true); 193 | const actions = queryActions(candidates[0].type); 194 | const action = actions[Math.min(actionIndex, actions.length - 1)]; 195 | yield executeAction(action, candidates); 196 | break; 197 | } 198 | case 'action': { 199 | const action = items[index]; 200 | const candidates = yield getTargetCandidates(prev); 201 | yield executeAction(action, candidates); 202 | break; 203 | } 204 | case 'arg': { 205 | const type = yield select(state => state.scheme.type); 206 | let payload = yield select(state => state.query); 207 | if (type === 'object') { 208 | payload = yield getTargetCandidates({ index, items, markedCandidateIds }); 209 | } 210 | yield responseArg(payload); 211 | break; 212 | } 213 | default: 214 | break; 215 | } 216 | }); 217 | } 218 | 219 | function* watchListActions() { 220 | /* eslint-disable object-curly-newline */ 221 | yield takeEvery('LIST_ACTIONS', function* handleListActions() { 222 | const { 223 | candidates: { index, items }, 224 | query, separators, markedCandidateIds, mode, prev, 225 | } = yield select(state => state); 226 | switch (mode) { 227 | case 'candidate': { 228 | const candidate = yield normalizeCandidate(items[index]); 229 | if (!candidate) { 230 | return; 231 | } 232 | yield put({ 233 | type: 'SAVE_CANDIDATES', 234 | payload: { candidate, query, index, items, separators, markedCandidateIds }, 235 | }); 236 | yield call(searchCandidates, { payload: '' }); 237 | break; 238 | } 239 | case 'action': 240 | yield put({ type: 'RESTORE_CANDIDATES', payload: prev }); 241 | break; 242 | case 'arg': 243 | break; 244 | default: 245 | break; 246 | } 247 | }); 248 | } 249 | 250 | function* watchMarkCandidate() { 251 | yield takeEvery('MARK_CANDIDATE', function* handleMarkCandidate() { 252 | const { mode, candidates: { index, items } } = yield select(state => state); 253 | if (mode === 'action') { 254 | return; 255 | } 256 | const candidate = yield normalizeCandidate(items[index]); 257 | yield put({ type: 'CANDIDATE_MARKED', payload: candidate }); 258 | }); 259 | } 260 | 261 | function* watchMarkAllCandidates() { 262 | yield takeEvery('MARK_ALL_CANDIDATES', function* handleMarkAllCandidates() { 263 | const { mode, candidates: { index, items } } = yield select(state => state); 264 | if (mode === 'action') { 265 | return; 266 | } 267 | const { type } = items[index]; 268 | yield put({ type: 'CANDIDATES_MARKED', payload: items.filter(c => c.type === type) }); 269 | }); 270 | } 271 | 272 | function* watchRequestArg() { 273 | yield takeEvery('REQUEST_ARG', function* handleRequestArg({ payload }) { 274 | const { scheme: { default: defaultValue } } = payload; 275 | yield put({ type: 'QUERY', payload: defaultValue || '' }); 276 | beginningOfLine(); 277 | }); 278 | } 279 | 280 | /** 281 | * Currently, we can't focus to an input form after tab changed. 282 | * So, we just close window. 283 | * If this restriction is change, we need to flag on. 284 | */ 285 | function* watchTabChange() { 286 | yield takeLatest('TAB_CHANGED', function* h({ payload = {} }) { 287 | if (!payload.canFocusToPopup) { 288 | close(); 289 | } else { 290 | yield call(delay, debounceDelayMs); 291 | document.querySelector('.commandInput').focus(); 292 | const query = yield select(state => state.query); 293 | const items = yield call(sendMessageToBackground, { 294 | type: 'SEARCH_CANDIDATES', 295 | payload: query, 296 | }); 297 | yield put({ type: 'CANDIDATES', payload: items }); 298 | } 299 | }); 300 | } 301 | 302 | function* watchQuit() { 303 | yield takeLatest('QUIT', close); 304 | } 305 | 306 | function* routerSaga() { 307 | yield fork(router, history, {}); 308 | } 309 | 310 | export default function* root() { 311 | yield all([ 312 | fork(watchTabChange), 313 | fork(watchQuery), 314 | fork(watchKeySequence), 315 | fork(watchChangeCandidate), 316 | fork(watchSelectCandidate), 317 | fork(watchReturn), 318 | fork(watchListActions), 319 | fork(watchMarkCandidate), 320 | fork(watchMarkAllCandidates), 321 | fork(watchRequestArg), 322 | fork(watchQuit), 323 | fork(watchPort), 324 | fork(routerSaga), 325 | fork(dispatchEmptyQuery), 326 | ]); 327 | } 328 | -------------------------------------------------------------------------------- /src/sources/bookmark.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getFaviconUrl } from '../utils/url'; 3 | import getMessage from '../utils/i18n'; 4 | 5 | function bookmark2candidate(v) { 6 | return { 7 | id: `${v.id}`, 8 | label: `${v.title}:${v.url}`, 9 | type: 'bookmark', 10 | args: [v.url, v.id], 11 | faviconUrl: getFaviconUrl(v.url), 12 | }; 13 | } 14 | 15 | function searchOrRecent(q, maxResults) { 16 | if (!q) { 17 | return browser.bookmarks.getRecent(maxResults); 18 | } 19 | return browser.bookmarks.search({ query: q }); 20 | } 21 | 22 | export default function candidates(q, { maxResults } = {}) { 23 | return searchOrRecent(q, maxResults) 24 | .then(l => l.filter(v => v.url).slice(0, maxResults).map(bookmark2candidate)) 25 | .then(items => ({ 26 | items, 27 | label: `${getMessage('bookmarks')} (:bookmark or b)`, 28 | })); 29 | } 30 | -------------------------------------------------------------------------------- /src/sources/command.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-multi-spaces, comma-spacing */ 2 | import browser from 'webextension-polyfill'; 3 | import getMessage from '../utils/i18n'; 4 | 5 | const commands = [ 6 | { name: 'open-options' , icon: 'options' }, 7 | { name: 'go-forward' , icon: 'forward' }, 8 | { name: 'go-back' , icon: 'back' }, 9 | { name: 'go-parent' , icon: 'parent' }, 10 | { name: 'go-root' , icon: 'root' }, 11 | { name: 'reload' , icon: 'reload' }, 12 | { name: 'add-bookmark' , icon: 'bookmark' }, 13 | { name: 'remove-bookmark' , icon: 'bookmark' }, 14 | { name: 'set-zoom' , icon: 'zoom' }, 15 | { name: 'restore-previous-session', icon: 'session' }, 16 | { name: 'manage-cookies' , icon: 'cookie' }, 17 | { name: 'download-hatebu' , icon: 'hatebu' }, 18 | ]; 19 | 20 | export default function candidates(q, { maxResults }) { 21 | const cs = commands.filter(c => c.name.includes(q)).slice(0, maxResults); 22 | return Promise.resolve(cs.map(c => ({ 23 | id: c.name, 24 | label: c.name, 25 | type: 'command', 26 | args: [c.name], 27 | faviconUrl: browser.extension.getURL(`images/${c.icon}.png`), 28 | }))).then(items => ({ 29 | items, 30 | label: `${getMessage('commands')} (:command or c)`, 31 | })); 32 | } 33 | -------------------------------------------------------------------------------- /src/sources/hatena_bookmark.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getFaviconUrl } from '../utils/url'; 3 | import { fetchBookmarks } from '../utils/hatebu'; 4 | import getMessage from '../utils/i18n'; 5 | 6 | const openOptionCommand = { 7 | id: 'hatena-options', 8 | label: `${getMessage('hatena_options_hint')}`, 9 | type: 'command', 10 | args: ['open-options'], 11 | faviconUrl: browser.extension.getURL('images/options.png'), 12 | }; 13 | 14 | export default async function candidates(q, { maxResults } = {}) { 15 | const { hatenaUserName } = await browser.storage.local.get('hatenaUserName'); 16 | if (!hatenaUserName && maxResults !== 0) { 17 | return { 18 | items: [openOptionCommand], 19 | label: `${getMessage('hatena_bookmarks_hint')}`, 20 | }; 21 | } 22 | 23 | // To make search efficient ... 24 | const bookmarks = await fetchBookmarks(hatenaUserName); 25 | const results = []; 26 | for (let i = bookmarks.length - 1; i >= 0; i -= 1) { 27 | const bookmark = bookmarks[i]; 28 | if (bookmark.title.includes(q) 29 | || bookmark.comment.includes(q) 30 | || bookmark.url.includes(q)) { 31 | results.push(bookmark); 32 | } 33 | if (results.length >= maxResults) { 34 | break; 35 | } 36 | } 37 | 38 | const items = results.map((v, id) => ({ 39 | id: `hatenbu-${id}`, 40 | label: `${v.title}:${v.url}:${v.comment}`, 41 | type: 'hatebu', 42 | args: [v.url, v.id], 43 | faviconUrl: getFaviconUrl(v.url), 44 | })); 45 | 46 | return { 47 | items, 48 | label: `${getMessage('hatena_bookmarks')} (:hatebu or hb)`, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/sources/history.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getFaviconUrl } from '../utils/url'; 3 | import getMessage from '../utils/i18n'; 4 | 5 | export default function candidates(q, { maxResults } = {}) { 6 | const startTime = 0; 7 | return browser.history.search({ text: q, startTime, maxResults }) 8 | .then(l => l.map(v => ({ 9 | id: `${v.id}`, 10 | label: `${v.title}:${v.url}`, 11 | type: 'history', 12 | args: [v.url], 13 | faviconUrl: getFaviconUrl(v.url), 14 | }))).then(items => ({ 15 | items, 16 | label: `${getMessage('histories')} (:history or h)`, 17 | })); 18 | } 19 | -------------------------------------------------------------------------------- /src/sources/link.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getFaviconUrl } from '../utils/url'; 3 | import { sendMessageToActiveContentTab } from '../utils/tabs'; 4 | import getMessage from '../utils/i18n'; 5 | 6 | const linkMaxResults = 100; 7 | 8 | export function faviconUrl(link) { 9 | if (link.role === 'link') { 10 | return getFaviconUrl(link.url); 11 | } 12 | return browser.extension.getURL('images/click.png'); 13 | } 14 | 15 | export function getLabel(link) { 16 | const { url, label } = link; 17 | const l = label.trim(); 18 | if (l && url) { 19 | return `${l}: ${url}`; 20 | } 21 | if (l) { 22 | return l; 23 | } 24 | return url; 25 | } 26 | 27 | export default function candidates(query, { maxResults } = {}) { 28 | return sendMessageToActiveContentTab({ 29 | type: 'FETCH_LINKS', 30 | payload: { 31 | query, 32 | maxResults: linkMaxResults, 33 | }, 34 | }).then(links => links.slice(0, maxResults).map((l) => { 35 | const { id } = l; 36 | return { 37 | id, 38 | label: getLabel(l), 39 | type: 'link', 40 | args: [l], 41 | faviconUrl: faviconUrl(l), 42 | }; 43 | })) 44 | .catch(() => []) 45 | .then(items => ({ 46 | items, 47 | label: `${getMessage('links')} (:link or l)`, 48 | })); 49 | } 50 | -------------------------------------------------------------------------------- /src/sources/search.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import getMessage from '../utils/i18n'; 3 | 4 | export default function candidates(q, { maxResults }) { 5 | let query = ''; 6 | if (q) { 7 | query += `${q} ― `; 8 | } 9 | return Promise.resolve([{ 10 | id: `search-${q}`, 11 | label: `${getMessage('search_placeholder', query)}`, 12 | type: 'search', 13 | args: [q], 14 | faviconUrl: browser.extension.getURL('images/search.png'), 15 | }]).then(items => ({ 16 | items: items.slice(0, maxResults), 17 | label: `${getMessage('search')} (:search or s)`, 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /src/sources/session.js: -------------------------------------------------------------------------------- 1 | import { fetch, session2candidate } from '../utils/sessions'; 2 | import getMessage from '../utils/i18n'; 3 | import { includes } from '../utils/string'; 4 | 5 | export default function candidates(q, { maxResults } = {}) { 6 | const hasQuery = v => includes(v.label, q); 7 | return fetch(maxResults) 8 | .then(items => items.map(session2candidate).filter(hasQuery)) 9 | .then(items => items.slice(0, maxResults)) 10 | .then(items => ({ 11 | items, 12 | label: `${getMessage('sessions')} (:session or s)`, 13 | })); 14 | } 15 | -------------------------------------------------------------------------------- /src/sources/tab.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import getMessage from '../utils/i18n'; 3 | import { isExtensionUrl } from '../utils/url'; 4 | import { includes } from '../utils/string'; 5 | 6 | function isCandidate(tab, q) { 7 | const { title: t, url: u } = tab; 8 | return (includes(t, q) || includes(u, q)) && !isExtensionUrl(u); 9 | } 10 | 11 | export default function candidates(q, { maxResults }) { 12 | return browser.tabs.query({}) 13 | .then((l) => { 14 | const items = l.filter(t => isCandidate(t, q)); 15 | return items.slice(0, maxResults).map(t => ({ 16 | id: `${t.id}`, 17 | label: `${t.title}: ${t.url}`, 18 | type: 'tab', 19 | args: [t.id, t.windowId], 20 | faviconUrl: t.favIconUrl, 21 | })); 22 | }).then(items => ({ 23 | items, 24 | label: `${getMessage('tabs')} (:tab or t)`, 25 | })); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/app.js: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | 3 | export function start(container, element, sagaMiddleware, rootSaga) { 4 | const task = sagaMiddleware.run(rootSaga); 5 | ReactDOM.render(element, container); 6 | return { container, task }; 7 | } 8 | 9 | export function stop({ container, task }) { 10 | ReactDOM.unmountComponentAtNode(container); 11 | task.cancel(); 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/args.js: -------------------------------------------------------------------------------- 1 | let postMessage = () => {}; 2 | let argListener = () => {}; 3 | 4 | export function getArgListener() { 5 | return argListener; 6 | } 7 | 8 | export function setPostMessageFunction(f) { 9 | postMessage = f; 10 | } 11 | 12 | export const Type = { 13 | boolean: 'boolean', 14 | string: 'string', 15 | integer: 'integer', 16 | number: 'number', 17 | array: 'array', 18 | object: 'object', 19 | }; 20 | 21 | export function requestArg(scheme) { 22 | return new Promise((resolve, reject) => { 23 | try { 24 | argListener = resolve; 25 | postMessage('REQUEST_ARG', { scheme }); 26 | } catch (e) { 27 | reject(e); 28 | } 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/cookies.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable object-curly-newline, no-param-reassign */ 2 | 3 | import browser from 'webextension-polyfill'; 4 | import { requestArg } from './args'; 5 | 6 | export function cookie2candidate(cookie) { 7 | const { name, value, domain, path } = cookie; 8 | const id = `${name}-${value}-${domain}-${path}`; 9 | return { 10 | id, 11 | label: `${name}:${value}`, 12 | type: 'cookie', 13 | args: [name, value, cookie], 14 | faviconUrl: null, 15 | }; 16 | } 17 | 18 | export async function fetch(url) { 19 | const cookies = await browser.cookies.getAll({ url }); 20 | return cookies.map(cookie2candidate); 21 | } 22 | 23 | export const actions = [ 24 | { id: 'change-value', label: 'change-value', type: 'action', args: [] }, 25 | { id: 'remove-value', label: 'remove-value', type: 'action', args: [] }, 26 | ]; 27 | 28 | function normalize(cookie, options) { 29 | delete cookie.hostOnly; 30 | delete cookie.session; 31 | return Object.assign({}, cookie, options); 32 | } 33 | 34 | async function changeValue(url, cookie) { 35 | const newValue = await requestArg({ 36 | type: 'string', 37 | title: 'Enter new value', 38 | default: cookie.value, 39 | }); 40 | return browser.cookies.set(normalize(cookie, { url, value: newValue })); 41 | } 42 | 43 | export async function manage(url) { 44 | const cookies = await fetch(url); 45 | const selectedCookies = await requestArg({ 46 | type: 'object', 47 | title: 'Cookies in current page', 48 | enum: cookies, 49 | }); 50 | const [action] = await requestArg({ 51 | type: 'object', 52 | title: 'Select an action', 53 | enum: actions, 54 | }); 55 | switch (action.id) { 56 | case 'change-value': { 57 | return changeValue(url, selectedCookies[0].args[2]); 58 | } 59 | case 'remove-value': 60 | return Promise.all(selectedCookies.map((c) => { 61 | const name = c.args[0]; 62 | return browser.cookies.remove({ url, name }); 63 | })); 64 | default: 65 | return null; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/hatebu.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import logger from 'kiroku'; 3 | import Model from './model'; 4 | import idb from './indexedDB'; 5 | import fetchAsText from './http'; 6 | import config from '../config'; 7 | 8 | export const Bookmark = new Model('hatena-bookmarks'); 9 | 10 | const storageKey = 'indexeddb.hatebu'; 11 | const commentRegex = new RegExp('\\s+$', ''); 12 | 13 | function parse(text) { 14 | const infos = text.split('\n'); 15 | const bookmarks = infos.splice(0, infos.length * (3 / 4)); 16 | return { bookmarks, infos, length: infos.length }; 17 | } 18 | 19 | function getBookmarkObj({ bookmarks, infos }, i) { 20 | const index = i * 3; 21 | const timestamp = infos[i].split('\t', 2)[1]; 22 | const title = bookmarks[index]; 23 | const comment = bookmarks[index + 1]; 24 | const url = bookmarks[index + 2]; 25 | const date = parseInt(timestamp, 10); 26 | return { 27 | id: timestamp, 28 | comment: comment.replace(commentRegex, ''), 29 | title, 30 | url, 31 | created_at: date, 32 | updated_at: date, 33 | }; 34 | } 35 | 36 | export function createObjectStore(db) { 37 | return Bookmark.createObjectStore(db); 38 | } 39 | 40 | export function needClear(userName) { 41 | const v = browser.storage.local.get(storageKey); 42 | return !v || !v[storageKey] || v[storageKey].userName !== userName; 43 | } 44 | 45 | export function needDownload(userName) { 46 | const now = Date.now(); 47 | const value = browser.storage.local.get(storageKey); 48 | const duration = 24 * 60 * 60 * 1000; 49 | if (!value || !value[storageKey]) { 50 | return true; 51 | } 52 | return value[storageKey].userName !== userName 53 | || value[storageKey].lastDownloadedAt + duration < now; 54 | } 55 | 56 | export async function downloadBookmarks(userName) { 57 | const url = `http://b.hatena.ne.jp/${userName}/search.data`; 58 | const text = await fetchAsText(url); 59 | logger.info(`Downloaded ${userName} bookmarks`); 60 | const bookmarkList = parse(text); 61 | const db = await idb.open(config.dbName, config.dbVersion); 62 | if (needClear(userName)) { 63 | Bookmark.clear(db); 64 | } 65 | for (let i = bookmarkList.length - 1; i >= 0; i -= 1) { 66 | try { 67 | const obj = getBookmarkObj(bookmarkList, i); 68 | logger.trace(`Saving ${obj.title} to object store for ${userName}`); 69 | // eslint-disable-next-line no-await-in-loop 70 | await Bookmark.create(obj, db); 71 | logger.trace(`Saved ${obj.title} to object store for ${userName}`); 72 | } catch (e) { 73 | logger.trace(e); 74 | return false; 75 | } 76 | } 77 | await browser.storage.local.set(storageKey, { 78 | userName, 79 | lastDownloadedAt: Date.now(), 80 | }); 81 | return true; 82 | } 83 | 84 | export async function fetchBookmarks() { 85 | const db = await idb.open(config.dbName, config.dbVersion); 86 | try { 87 | return await Bookmark.findAll(db); 88 | } catch (e) { 89 | return []; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/http.js: -------------------------------------------------------------------------------- 1 | /* global fetch: false */ 2 | 3 | export default async function fetchAsText(url) { 4 | const response = await fetch(url); 5 | if (!response.ok) { 6 | return Promise.reject(response); 7 | } 8 | return response.text(); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/i18n.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | export default function getMessage(key, substitutions = '') { 4 | return browser.i18n.getMessage(key, substitutions); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/indexedDB.js: -------------------------------------------------------------------------------- 1 | /* global indexedDB: false */ 2 | 3 | function open(dbName, version = 1) { 4 | return new Promise((resolve, reject) => { 5 | const request = indexedDB.open(dbName, version, { storage: 'persistent' }); 6 | request.onerror = event => reject(event); 7 | request.onsuccess = event => resolve(event.target.result); 8 | }); 9 | } 10 | 11 | function upgrade(dbName, version = 1, callback) { 12 | return new Promise((resolve, reject) => { 13 | const request = indexedDB.open(dbName, version, { storage: 'persistent' }); 14 | request.onerror = event => reject(event); 15 | request.onsuccess = event => resolve(event.target.result); 16 | request.onupgradeneeded = (event) => { 17 | callback(event.target.result).then(() => { 18 | resolve(event.target.result); 19 | }); 20 | }; 21 | }); 22 | } 23 | 24 | function transactionComplete(store) { 25 | /* eslint-disable no-param-reassign */ 26 | return new Promise((resolve) => { 27 | store.transaction.oncomplete = resolve; 28 | }); 29 | } 30 | 31 | function destroy(dbName) { 32 | return new Promise((resolve, reject) => { 33 | const request = indexedDB.deleteDatabase(dbName); 34 | request.onerror = event => reject(event); 35 | request.onsuccess = event => resolve(event.target.result); 36 | }); 37 | } 38 | 39 | export default { 40 | open, 41 | upgrade, 42 | transactionComplete, 43 | destroy, 44 | }; 45 | -------------------------------------------------------------------------------- /src/utils/model.js: -------------------------------------------------------------------------------- 1 | import idb from './indexedDB'; 2 | 3 | export default function Model(name) { 4 | this.name = name; 5 | } 6 | 7 | Model.prototype.createObjectStore = function createObjectStore(db) { 8 | const objectStore = db.createObjectStore(this.name, { keyPath: 'id' }); 9 | objectStore.createIndex('created_at', 'created_at', { unique: false }); 10 | objectStore.createIndex('updated_at', 'updated_at', { unique: false }); 11 | return idb.transactionComplete(objectStore); 12 | }; 13 | 14 | Model.prototype.objectStore = function objectStore(db) { 15 | return db.transaction(this.name, 'readwrite').objectStore(this.name); 16 | }; 17 | 18 | Model.prototype.findAll = function findAll(db) { 19 | const store = this.objectStore(db); 20 | const items = []; 21 | return new Promise((resolve) => { 22 | const c = store.openCursor(); 23 | c.onsuccess = (event) => { 24 | const cursor = event.target.result; 25 | if (cursor) { 26 | items.push(cursor.value); 27 | cursor.continue(); 28 | } else { 29 | resolve(items); 30 | } 31 | }; 32 | }); 33 | }; 34 | 35 | Model.prototype.findById = function findById(id, db) { 36 | const store = this.objectStore(db); 37 | return new Promise((resolve, reject) => { 38 | const request = store.get(id); 39 | request.onerror = reject; 40 | request.onsuccess = () => resolve(request.result); 41 | }); 42 | }; 43 | 44 | Model.prototype.create = function create(data, db) { 45 | /* eslint-disable no-param-reassign */ 46 | const store = this.objectStore(db); 47 | if (!data.created_at) { 48 | data.created_at = new Date(); 49 | } 50 | if (!data.updated_at) { 51 | data.updated_at = new Date(); 52 | } 53 | return new Promise((resolve, reject) => { 54 | const request = store.add(data); 55 | request.onerror = reject; 56 | request.onsuccess = resolve; 57 | }).then(() => data); 58 | }; 59 | 60 | Model.prototype.findOrCreateById = function findOrCreateById(data, db) { 61 | return this.findById(data.id, db).then((item) => { 62 | if (item) { 63 | return item; 64 | } 65 | return this.create(data, db); 66 | }); 67 | }; 68 | 69 | Model.prototype.update = function update(data, db) { 70 | /* eslint-disable no-param-reassign */ 71 | if (!data.updated_at) { 72 | data.updated_at = new Date(); 73 | } 74 | const store = this.objectStore(db); 75 | return new Promise((resolve, reject) => { 76 | const request = store.put(data); 77 | request.onerror = reject; 78 | request.onsuccess = resolve; 79 | }).then(() => data); 80 | }; 81 | 82 | Model.prototype.destroy = function destroy(id, db) { 83 | const store = this.objectStore(db); 84 | return new Promise((resolve, reject) => { 85 | const request = store.delete(id); 86 | request.onerror = reject; 87 | request.onsuccess = () => { 88 | resolve(request.result); 89 | }; 90 | }); 91 | }; 92 | 93 | Model.prototype.clear = function clear(db) { 94 | const store = this.objectStore(db); 95 | return new Promise((resolve, reject) => { 96 | const request = store.clear(); 97 | request.onerror = reject; 98 | request.onsuccess = resolve; 99 | }); 100 | }; 101 | -------------------------------------------------------------------------------- /src/utils/options_migrator.js: -------------------------------------------------------------------------------- 1 | import { defaultOrder } from '../reducers/options'; 2 | import { MAX_RESULTS_FOR_EMPTY } from '../candidates'; 3 | 4 | // eslint-disable-next-line no-param-reassign 5 | function migrateMaxResultsForEmpty(state) { 6 | return defaultOrder.reduce((acc, t) => { 7 | if (acc[t] === undefined) { 8 | return Object.assign(acc, { [t]: MAX_RESULTS_FOR_EMPTY }); 9 | } 10 | return acc; 11 | }, Object.assign({}, state)); 12 | } 13 | 14 | // eslint-disable-next-line no-param-reassign 15 | function migrateOrderOfCandidates(state) { 16 | return state.slice().concat(defaultOrder.filter(v => !state.includes(v))); 17 | } 18 | 19 | const migrator = { 20 | maxResultsForEmpty: migrateMaxResultsForEmpty, 21 | orderOfCandidates: migrateOrderOfCandidates, 22 | }; 23 | 24 | export default function migrate(state) { 25 | Object.keys(state).forEach((key) => { 26 | if (migrator[key]) { 27 | // eslint-disable-next-line no-param-reassign 28 | state[key] = migrator[key](state[key]); 29 | } 30 | }); 31 | return state; 32 | } 33 | -------------------------------------------------------------------------------- /src/utils/port.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { eventChannel } from 'redux-saga'; 3 | 4 | const ports = {}; 5 | 6 | export function createPortChannel(p) { 7 | return eventChannel((emit) => { 8 | const messageHandler = (event) => { 9 | emit(event); 10 | }; 11 | p.onMessage.addListener(messageHandler); 12 | const removeEventListener = () => { 13 | p.onMessage.removeListener(messageHandler); 14 | }; 15 | return removeEventListener; 16 | }); 17 | } 18 | 19 | export function getPort(name) { 20 | if (ports[name]) { 21 | return ports[name]; 22 | } 23 | const port = browser.runtime.connect({ name }); 24 | ports[name] = port; 25 | return port; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/sessions.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getFaviconUrl } from './url'; 3 | 4 | let MAX_SESSION_RESULTS = 20; 5 | if (browser.sessions) { 6 | ({ MAX_SESSION_RESULTS } = browser.sessions); 7 | } 8 | 9 | export function session2candidate(session) { 10 | const { tab, window } = session; 11 | if (tab) { 12 | return { 13 | id: `session-tab-${tab.sessionId}-${tab.windowId}-${tab.index}`, 14 | label: `${tab.title}:${tab.url}`, 15 | type: 'session', 16 | args: [tab.sessionId, 'tab', tab.windowId, tab.index], 17 | faviconUrl: getFaviconUrl(tab.url), 18 | }; 19 | } 20 | const t = window.tabs[0]; 21 | const title = `${t.title} + ${window.tabs.length - 1} tabs`; 22 | return { 23 | id: `session-window-${window.sessionId}`, 24 | label: title, 25 | type: 'session', 26 | args: [window.sessionId, 'window'], 27 | faviconUrl: getFaviconUrl(t.url), 28 | }; 29 | } 30 | 31 | export function fetch(maxResults = MAX_SESSION_RESULTS) { 32 | return browser.sessions.getRecentlyClosed({ 33 | maxResults: Math.min(maxResults, MAX_SESSION_RESULTS), 34 | }); 35 | } 36 | 37 | export function restore(candidates) { 38 | return Promise.all(candidates.map((candidate) => { 39 | const sessionId = candidate.args[0]; 40 | return browser.sessions.restore(sessionId); 41 | })); 42 | } 43 | 44 | export function forget(candidates) { 45 | return Promise.all(candidates.map((candidate) => { 46 | const sessionId = candidate.args[0]; 47 | if (candidate.args[1] === 'tab') { 48 | return browser.sessions.forgetClosedTab(candidate.args[2], sessionId); 49 | } 50 | return browser.sessions.forgetClosedWindow(sessionId); 51 | })); 52 | } 53 | 54 | export async function restorePrevious() { 55 | const [session] = await fetch(); 56 | if (session.tab) { 57 | return browser.sessions.restore(session.tab.sessionId); 58 | } 59 | return browser.sessions.restore(session.window.sessionId); 60 | } 61 | -------------------------------------------------------------------------------- /src/utils/string.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export function includes(str1, str2) { 3 | return str1.toUpperCase().includes(str2.toUpperCase()); 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/tabs.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | import { getActiveTabId } from '../popup_window'; 3 | 4 | export function getActiveTab() { 5 | const options = { currentWindow: true, active: true }; 6 | return browser.tabs.query(options).then((tabs) => { 7 | if (tabs.length > 0) { 8 | return tabs[0]; 9 | } 10 | return null; 11 | }); 12 | } 13 | 14 | export function getActiveContentTab() { 15 | const activeTabId = getActiveTabId(); 16 | if (activeTabId) { 17 | return browser.tabs.get(activeTabId); 18 | } 19 | return getActiveTab(); 20 | } 21 | 22 | export function sendMessageToActiveContentTab(msg) { 23 | return getActiveContentTab().then(t => browser.tabs.sendMessage(t.id, msg)); 24 | } 25 | 26 | export function sendMessageToActiveContentTabViaBackground(msg) { 27 | const type = 'SEND_MESSAGE_TO_ACTIVE_CONTENT_TAB'; 28 | return browser.runtime.sendMessage({ type, payload: msg }); 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/url.js: -------------------------------------------------------------------------------- 1 | import browser from 'webextension-polyfill'; 2 | 3 | const { URL } = window; 4 | const faviconUrl = 'https://s2.googleusercontent.com/s2/favicons'; 5 | let browserInfo = { name: 'chrome' }; 6 | 7 | export function init() { 8 | if (browser.runtime.getBrowserInfo) { 9 | return browser.runtime.getBrowserInfo().then((info) => { 10 | browserInfo = info; 11 | }); 12 | } 13 | return Promise.resolve(); 14 | } 15 | 16 | export function extractDomain(url) { 17 | if (!url || url.startsWith('moz-extension:') || url.startsWith('file:')) { 18 | return null; 19 | } 20 | try { 21 | return new URL(url).hostname; 22 | } catch (e) { 23 | return null; 24 | } 25 | } 26 | 27 | export function getFaviconUrl(url) { 28 | switch (browserInfo.name) { 29 | case 'chrome': 30 | return `chrome://favicon/${url}`; 31 | default: { 32 | const domain = extractDomain(url); 33 | if (domain) { 34 | return `${faviconUrl}?domain=${domain}`; 35 | } 36 | return null; 37 | } 38 | } 39 | } 40 | 41 | export function isExtensionUrl(url) { 42 | return url.startsWith('chrome-extension') || url.startsWith('moz-extension'); 43 | } 44 | 45 | init(); 46 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }] 4 | } 5 | } -------------------------------------------------------------------------------- /test/actions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | activateTab, 4 | closeTab, 5 | openUrlsInNewTab, 6 | openUrl, 7 | clickLink, 8 | openLinkInNewTab, 9 | openLinkInNewWindow, 10 | openLinkInPrivateWindow, 11 | search, 12 | searchInNewTab, 13 | deleteHistory, 14 | deleteBookmark, 15 | runCommand, 16 | } from '../src/actions'; 17 | 18 | const tabArgs = [1]; 19 | const urlArgs = ['http://example.com']; 20 | const linkArgs = [{ url: 'http://example.com' }]; 21 | const searchArgs = ['query']; 22 | 23 | test('activateTab', (t) => { 24 | activateTab([{ type: 'tab', args: tabArgs }]); 25 | activateTab([]); 26 | t.pass(); 27 | }); 28 | 29 | test('closeTab', (t) => { 30 | closeTab([{ type: 'tab', args: tabArgs }]); 31 | closeTab([]); 32 | t.pass(); 33 | }); 34 | 35 | test('openUrlsInNewTab', (t) => { 36 | openUrlsInNewTab([{ type: 'history', args: urlArgs }]); 37 | openUrlsInNewTab([]); 38 | t.pass(); 39 | }); 40 | 41 | test('openUrl', (t) => { 42 | openUrl([{ type: 'history', args: urlArgs }]); 43 | openUrl([]); 44 | t.pass(); 45 | }); 46 | 47 | test('clickLink', (t) => { 48 | clickLink([{ type: 'link', args: linkArgs }]); 49 | clickLink([]); 50 | t.pass(); 51 | }); 52 | 53 | test('openLinkInNewTab', (t) => { 54 | openLinkInNewTab([{ type: 'link', args: linkArgs }]); 55 | openLinkInNewTab([]); 56 | t.pass(); 57 | }); 58 | 59 | test('openLinkInNewWindow', (t) => { 60 | openLinkInNewWindow([{ type: 'link', args: linkArgs }]); 61 | openLinkInNewWindow([]); 62 | t.pass(); 63 | }); 64 | 65 | test('openLinkInPrivateWindow', (t) => { 66 | openLinkInPrivateWindow([{ type: 'link', args: linkArgs }]); 67 | openLinkInPrivateWindow([]); 68 | t.pass(); 69 | }); 70 | 71 | test('search', (t) => { 72 | search([{ type: 'search', args: searchArgs }]); 73 | search([]); 74 | t.pass(); 75 | }); 76 | 77 | test('searchInNewTab', (t) => { 78 | searchInNewTab([{ type: 'search', args: searchArgs }]); 79 | searchInNewTab([]); 80 | t.pass(); 81 | }); 82 | 83 | test('deleteHistory', (t) => { 84 | deleteHistory([{ type: 'history', args: urlArgs }]); 85 | deleteHistory([]); 86 | t.pass(); 87 | }); 88 | 89 | test('deleteHBookmark', (t) => { 90 | deleteBookmark([{ type: 'history', args: urlArgs }]); 91 | deleteBookmark([]); 92 | t.pass(); 93 | }); 94 | 95 | test('runCommand', (t) => { 96 | runCommand([{ type: 'command', args: ['open-options'] }]); 97 | runCommand([{ type: 'command', args: ['go-forward'] }]); 98 | runCommand([{ type: 'command', args: ['go-back'] }]); 99 | runCommand([{ type: 'command', args: ['go-parent'] }]); 100 | runCommand([{ type: 'command', args: ['go-root'] }]); 101 | runCommand([{ type: 'command', args: ['reload'] }]); 102 | runCommand([{ type: 'command', args: ['add-bookmark'] }]); 103 | runCommand([{ type: 'command', args: ['remove-bookmark'] }]); 104 | runCommand([{ type: 'command', args: ['unknown'] }]); 105 | t.pass(); 106 | }); 107 | -------------------------------------------------------------------------------- /test/background.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import browser from 'webextension-polyfill'; 3 | import { 4 | init, 5 | getContentScriptPorts, 6 | getPopupPorts, 7 | messageListener, 8 | commandListener, 9 | storageChangedListener, 10 | } from '../src/background'; 11 | import { getPopupWindow } from '../src/popup_window'; 12 | import createPort from './create_port'; 13 | 14 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 15 | 16 | const { onConnect, onMessage } = browser.runtime; 17 | const { onCommand } = browser.commands; 18 | const { onFocusChanged } = browser.windows; 19 | const { onActivated, onRemoved } = browser.tabs; 20 | const { onChanged } = browser.storage; 21 | 22 | const onConnectPort = createPort(); 23 | const onMessagePort = createPort(); 24 | const onCommandPort = createPort(); 25 | const onRemovedPort = createPort(); 26 | const onFocusChangedPort = createPort(); 27 | const onActivatedPort = createPort(); 28 | const onChangedPort = createPort(); 29 | 30 | async function setup() { 31 | browser.runtime.onConnect = onConnectPort.onMessage; 32 | browser.runtime.onMessage = onMessagePort.onMessage; 33 | browser.commands.onCommand = onCommandPort.onMessage; 34 | browser.windows.onFocusChanged = onFocusChangedPort.onMessage; 35 | browser.tabs.onActivated = onActivatedPort.onMessage; 36 | browser.tabs.onRemoved = onRemovedPort.onMessage; 37 | browser.storage.onChanged = onChangedPort.onMessage; 38 | await init(); 39 | await delay(10); 40 | } 41 | 42 | function restore() { 43 | browser.runtime.onConnect = onConnect; 44 | browser.runtime.onMessage = onMessage; 45 | browser.commands.onCommand = onCommand; 46 | browser.windows.onFocusChanged = onFocusChanged; 47 | browser.tabs.onActivated = onActivated; 48 | browser.tabs.onRemoved = onRemoved; 49 | browser.storage.onChanged = onChanged; 50 | } 51 | 52 | test.before(setup); 53 | test.after(restore); 54 | 55 | test.serial('background', (t) => { 56 | t.pass(); 57 | }); 58 | 59 | test.serial('onCommand listener execute toggle_popup_window', (t) => { 60 | const promises = onCommandPort.messageListeners.map((l) => { 61 | l('toggle_popup_window'); 62 | return delay(10).then(() => t.truthy(getPopupWindow())); 63 | }); 64 | return Promise.all(promises); 65 | }); 66 | 67 | test.serial('onCommand listener do nothing', (t) => { 68 | onCommandPort.messageListeners.forEach((l) => { 69 | l('unknown_command'); 70 | t.pass(); 71 | }); 72 | }); 73 | 74 | test.serial('handle ACTIVE_CONTENT_TAB message', (t) => { 75 | onMessagePort.messageListeners.forEach((l) => { 76 | l({ type: 'ACTIVE_CONTENT_TAB' }); 77 | t.pass(); 78 | }); 79 | }); 80 | 81 | test.serial('handle tabs.onActivated event', (t) => { 82 | onActivatedPort.messageListeners.forEach((l) => { 83 | l({ tabId: 1, windowId: 1 }); 84 | t.pass(); 85 | }); 86 | }); 87 | 88 | test.serial('handle window focus changed event', (t) => { 89 | onFocusChangedPort.messageListeners.forEach((l) => { 90 | l(1); 91 | l(2); 92 | t.pass(); 93 | }); 94 | }); 95 | 96 | test.serial('manage content script ports', (t) => { 97 | let contentDisconnectHandler = null; 98 | let popupDisconnectHandler = null; 99 | onConnectPort.messageListeners.forEach((l) => { 100 | l({ 101 | name: 'content-script-0000', 102 | onDisconnect: { 103 | addListener: (listener) => { 104 | contentDisconnectHandler = listener; 105 | }, 106 | }, 107 | onMessage: { 108 | addListener: () => {}, 109 | removeListener: () => {}, 110 | }, 111 | }); 112 | l({ 113 | name: 'popup-0000', 114 | onDisconnect: { 115 | addListener: (listener) => { 116 | popupDisconnectHandler = listener; 117 | }, 118 | }, 119 | onMessage: { 120 | addListener: () => {}, 121 | removeListener: () => {}, 122 | }, 123 | }); 124 | }); 125 | t.is(getPopupPorts().length, 1); 126 | t.is(getContentScriptPorts().length, 1); 127 | 128 | popupDisconnectHandler(); 129 | contentDisconnectHandler(); 130 | 131 | t.is(getPopupPorts().length, 0); 132 | t.is(getContentScriptPorts().length, 0); 133 | }); 134 | 135 | test('messageListener', (t) => { 136 | messageListener({ type: 'SEND_MESSAGE_TO_ACTIVE_CONTENT_TAB' }); 137 | messageListener({ type: 'SEARCH_CANDIDATES', payload: '' }); 138 | messageListener({ 139 | type: 'EXECUTE_ACTION', 140 | payload: { 141 | actionId: 'google-search', 142 | candidates: [], 143 | }, 144 | }); 145 | t.pass(); 146 | }); 147 | 148 | test('commandListener', (t) => { 149 | commandListener('toggle_popup_window'); 150 | commandListener('toggle_content_popup'); 151 | t.pass(); 152 | }); 153 | 154 | test('storageChangedListener', async (t) => { 155 | await storageChangedListener(); 156 | t.pass(); 157 | }); 158 | -------------------------------------------------------------------------------- /test/browser_mock.js: -------------------------------------------------------------------------------- 1 | const createPort = require('./create_port'); 2 | 3 | const browser = {}; 4 | 5 | browser.i18n = {}; 6 | browser.i18n.getMessage = key => key; 7 | 8 | browser.extension = {}; 9 | browser.extension.getURL = key => `moz-extension://extension-id/${key}`; 10 | 11 | browser.runtime = {}; 12 | browser.runtime.connect = createPort; 13 | 14 | const port = createPort(); 15 | 16 | browser.runtime.onConnect = { 17 | addListener: () => {}, 18 | removeListener: () => {}, 19 | }; 20 | browser.runtime.onMessage = port.onMessage; 21 | browser.runtime.browserInfo = () => Promise.resolve({ name: 'Firefox' }); 22 | browser.runtime.sendMessage = () => Promise.resolve(); 23 | browser.runtime.openOptionsPage = () => Promise.resolve(); 24 | 25 | browser.storage = { 26 | local: { 27 | get: () => Promise.resolve({}), 28 | set: () => Promise.resolve({}), 29 | }, 30 | onChanged: { 31 | addListener: () => {}, 32 | removeListener: () => {}, 33 | }, 34 | }; 35 | 36 | browser.history = { 37 | search: () => Promise.resolve([]), 38 | deleteUrl: () => Promise.resolve(), 39 | }; 40 | 41 | browser.bookmarks = { 42 | search: () => Promise.resolve([]), 43 | remove: () => Promise.resolve(), 44 | create: () => Promise.resolve(), 45 | getRecent: () => Promise.resolve([]), 46 | }; 47 | 48 | browser.tabs = { 49 | get: () => Promise.resolve(), 50 | create: () => Promise.resolve(), 51 | update: () => Promise.resolve(), 52 | remove: () => Promise.resolve(), 53 | query: () => Promise.resolve([{ id: 1, url: 'http://example.com', title: '' }]), 54 | sendMessage: () => Promise.resolve(), 55 | onActivated: { 56 | addListener: () => {}, 57 | removeListener: () => {}, 58 | }, 59 | onRemoved: { 60 | addListener: () => {}, 61 | removeListener: () => {}, 62 | }, 63 | }; 64 | 65 | browser.windows = { 66 | create: () => Promise.resolve({ id: 1 }), 67 | remove: () => Promise.resolve(), 68 | onRemoved: { 69 | addListener: () => {}, 70 | removeListener: () => {}, 71 | }, 72 | onFocusChanged: { 73 | addListener: () => {}, 74 | removeListener: () => {}, 75 | }, 76 | }; 77 | 78 | browser.sessions = { 79 | MAX_SESSION_RESULTS: 25, 80 | getRecentlyClosed: () => Promise.resolve([]), 81 | restore: () => Promise.resolve(), 82 | forgetClosedTab: () => Promise.resolve(), 83 | forgetClosedWindow: () => Promise.resolve(), 84 | }; 85 | 86 | browser.cookies = { 87 | getAll: () => Promise.resolve(), 88 | set: () => Promise.resolve(), 89 | remove: () => Promise.resolve(), 90 | }; 91 | 92 | browser.commands = { 93 | onCommand: { 94 | addListener: () => {}, 95 | removeListener: () => {}, 96 | }, 97 | }; 98 | 99 | browser.system = { 100 | display: { 101 | getInfo: callback => callback([{ 102 | bounds: { 103 | left: 0, 104 | top: 0, 105 | width: 100, 106 | height: 100, 107 | }, 108 | }]), 109 | }, 110 | }; 111 | 112 | module.exports = browser; 113 | -------------------------------------------------------------------------------- /test/candidates.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parse, init } from '../src/candidates'; 3 | 4 | test('parse returns query type and value', (t) => { 5 | /* eslint-disable no-multi-spaces, comma-spacing */ 6 | init(); 7 | t.deepEqual(parse('') , { type: null , value: '' }); 8 | t.deepEqual(parse('aaaa') , { type: null , value: 'aaaa' }); 9 | t.deepEqual(parse(':link') , { type: 'link', value: '' }); 10 | t.deepEqual(parse(':link aaaa') , { type: 'link', value: 'aaaa' }); 11 | t.deepEqual(parse(':link aaaa bbbb'), { type: 'link', value: 'aaaa bbbb' }); 12 | t.deepEqual(parse('l') , { type: 'link', value: '' }); 13 | t.deepEqual(parse('l aaaa') , { type: 'link', value: 'aaaa' }); 14 | t.deepEqual(parse('l aaaa bbbb') , { type: 'link', value: 'aaaa bbbb' }); 15 | t.deepEqual(parse('link') , { type: null , value: 'link' }); 16 | }); 17 | -------------------------------------------------------------------------------- /test/components/Candidate.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mount } from 'enzyme'; 3 | import React from 'react'; 4 | 5 | import Candidate from '../../src/components/Candidate'; 6 | 7 | const noop = () => {}; 8 | 9 | const item = { 10 | id: '1', 11 | label: 'label', 12 | type: 'content', 13 | name: 'name', 14 | }; 15 | 16 | test('', (t) => { 17 | const element = ; 18 | const wrapper = mount(element); 19 | t.is(wrapper.find('div.candidate').length, 1); 20 | t.is(wrapper.find('div.candidate.selected').length, 1); 21 | t.is(wrapper.find('div.candidate.marked').length, 1); 22 | }); 23 | 24 | test('', (t) => { 25 | const element = ; 26 | const wrapper = mount(element); 27 | t.is(wrapper.find('div.candidate').length, 1); 28 | t.is(wrapper.find('div.candidate.selected').length, 0); 29 | t.is(wrapper.find('div.candidate.marked').length, 0); 30 | }); 31 | 32 | test('', (t) => { 33 | const element = ; 34 | const wrapper = mount(element); 35 | t.is(wrapper.find('div.candidate').length, 1); 36 | t.is(wrapper.find('div.candidate.selected').length, 1); 37 | t.is(wrapper.find('div.candidate.marked').length, 0); 38 | }); 39 | 40 | test('', (t) => { 41 | const element = ; 42 | const wrapper = mount(element); 43 | t.is(wrapper.find('div.candidate').length, 1); 44 | t.is(wrapper.find('div.candidate.selected').length, 0); 45 | t.is(wrapper.find('div.candidate.marked').length, 1); 46 | }); 47 | -------------------------------------------------------------------------------- /test/containers/Options.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mount } from 'enzyme'; 3 | import React from 'react'; 4 | import { Provider } from 'react-redux'; 5 | import { createStore } from 'redux'; 6 | 7 | import Options from '../../src/containers/Options'; 8 | import reducers from '../../src/reducers/options'; 9 | 10 | const store = createStore(reducers); 11 | 12 | const element = ( 13 | 14 | 15 | 16 | ); 17 | 18 | test('', (t) => { 19 | const wrapper = mount(element); 20 | t.is(wrapper.find('div.options').length, 1); 21 | }); 22 | -------------------------------------------------------------------------------- /test/containers/Popup.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { mount } from 'enzyme'; 3 | import React from 'react'; 4 | import createHistory from 'history/createHashHistory'; 5 | import { Provider } from 'react-redux'; 6 | import { 7 | applyMiddleware, 8 | createStore, 9 | } from 'redux'; 10 | import { 11 | ConnectedRouter, 12 | routerMiddleware, 13 | } from 'connected-react-router'; 14 | import { 15 | HashRouter, 16 | Switch, 17 | Route, 18 | } from 'react-router-dom'; 19 | 20 | import Popup from '../../src/containers/Popup'; 21 | import reducers from '../../src/reducers/popup'; 22 | 23 | const history = createHistory(); 24 | const store = createStore(reducers(history), applyMiddleware(routerMiddleware(history))); 25 | const element = ( 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 38 | 39 | test('', async (t) => { 40 | const wrapper = mount(element); 41 | await delay(500); 42 | t.is(wrapper.find('form.commandForm').length, 1); 43 | t.is(wrapper.find('input.commandInput').length, 1); 44 | t.is(wrapper.find('ul.candidatesList').length, 1); 45 | }); 46 | -------------------------------------------------------------------------------- /test/content_popup.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | toggle, 4 | hasPopup, 5 | messageListener, 6 | } from '../src/content_popup'; 7 | 8 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 9 | 10 | test.serial('toggle toggles popup from content', async (t) => { 11 | await delay(); 12 | t.falsy(hasPopup()); 13 | toggle(); 14 | await delay(); 15 | t.truthy(hasPopup()); 16 | toggle(); 17 | await delay(); 18 | t.falsy(hasPopup()); 19 | }); 20 | 21 | test.serial('messageListener handles message if origin is not extension url', (t) => { 22 | const data = JSON.stringify({ type: 'CLOSE' }); 23 | const unknownData = JSON.stringify({ type: 'UNKNOWN' }); 24 | messageListener({ origin: 'http://example.com', data }); 25 | messageListener({ origin: 'http://example.com', data: unknownData }); 26 | messageListener({ origin: 'chrome-extension://xxxxx/popup/index.html', data }); 27 | messageListener({ origin: 'chrome-extension://xxxxx/popup/index.html', data: unknownData }); 28 | t.pass(); 29 | }); 30 | -------------------------------------------------------------------------------- /test/content_script.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | executeAction, 4 | portMessageListener, 5 | messageListener, 6 | } from '../src/content_script'; 7 | 8 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 9 | 10 | test('content_script', async (t) => { 11 | await delay(500); 12 | t.pass(); 13 | }); 14 | 15 | test('executeAction calls contentHandler of a action', async (t) => { 16 | executeAction('click', []); 17 | t.pass(); 18 | }); 19 | 20 | test('executeAction does nothing for action that has no contentHandler', async (t) => { 21 | executeAction('unknown', []); 22 | t.pass(); 23 | }); 24 | 25 | test('portMessageListener handles POPUP_CLOSE message', (t) => { 26 | const message = { type: 'POPUP_CLOSE', payload: {} }; 27 | portMessageListener(message); 28 | t.pass(); 29 | }); 30 | 31 | test('portMessageListener handles UNKNOWN message', (t) => { 32 | const message = { type: 'UNKNOWN', payload: {} }; 33 | portMessageListener(message); 34 | t.pass(); 35 | }); 36 | 37 | test('messageListener handles FETCH_LINKS messages from popup', async (t) => { 38 | const message = { type: 'FETCH_LINKS', payload: { query: '', maxResults: 20 } }; 39 | await messageListener(message); 40 | t.pass(); 41 | }); 42 | 43 | test('messageListener handles CHANGE_CANDIDATE messages from popup', async (t) => { 44 | const message = { type: 'CHANGE_CANDIDATE', payload: { type: 'search' } }; 45 | await messageListener(message); 46 | t.pass(); 47 | }); 48 | 49 | test('messageListener handles CHANGE_CANDIDATE messages with a link candidate, from popup', async (t) => { 50 | const message = { type: 'CHANGE_CANDIDATE', payload: { type: 'link', args: [] } }; 51 | await messageListener(message); 52 | t.pass(); 53 | }); 54 | 55 | test('messageListener handles EXECUTE_ACTION messages from popup', async (t) => { 56 | const message = { type: 'EXECUTE_ACTION', payload: { actionId: 'open', candidates: [] } }; 57 | await messageListener(message, {}, () => t.end()); 58 | t.pass(); 59 | }); 60 | 61 | test('messageListener does not handle unknown messages from popup', async (t) => { 62 | const message = { type: 'UNKNOWN', payload: {} }; 63 | await messageListener(message, {}, () => t.end()); 64 | t.pass(); 65 | }); 66 | -------------------------------------------------------------------------------- /test/create_port.js: -------------------------------------------------------------------------------- 1 | module.exports = function createPort() { 2 | const messageListeners = []; 3 | return { 4 | messageListeners, 5 | postMessage: () => {}, 6 | onMessage: { 7 | addListener: listener => messageListeners.push(listener), 8 | removeListener: (listener) => { 9 | messageListeners.some((v, i) => { 10 | if (v === listener) { 11 | messageListeners.splice(i, 1); 12 | } 13 | return null; 14 | }); 15 | }, 16 | }, 17 | onDisconnect: { 18 | addListener: () => {}, 19 | removeListener: () => {}, 20 | }, 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /test/cursor.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | cursor2position, 4 | activeElementValue, 5 | forwardChar, 6 | backwardChar, 7 | nextLine, 8 | previousLine, 9 | endOfLine, 10 | beginningOfLine, 11 | endOfBuffer, 12 | beginningOfBuffer, 13 | deleteBackwardChar, 14 | killLine, 15 | } from '../src/cursor'; 16 | 17 | 18 | function setInputElement(text) { 19 | const { document } = window; 20 | const input = ``; 21 | document.body.innerHTML = input; 22 | } 23 | 24 | function getInputElement() { 25 | const { document } = window; 26 | return document.getElementById('input'); 27 | } 28 | 29 | test('cursor.activeElementValue returns focused input value', (t) => { 30 | setInputElement('abcdefg'); 31 | const elem = getInputElement(); 32 | elem.focus(); 33 | elem.setSelectionRange(0, 0); 34 | t.is(elem.id, 'input'); 35 | t.is(activeElementValue(), 'abcdefg'); 36 | t.is(window.document.activeElement.selectionStart, 0); 37 | }); 38 | 39 | test('cursor.cursor2position returns a position from cursor', (t) => { 40 | const lines = '1234\n5678\nabcd'.split('\n'); 41 | t.deepEqual(cursor2position(lines, 0), { x: 0, y: 0 }); 42 | t.deepEqual(cursor2position(lines, 1), { x: 1, y: 0 }); 43 | t.deepEqual(cursor2position(lines, 4), { x: 4, y: 0 }); 44 | t.deepEqual(cursor2position(lines, 5), { x: 0, y: 1 }); 45 | t.deepEqual(cursor2position(lines, 6), { x: 1, y: 1 }); 46 | 47 | t.deepEqual(cursor2position([], 0), { x: 0, y: 0 }); 48 | }); 49 | 50 | test('move functions change cursor', (t) => { 51 | setInputElement('abcd\n1234'); 52 | const elem = getInputElement(); 53 | elem.focus(); 54 | elem.setSelectionRange(0, 0); 55 | t.is(elem.selectionStart, 0); 56 | forwardChar(); 57 | t.is(elem.selectionStart, 1); 58 | backwardChar(); 59 | t.is(elem.selectionStart, 0); 60 | nextLine(); 61 | t.is(elem.selectionStart, 5); 62 | previousLine(); 63 | t.is(elem.selectionStart, 0); 64 | endOfLine(); 65 | t.is(elem.selectionStart, 4); 66 | beginningOfLine(); 67 | t.is(elem.selectionStart, 0); 68 | endOfBuffer(); 69 | t.is(elem.selectionStart, 8); 70 | beginningOfBuffer(); 71 | t.is(elem.selectionStart, 0); 72 | }); 73 | 74 | test('deleteBackwardChar removes previous character', (t) => { 75 | setInputElement('abcd\n1234'); 76 | const elem = getInputElement(); 77 | elem.focus(); 78 | elem.setSelectionRange(1, 1); 79 | deleteBackwardChar(); 80 | t.is(elem.value, 'bcd\n1234'); 81 | }); 82 | 83 | test('killline removes characters from current cusor to end of line', (t) => { 84 | setInputElement('abcd\n1234'); 85 | const elem = getInputElement(); 86 | elem.focus(); 87 | elem.setSelectionRange(1, 1); 88 | killLine(); 89 | t.is(elem.value, 'a\n1234'); 90 | }); 91 | -------------------------------------------------------------------------------- /test/key_sequences.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { JSDOM } from 'jsdom'; 3 | import keySequence from '../src/key_sequences'; 4 | 5 | const jsdom = new JSDOM(''); 6 | function code(c) { 7 | return c.toUpperCase().charCodeAt(0); 8 | } 9 | 10 | function key(k, { s = false, c = false, m = false } = {}) { 11 | const { window: { KeyboardEvent } } = jsdom; 12 | return new KeyboardEvent('keydown', { 13 | keyCode: k, 14 | shiftKey: s, 15 | ctrlKey: c, 16 | metaKey: m, 17 | }); 18 | } 19 | 20 | const up = 38; 21 | 22 | test('keySequence returns key sequences from keyEvent', (t) => { 23 | const s = true; 24 | const c = true; 25 | const m = true; 26 | t.is(keySequence(key(code('a'))), 'a'); 27 | t.is(keySequence(key(code('a'), { s })), 'S-a'); 28 | t.is(keySequence(key(code('a'), { c })), 'C-a'); 29 | t.is(keySequence(key(code('a'), { m })), 'M-a'); 30 | t.is(keySequence(key(code('a'), { c, m })), 'C-M-a'); 31 | t.is(keySequence(key(up)), 'up'); 32 | t.is(keySequence(key(up, { s })), 'S-up'); 33 | }); 34 | -------------------------------------------------------------------------------- /test/link.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | HIGHLIGHTER_ID, 4 | LINK_MARKER_CLASS, 5 | getTargetElements, 6 | search, 7 | createHighlighter, 8 | highlight, 9 | dehighlight, 10 | click, 11 | } from '../src/link'; 12 | 13 | const style = 'style="height: 10px;"'; 14 | 15 | function a(url, text, title = '') { 16 | return `${text}`; 17 | } 18 | 19 | function button(text, title) { 20 | return ``; 21 | } 22 | 23 | function input(type, value) { 24 | return ``; 25 | } 26 | 27 | function div(role, ariaLabel) { 28 | return `
    `; 29 | } 30 | 31 | function setup() { 32 | const { document } = window; 33 | const container = document.getElementById('container'); 34 | const links = [ 35 | a('https://example.org/', 'normal link'), 36 | a('/relative', 'relative link'), 37 | a('//outside.com/', 'no protocol link'), 38 | a('#', 'some action'), 39 | a('https://example.org/', '', 'title'), 40 | button('text', 'title'), 41 | button('', 'title'), 42 | input('button', 'input button'), 43 | input('submit', 'input submit'), 44 | div('button', 'aria-label'), 45 | ]; 46 | container.innerHTML = links.join('\n'); 47 | } 48 | 49 | test.beforeEach(setup); 50 | test.afterEach(dehighlight); 51 | 52 | test.serial('getTargetElements returns visible and clickable links', (t) => { 53 | setup(); 54 | const targets = getTargetElements(); 55 | t.is(targets.length, 10); 56 | }); 57 | 58 | test.serial('search returns visible and clickable links', (t) => { 59 | setup(); 60 | const candidates = search(); 61 | t.is(candidates.length, 10); 62 | t.deepEqual(candidates[0], { 63 | id: 'link-0', 64 | index: 0, 65 | url: 'https://example.org/', 66 | label: 'normal link', 67 | role: 'link', 68 | }); 69 | t.deepEqual(candidates[1], { 70 | id: 'link-1', 71 | index: 1, 72 | url: 'https://example.org/relative', 73 | label: 'relative link', 74 | role: 'link', 75 | }); 76 | t.deepEqual(candidates[2], { 77 | id: 'link-2', 78 | index: 2, 79 | url: 'https://outside.com/', 80 | label: 'no protocol link', 81 | role: 'link', 82 | }); 83 | t.deepEqual(candidates[3], { 84 | id: 'link-3', 85 | index: 3, 86 | url: '', 87 | label: 'some action', 88 | role: 'button', 89 | }); 90 | t.deepEqual(candidates[4], { 91 | id: 'link-4', 92 | index: 4, 93 | url: 'https://example.org/', 94 | label: 'title', 95 | role: 'link', 96 | }); 97 | t.deepEqual(candidates[5], { 98 | id: 'link-5', 99 | index: 5, 100 | url: '', 101 | label: 'text', 102 | role: 'button', 103 | }); 104 | t.deepEqual(candidates[6], { 105 | id: 'link-6', 106 | index: 6, 107 | url: '', 108 | label: 'title', 109 | role: 'button', 110 | }); 111 | t.deepEqual(candidates[7], { 112 | id: 'link-7', 113 | index: 7, 114 | url: '', 115 | label: 'input button', 116 | role: 'button', 117 | }); 118 | t.deepEqual(candidates[8], { 119 | id: 'link-8', 120 | index: 8, 121 | url: '', 122 | label: 'input submit', 123 | role: 'button', 124 | }); 125 | t.deepEqual(candidates[9], { 126 | id: 'link-9', 127 | index: 9, 128 | url: '', 129 | label: 'aria-label', 130 | role: 'button', 131 | }); 132 | }); 133 | 134 | 135 | test.serial('search with a query returns links that are matched with the query ', (t) => { 136 | setup(); 137 | const candidates = search({ query: 'normal link' }); 138 | t.is(candidates.length, 1); 139 | }); 140 | 141 | test.serial('createHighlighter returns highter element', (t) => { 142 | t.truthy(createHighlighter({ 143 | left: 10, 144 | top: 10, 145 | width: 10, 146 | height: 10, 147 | })); 148 | }); 149 | 150 | test.serial('highlight appends highlight element and link markers', (t) => { 151 | setup(); 152 | highlight({ index: 0, url: 'https://example.org/' }); 153 | t.truthy(document.getElementById(HIGHLIGHTER_ID)); 154 | t.true(document.getElementsByClassName(LINK_MARKER_CLASS).length === 10); 155 | }); 156 | 157 | test.serial('dehighlight removes highlight element and link markers', (t) => { 158 | setup(); 159 | highlight({ index: 0, url: 'https://example.org/' }); 160 | dehighlight(); 161 | t.falsy(document.getElementById(HIGHLIGHTER_ID)); 162 | t.true(document.getElementsByClassName(LINK_MARKER_CLASS).length === 0); 163 | }); 164 | 165 | test.serial('click triggers target element click', (t) => { 166 | setup(); 167 | click({ index: 0, url: 'https://example.org/' }); 168 | click({ index: 1, url: 'https://example.org/relative' }); 169 | click(); 170 | t.pass(); 171 | }); 172 | -------------------------------------------------------------------------------- /test/options_ui.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import ReactTestUtils from 'react-dom/test-utils'; 3 | import app, { start, stop } from '../src/options_ui'; 4 | 5 | const WAIT_MS = 250; 6 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 7 | 8 | app.then(a => stop(a)); // stop default app 9 | let optionsUI = null; 10 | 11 | const { getBoundingClientRect } = Element.prototype; 12 | 13 | function dispatchEvent(name, node, x, y) { 14 | const event = document.createEvent('MouseEvents'); 15 | event.initMouseEvent( 16 | name, true, true, window, 0, 17 | x, y, x, y, 18 | false, false, false, false, 0, 19 | null, 20 | ); 21 | node.dispatchEvent(event); 22 | } 23 | 24 | async function setup() { 25 | optionsUI = await start(); 26 | Element.prototype.getBoundingClientRect = () => ({ 27 | top: 0, 28 | left: 0, 29 | bottom: 10, 30 | right: 10, 31 | width: 10, 32 | height: 10, 33 | }); 34 | } 35 | 36 | function restore() { 37 | stop(optionsUI); 38 | Element.prototype.getBoundingClientRect = getBoundingClientRect; 39 | } 40 | 41 | test.beforeEach(setup); 42 | test.afterEach(restore); 43 | 44 | test.serial('options_ui succeeds in rendering html', async (t) => { 45 | await delay(WAIT_MS); 46 | const { document } = window; 47 | const options = document.querySelector('div.options'); 48 | t.truthy(options !== null); 49 | await delay(WAIT_MS); 50 | }); 51 | 52 | test.serial('options_ui changes popup width', async (t) => { 53 | await delay(WAIT_MS); 54 | const { document } = window; 55 | const input = document.querySelector('.popupWidthInput'); 56 | input.value = 500; 57 | ReactTestUtils.Simulate.change(input); 58 | t.pass(); 59 | await delay(WAIT_MS); 60 | }); 61 | 62 | test.serial('options_ui changes order of candidates', async (t) => { 63 | await delay(WAIT_MS); 64 | const { document } = window; 65 | const item = document.querySelector('.sortableListItem'); 66 | t.truthy(item !== null); 67 | const { top: x, left: y } = item.getBoundingClientRect(); 68 | dispatchEvent('mousedown', item, x, y); 69 | dispatchEvent('mousemove', item, x, y + 10); 70 | dispatchEvent('mouseup', item, x, y + 20); 71 | t.pass(); 72 | await delay(WAIT_MS); 73 | }); 74 | 75 | test.serial('options_ui changes max results for empty query', async (t) => { 76 | await delay(WAIT_MS); 77 | const { document } = window; 78 | const input = document.querySelector('.maxResultsInput'); 79 | input.value = 10; 80 | ReactTestUtils.Simulate.change(input); 81 | t.pass(); 82 | await delay(WAIT_MS); 83 | }); 84 | 85 | test.serial('options_ui enable C-j,k move ', async (t) => { 86 | await delay(WAIT_MS); 87 | const { document } = window; 88 | const checkbox = document.querySelector('.cjkMoveCheckbox'); 89 | ReactTestUtils.Simulate.change(checkbox, { target: { checked: true } }); 90 | t.pass(); 91 | await delay(WAIT_MS); 92 | }); 93 | 94 | test.serial('options_ui change theme', async (t) => { 95 | await delay(WAIT_MS); 96 | const { document } = window; 97 | const select = document.querySelector('.themeSelect'); 98 | const theme = 'some-theme-value'; 99 | ReactTestUtils.Simulate.change(select, { target: { value: theme } }); 100 | t.pass(); 101 | await delay(WAIT_MS); 102 | }); 103 | -------------------------------------------------------------------------------- /test/popup.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import ReactTestUtils from 'react-dom/test-utils'; 4 | import browser from 'webextension-polyfill'; 5 | import app, { start, stop } from '../src/popup'; 6 | import { port } from '../src/sagas/popup'; 7 | import search from '../src/candidates'; 8 | 9 | const WAIT_MS = 250; 10 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 11 | const ENTER = 13; 12 | const SPC = 32; 13 | const TAB = 9; 14 | 15 | app.then(a => stop(a)); // stop default app 16 | 17 | const { close } = window; 18 | const { sendMessage } = browser.runtime; 19 | let popup = null; 20 | 21 | function code(c) { 22 | return c.toUpperCase().charCodeAt(0); 23 | } 24 | 25 | function keyDown(node, keyCode, { s = false, c = false, m = false } = {}) { 26 | ReactTestUtils.Simulate.keyDown(node, { 27 | keyCode, 28 | key: keyCode, 29 | which: keyCode, 30 | shiftKey: s, 31 | ctrlKey: c, 32 | metaKey: m, 33 | }); 34 | } 35 | 36 | function getSelectedIndex() { 37 | const items = document.getElementsByClassName('candidate'); 38 | const selected = document.getElementsByClassName('selected'); 39 | if (selected.length > 0) { 40 | for (let i = 0; i < items.length; i += 1) { 41 | if (items[i] === selected[0]) { 42 | return i; 43 | } 44 | } 45 | } 46 | return -1; 47 | } 48 | 49 | async function setup() { 50 | document.scrollingElement = { scrollTo: nisemono.func() }; 51 | nisemono.expects(document.scrollingElement.scrollTo).returns(); 52 | window.close = nisemono.func(); 53 | browser.runtime.sendMessage = ({ type, payload }) => { 54 | switch (type) { 55 | case 'SEARCH_CANDIDATES': 56 | return search(payload); 57 | default: 58 | return Promise.resolve(); 59 | } 60 | }; 61 | popup = await start(); 62 | } 63 | 64 | function restore() { 65 | document.scrollingElement = null; 66 | window.close = close; 67 | browser.runtime.sendMessage = sendMessage; 68 | stop(popup); 69 | } 70 | 71 | test.beforeEach(setup); 72 | test.afterEach(restore); 73 | 74 | test.serial('popup succeeds in rendering html', async (t) => { 75 | await delay(WAIT_MS); 76 | const { document } = window; 77 | const input = document.querySelector('.commandInput'); 78 | t.truthy(input !== null); 79 | const candidate = document.querySelector('.candidate'); 80 | t.truthy(candidate !== null); 81 | await delay(500); 82 | }); 83 | 84 | test.serial('popup changes a candidate', async (t) => { 85 | await delay(WAIT_MS); 86 | const { document } = window; 87 | const input = document.querySelector('.commandInput'); 88 | const { length } = document.getElementsByClassName('candidate'); 89 | t.is(getSelectedIndex(), 0); 90 | 91 | keyDown(input, TAB); 92 | await delay(WAIT_MS); 93 | t.is(getSelectedIndex(), 1); 94 | 95 | keyDown(input, TAB, { s: true }); 96 | await delay(WAIT_MS); 97 | t.is(getSelectedIndex(), 0); 98 | 99 | keyDown(input, TAB, { s: true }); 100 | await delay(WAIT_MS); 101 | t.is(getSelectedIndex(), length - 1); 102 | 103 | keyDown(input, TAB); 104 | await delay(WAIT_MS); 105 | t.is(getSelectedIndex(), 0); 106 | }); 107 | 108 | test.serial('popup selects a candidate by `return`', async (t) => { 109 | await delay(WAIT_MS); 110 | const { document } = window; 111 | const input = document.querySelector('.commandInput'); 112 | input.value = 'aa'; 113 | ReactTestUtils.Simulate.change(input); 114 | keyDown(input, ENTER); 115 | t.pass(); 116 | await delay(WAIT_MS); 117 | }); 118 | 119 | test.serial('popup selects a candidate by `click`', async (t) => { 120 | await delay(WAIT_MS); 121 | const { document } = window; 122 | const candidate = document.querySelector('.candidate'); 123 | ReactTestUtils.Simulate.click(candidate); 124 | t.pass(); 125 | await delay(WAIT_MS); 126 | }); 127 | 128 | test.serial('popup selects action lists', async (t) => { 129 | await delay(WAIT_MS); 130 | const { document } = window; 131 | const input = document.querySelector('.commandInput'); 132 | ReactTestUtils.Simulate.change(input); 133 | keyDown(input, code('i'), { c: true }); 134 | await delay(WAIT_MS); 135 | keyDown(input, code('i'), { c: true }); 136 | t.pass(); 137 | await delay(WAIT_MS); 138 | }); 139 | 140 | 141 | test.serial('popup selects a action and `return`', async (t) => { 142 | await delay(WAIT_MS); 143 | const { document } = window; 144 | const input = document.querySelector('.commandInput'); 145 | ReactTestUtils.Simulate.change(input); 146 | keyDown(input, code('i'), { c: true }); 147 | await delay(WAIT_MS); 148 | keyDown(input, ENTER); 149 | t.pass(); 150 | await delay(WAIT_MS); 151 | }); 152 | 153 | test.serial('popup selects a action and `click`', async (t) => { 154 | await delay(WAIT_MS); 155 | const { document } = window; 156 | const input = document.querySelector('.commandInput'); 157 | ReactTestUtils.Simulate.change(input); 158 | keyDown(input, code('i'), { c: true }); 159 | await delay(WAIT_MS); 160 | const candidate = document.querySelector('.candidate'); 161 | ReactTestUtils.Simulate.click(candidate); 162 | t.pass(); 163 | await delay(WAIT_MS); 164 | }); 165 | 166 | test.serial('popup marks candidates', async (t) => { 167 | await delay(WAIT_MS); 168 | const { document } = window; 169 | const input = document.querySelector('.commandInput'); 170 | keyDown(input, SPC, { c: true }); 171 | await delay(WAIT_MS); 172 | const candidate = document.querySelector('.candidate.marked'); 173 | t.truthy(candidate !== null); 174 | await delay(WAIT_MS); 175 | }); 176 | 177 | test.serial('popup cannot marks actions', async (t) => { 178 | await delay(WAIT_MS); 179 | const { document } = window; 180 | const input = document.querySelector('.commandInput'); 181 | keyDown(input, code('i'), { c: true }); 182 | await delay(WAIT_MS); 183 | keyDown(input, SPC, { c: true }); 184 | await delay(WAIT_MS); 185 | const markedCandidate = document.querySelector('.candidate.marked'); 186 | t.truthy(markedCandidate === null); 187 | await delay(WAIT_MS); 188 | }); 189 | 190 | test.serial('popup handles REQUEST_ARG message', async (t) => { 191 | await delay(WAIT_MS); 192 | const input = document.querySelector('.commandInput'); 193 | port.messageListeners.forEach((l) => { 194 | l({ 195 | type: 'REQUEST_ARG', 196 | payload: { 197 | scheme: { 198 | type: 'number', 199 | title: 'arg title', 200 | minimum: 0, 201 | maximum: 10, 202 | }, 203 | }, 204 | }); 205 | keyDown(input, code('1')); 206 | keyDown(input, ENTER); 207 | }); 208 | await delay(WAIT_MS); 209 | t.pass(); 210 | }); 211 | 212 | test.serial('popup handles TAB_CHANGED action and close', async (t) => { 213 | await delay(WAIT_MS); 214 | port.messageListeners.forEach((l) => { 215 | l({ type: 'TAB_CHANGED' }); 216 | }); 217 | t.pass(); 218 | await delay(WAIT_MS); 219 | }); 220 | 221 | test.serial('popup handles TAB_CHANGED action re-focus', async (t) => { 222 | await delay(WAIT_MS); 223 | port.messageListeners.forEach((l) => { 224 | l({ type: 'TAB_CHANGED', payload: { canFocusToPopup: true } }); 225 | }); 226 | t.pass(); 227 | await delay(WAIT_MS); 228 | }); 229 | 230 | 231 | test.serial('popup handles QUIT action re-focus', async (t) => { 232 | await delay(WAIT_MS); 233 | port.messageListeners.forEach((l) => { 234 | l({ type: 'QUIT' }); 235 | }); 236 | t.true(window.close.isCalled); 237 | await delay(WAIT_MS); 238 | }); 239 | -------------------------------------------------------------------------------- /test/popup_window.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | toggle, 4 | getPopupWindow, 5 | getActiveTabId, 6 | onTabRemoved, 7 | onTabActivated, 8 | } from '../src/popup_window'; 9 | 10 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 11 | 12 | test.serial('toggle removes popup window if popup window is already shown', async (t) => { 13 | await delay(); 14 | t.falsy(getPopupWindow()); 15 | toggle(); 16 | await delay(); 17 | t.truthy(getPopupWindow()); 18 | toggle(); 19 | await delay(); 20 | t.falsy(getPopupWindow()); 21 | }); 22 | 23 | test.serial('onTabRemoved removes popup window', async (t) => { 24 | t.falsy(getPopupWindow()); 25 | toggle(); 26 | await delay(); 27 | t.truthy(getPopupWindow()); 28 | await delay(); 29 | onTabRemoved(1, { windowId: 1 }); 30 | await delay(); 31 | t.falsy(getPopupWindow()); 32 | }); 33 | 34 | test.serial('onTabActivated update activeTabId', async (t) => { 35 | t.falsy(getActiveTabId()); 36 | onTabActivated({ tabId: 1, windowId: 2 }); 37 | await delay(); 38 | t.is(getActiveTabId(), 1); 39 | await delay(); 40 | onTabRemoved(1, { windowId: 1 }); 41 | await delay(); 42 | t.falsy(getActiveTabId()); 43 | }); 44 | -------------------------------------------------------------------------------- /test/sagas/key_sequence.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { put } from 'redux-saga/effects'; 3 | import { 4 | commandOfSeq, 5 | dispatchAction, 6 | handleKeySequece, 7 | init, 8 | } from '../../src/sagas/key_sequence'; 9 | 10 | test('dispatchAction saga', (t) => { 11 | const gen = dispatchAction('TEST', {})(); 12 | t.deepEqual(gen.next().value, put({ type: 'TEST', payload: {} })); 13 | }); 14 | 15 | test('handleKeySequece saga', (t) => { 16 | const noCommandGen = handleKeySequece({ payload: 'a' }); 17 | t.deepEqual(noCommandGen.next(), { done: true, value: undefined }); 18 | 19 | const nextGen = handleKeySequece({ payload: 'C-n' }); 20 | nextGen.next(); 21 | t.deepEqual(nextGen.next(), { done: true, value: undefined }); 22 | 23 | const deleteGen = handleKeySequece({ payload: 'C-h' }); 24 | deleteGen.next(); 25 | t.deepEqual(deleteGen.next().value, put({ type: 'QUERY', payload: '' })); 26 | t.deepEqual(deleteGen.next(), { done: true, value: undefined }); 27 | }); 28 | 29 | test('init setups commandOfSeq', (t) => { 30 | t.falsy(commandOfSeq['C-j']); 31 | init({ enabledCJKMove: true }); 32 | t.truthy(commandOfSeq['C-j']); 33 | }); 34 | -------------------------------------------------------------------------------- /test/sagas/popup.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { delay } from 'redux-saga'; 3 | import { put, call, select } from 'redux-saga/effects'; 4 | import { 5 | debounceDelayMs, 6 | dispatchEmptyQuery, 7 | searchCandidates, 8 | modeSelector, 9 | candidateSelector, 10 | executeAction, 11 | normalizeCandidate, 12 | getTargetCandidates, 13 | } from '../../src/sagas/popup'; 14 | 15 | const items = [{ 16 | id: 'google-search-test', 17 | label: 'test Search with Google', 18 | type: 'search', 19 | args: ['test'], 20 | faviconUrl: null, 21 | }]; 22 | 23 | test('dispatchEmptyQuery saga', (t) => { 24 | const gen = dispatchEmptyQuery(); 25 | t.deepEqual(gen.next().value, put({ type: 'QUERY', payload: '' })); 26 | }); 27 | 28 | test('searchCandidates saga', (t) => { 29 | const gen = searchCandidates({ payload: '' }); 30 | t.deepEqual(gen.next().value, call(delay, debounceDelayMs)); 31 | t.deepEqual(gen.next().value, select(candidateSelector)); 32 | t.deepEqual(gen.next().value, select(modeSelector)); 33 | }); 34 | 35 | test('executeAction', (t) => { 36 | const action = { handler: () => Promise.resolve() }; 37 | const gen = executeAction(action, items); 38 | gen.next(); 39 | gen.next(); 40 | t.pass(); 41 | 42 | const noActionGen = executeAction(null, items); 43 | noActionGen.next(); 44 | t.pass(); 45 | }); 46 | 47 | test('normalizeCandidate', (t) => { 48 | const noCandidateGen = normalizeCandidate(null); 49 | t.deepEqual(noCandidateGen.next().value, null); 50 | 51 | const gen = normalizeCandidate({ type: 'test' }); 52 | t.deepEqual(gen.next().value, { type: 'test' }); 53 | }); 54 | 55 | test('getTargetCandidates', (t) => { 56 | const markedCandidateIds = { 'google-search-test': true }; 57 | const gen = getTargetCandidates({ markedCandidateIds, items, index: 0 }); 58 | t.deepEqual(gen.next().value, items); 59 | }); 60 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require('jsdom'); 2 | const logger = require('kiroku'); 3 | const indexedDB = require('fake-indexeddb'); 4 | const IDBKeyRange = require('fake-indexeddb/lib/FDBKeyRange'); 5 | 6 | const body = '
    '; 7 | const jsdom = new JSDOM(`${body}`, { 8 | pretendToBeVisual: true, 9 | url: 'https://example.org/', 10 | }); 11 | const { window } = jsdom; 12 | 13 | function copyProps(src, target) { 14 | const props = Object.getOwnPropertyNames(src) 15 | .filter(prop => typeof target[prop] === 'undefined') 16 | .reduce((result, prop) => Object.assign({}, result, { 17 | [prop]: Object.getOwnPropertyDescriptor(src, prop), 18 | }), {}); 19 | Object.defineProperties(target, props); 20 | } 21 | 22 | global.window = window; 23 | global.document = window.document; 24 | global.navigator = { 25 | userAgent: 'node.js', 26 | }; 27 | 28 | global.indexedDB = indexedDB; 29 | global.IDBKeyRange = IDBKeyRange; 30 | 31 | Object.defineProperties(window.HTMLElement.prototype, { 32 | offsetLeft: { 33 | get() { return parseFloat(window.getComputedStyle(this).marginLeft) || 0; }, 34 | }, 35 | offsetTop: { 36 | get() { return parseFloat(window.getComputedStyle(this).marginTop) || 0; }, 37 | }, 38 | offsetHeight: { 39 | get() { return parseFloat(window.getComputedStyle(this).height) || 0; }, 40 | }, 41 | offsetWidth: { 42 | get() { return parseFloat(window.getComputedStyle(this).width) || 0; }, 43 | }, 44 | }); 45 | 46 | window.HTMLElement.prototype.scrollIntoView = () => {}; 47 | 48 | const raf = require('raf'); 49 | 50 | raf.polyfill(global); 51 | 52 | const enzyme = require('enzyme'); 53 | const Adapter = require('enzyme-adapter-react-16'); 54 | const browser = require('./browser_mock'); 55 | 56 | global.browser = browser; 57 | global.chrome = null; 58 | 59 | copyProps(window, global); 60 | 61 | enzyme.configure({ adapter: new Adapter() }); 62 | logger.setLevel('FATAL'); 63 | -------------------------------------------------------------------------------- /test/sources/bookmark.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import candidates from '../../src/sources/bookmark'; 4 | 5 | const { browser } = global; 6 | const { search, getRecent } = browser.bookmarks; 7 | function setup() { 8 | browser.bookmarks.search = nisemono.func(); 9 | browser.bookmarks.getRecent = nisemono.func(); 10 | nisemono.expects(browser.bookmarks.search).resolves([{ 11 | id: 'bookmark-0', 12 | title: 'title', 13 | url: 'https://example.com/', 14 | type: 'bookmark', 15 | }]); 16 | nisemono.expects(browser.bookmarks.getRecent).resolves([{ 17 | id: 'recent-bookmark', 18 | title: 'recent', 19 | url: 'https://example.com/', 20 | type: 'bookmark', 21 | }]); 22 | } 23 | 24 | function restore() { 25 | browser.bookmarks.search = search; 26 | browser.bookmarks.getRecent = getRecent; 27 | } 28 | 29 | test.beforeEach(setup); 30 | test.afterEach(restore); 31 | 32 | test('candidates() search bookmarks ', t => candidates('q').then(({ items, label }) => { 33 | t.true(label !== null); 34 | t.is(items.length, 1); 35 | t.is(items[0].id, 'bookmark-0'); 36 | })); 37 | 38 | test('candidates() get recent bookmarks for empty query', t => candidates('').then(({ items, label }) => { 39 | t.true(label !== null); 40 | t.is(items.length, 1); 41 | t.is(items[0].id, 'recent-bookmark'); 42 | })); 43 | -------------------------------------------------------------------------------- /test/sources/history.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import candidates from '../../src/sources/history'; 4 | 5 | const { browser } = global; 6 | const { search } = browser.history; 7 | function setup() { 8 | browser.history.search = nisemono.func(); 9 | nisemono.expects(browser.history.search).resolves([{ 10 | id: 'history-0', 11 | title: 'title', 12 | url: 'https://example.com/', 13 | }]); 14 | } 15 | 16 | function restore() { 17 | browser.history.search = search; 18 | } 19 | 20 | test.beforeEach(setup); 21 | test.afterEach(restore); 22 | 23 | test('candidates() search histories ', t => candidates('').then(({ items, label }) => { 24 | t.true(label !== null); 25 | t.is(items.length, 1); 26 | t.is(items[0].id, 'history-0'); 27 | })); 28 | -------------------------------------------------------------------------------- /test/sources/link.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import { getFaviconUrl } from '../../src/utils/url'; 4 | import candidates, { faviconUrl, getLabel } from '../../src/sources/link'; 5 | 6 | const { browser } = global; 7 | const { query, sendMessage } = browser.tabs; 8 | const { sendMessage: sendMessageToRuntime } = browser.runtime; 9 | 10 | function setup() { 11 | browser.tabs.query = nisemono.func(); 12 | nisemono.expects(browser.tabs.query).resolves([{ 13 | id: 'tab-0', 14 | title: 'title', 15 | url: 'https://example.com/', 16 | windowId: 'window-0', 17 | }]); 18 | browser.tabs.sendMessage = nisemono.func(); 19 | browser.runtime.sendMessage = nisemono.func(); 20 | } 21 | 22 | function restore() { 23 | browser.tabs.query = query; 24 | browser.tabs.sendMessage = sendMessage; 25 | browser.runtime.sendMessage = sendMessageToRuntime; 26 | } 27 | 28 | test.beforeEach(setup); 29 | test.afterEach(restore); 30 | 31 | test('faviconUrl returns url', (t) => { 32 | const url = 'https://example.com'; 33 | const clickImage = 'moz-extension://extension-id/images/click.png'; 34 | t.is(faviconUrl({ role: 'link', url }), getFaviconUrl(url)); 35 | t.is(faviconUrl({ role: 'button', url }), clickImage); 36 | }); 37 | 38 | test('getLabel returns label', (t) => { 39 | const url = 'https://example.com'; 40 | const label = 'label'; 41 | t.is(getLabel({ url, label }), `${label}: ${url}`); 42 | t.is(getLabel({ url, label: ' ' }), url); 43 | t.is(getLabel({ label }), label); 44 | }); 45 | 46 | test.serial('candidates returns link candidates', (t) => { 47 | nisemono.expects(browser.tabs.sendMessage).resolves([{ 48 | id: 'link-0', 49 | label: 'title', 50 | url: 'https://example.com/', 51 | role: 'link', 52 | }]); 53 | return candidates('q').then(({ items, label }) => { 54 | t.true(label !== null); 55 | t.is(items.length, 1); 56 | t.is(items[0].id, 'link-0'); 57 | }); 58 | }); 59 | 60 | test.serial('candidates returns link candidates with query', (t) => { 61 | nisemono.expects(browser.tabs.sendMessage).rejects(new Error('error')); 62 | nisemono.expects(browser.runtime.sendMessage).rejects(new Error('error')); 63 | return candidates('q').then(({ items, label }) => { 64 | t.true(label !== null); 65 | t.is(items.length, 0); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/sources/search.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import candidates from '../../src/sources/search'; 3 | 4 | test('candidates() returns search candidates ', t => candidates('q', { maxResults: 5 }).then(({ items, label }) => { 5 | t.true(label !== null); 6 | t.is(items.length, 1); 7 | t.is(items[0].id, 'search-q'); 8 | })); 9 | -------------------------------------------------------------------------------- /test/sources/tab.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import candidates from '../../src/sources/tab'; 4 | 5 | const { browser } = global; 6 | const { query } = browser.tabs; 7 | function setup() { 8 | browser.tabs.query = nisemono.func(); 9 | nisemono.expects(browser.tabs.query).resolves([{ 10 | id: 'tab-0', 11 | title: 'title', 12 | url: 'https://example.com/', 13 | windowId: 'window-0', 14 | }]); 15 | } 16 | 17 | function restore() { 18 | browser.tabs.query = query; 19 | } 20 | 21 | test.beforeEach(setup); 22 | test.afterEach(restore); 23 | 24 | test('candidates() searches tabs ', t => candidates('', { maxResults: 5 }).then(({ items, label }) => { 25 | t.true(label !== null); 26 | t.is(items.length, 1); 27 | t.is(items[0].id, 'tab-0'); 28 | })); 29 | -------------------------------------------------------------------------------- /test/utils/args.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { 3 | getArgListener, 4 | setPostMessageFunction, 5 | requestArg, 6 | } from '../../src/utils/args'; 7 | 8 | test('getArgListener returns arg listener', (t) => { 9 | const listener = getArgListener(); 10 | t.truthy(listener); 11 | }); 12 | 13 | test('requestArg calls postMessage', t => new Promise((resolve) => { 14 | setPostMessageFunction(resolve); 15 | requestArg({ type: 'number', title: 'title' }); 16 | t.pass(); 17 | })); 18 | -------------------------------------------------------------------------------- /test/utils/cookies.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import { getArgListener } from '../../src/utils/args'; 4 | import { 5 | cookie2candidate, 6 | manage, 7 | actions, 8 | } from '../../src/utils/cookies'; 9 | 10 | const WAIT_MS = 10; 11 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); 12 | const { browser } = global; 13 | const { 14 | getAll, 15 | set, 16 | remove, 17 | } = browser.cookies; 18 | 19 | test.beforeEach(() => { 20 | browser.cookies.getAll = nisemono.func(); 21 | browser.cookies.set = nisemono.func(); 22 | browser.cookies.remove = nisemono.func(); 23 | }); 24 | 25 | test.afterEach(() => { 26 | browser.cookies.getAll = getAll; 27 | browser.cookies.set = set; 28 | browser.cookies.remove = remove; 29 | }); 30 | 31 | const cookie = { 32 | name: 'name', 33 | value: 'value', 34 | domain: 'example.com', 35 | hostOnly: false, 36 | path: '/', 37 | secure: true, 38 | httpOnly: false, 39 | storeId: 1, 40 | }; 41 | 42 | const [changeAction, removeAction] = actions; 43 | 44 | test.serial('cookie2candidate converts cookie to candidate', (t) => { 45 | t.deepEqual(cookie2candidate(cookie), { 46 | id: 'name-value-example.com-/', 47 | label: 'name:value', 48 | args: ['name', 'value', cookie], 49 | faviconUrl: null, 50 | type: 'cookie', 51 | }); 52 | }); 53 | 54 | test.serial('manage(): remove action', async (t) => { 55 | nisemono.expects(browser.cookies.getAll).resolves([cookie]); 56 | manage('http://example.com'); 57 | await delay(WAIT_MS); 58 | getArgListener()([cookie2candidate(cookie)]); 59 | await delay(WAIT_MS); 60 | getArgListener()([removeAction]); 61 | await delay(WAIT_MS); 62 | t.true(browser.cookies.remove.isCalled); 63 | }); 64 | 65 | 66 | test.serial('manage(): change action', async (t) => { 67 | nisemono.expects(browser.cookies.getAll).resolves([cookie]); 68 | manage('http://example.com'); 69 | await delay(WAIT_MS); 70 | getArgListener()([cookie2candidate(cookie)]); 71 | await delay(WAIT_MS); 72 | getArgListener()([changeAction]); 73 | await delay(WAIT_MS); 74 | getArgListener()('new-value'); 75 | await delay(WAIT_MS); 76 | t.true(browser.cookies.set.isCalled); 77 | }); 78 | 79 | test.serial('manage(): unknown action', async (t) => { 80 | nisemono.expects(browser.cookies.getAll).resolves([cookie]); 81 | manage('http://example.com'); 82 | await delay(WAIT_MS); 83 | getArgListener()([cookie2candidate(cookie)]); 84 | await delay(WAIT_MS); 85 | getArgListener()([{}]); 86 | await delay(WAIT_MS); 87 | t.false(browser.cookies.set.isCalled); 88 | t.false(browser.cookies.remove.isCalled); 89 | }); 90 | -------------------------------------------------------------------------------- /test/utils/hatebu.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import idb from '../../src/utils/indexedDB'; 3 | import config from '../../src/config'; 4 | import { 5 | createObjectStore, 6 | Bookmark, 7 | fetchBookmarks, 8 | } from '../../src/utils/hatebu'; 9 | 10 | test.beforeEach(async () => { 11 | await idb.upgrade(config.dbName, config.dbVersion, db => createObjectStore(db)); 12 | }); 13 | 14 | test.afterEach(() => { 15 | idb.destroy(config.dbName); 16 | }); 17 | 18 | test.serial('fetchBookmarks returns empty array', async (t) => { 19 | const items = await fetchBookmarks(); 20 | t.true(items.length === 0); 21 | }); 22 | 23 | test.serial('fetchBookmarks returns hatena bookmarks', async (t) => { 24 | const db = await idb.open(config.dbName, config.dbVersion); 25 | await Bookmark.create({ 26 | id: Date.now(), 27 | comment: 'aaaa', 28 | title: 'aaaa', 29 | url: 'http://hatebu.com', 30 | created_at: new Date(), 31 | updated_at: new Date(), 32 | }, db); 33 | const items = await fetchBookmarks(); 34 | t.true(items.length === 1); 35 | }); 36 | -------------------------------------------------------------------------------- /test/utils/port.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { createPortChannel, getPort } from '../../src/utils/port'; 3 | 4 | test.cb('createPortChannel returns new port channel', (t) => { 5 | let listener; 6 | const port = { 7 | onMessage: { 8 | addListener: (l) => { 9 | listener = l; 10 | }, 11 | removeListener: (l) => { 12 | if (l === listener) { 13 | listener = null; 14 | } 15 | }, 16 | }, 17 | }; 18 | const channel = createPortChannel(port); 19 | t.not(listener, null); 20 | t.not(channel, null); 21 | channel.take(() => { 22 | t.end(); 23 | }); 24 | listener('event'); 25 | 26 | channel.close(); 27 | t.is(listener, null); 28 | }); 29 | 30 | test('getPort returns connected port', (t) => { 31 | const port = getPort('popup'); 32 | t.is(port, getPort('popup')); 33 | }); 34 | -------------------------------------------------------------------------------- /test/utils/sessions.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import { getFaviconUrl } from '../../src/utils/url'; 4 | import { 5 | session2candidate, 6 | restore, 7 | forget, 8 | restorePrevious, 9 | } from '../../src/utils/sessions'; 10 | 11 | const tabSession = { 12 | tab: { 13 | sessionId: 1, 14 | index: 75, 15 | title: 'tab-title', 16 | url: 'https://example.com', 17 | windowId: 5, 18 | }, 19 | }; 20 | 21 | const windowSession = { 22 | window: { 23 | sessionId: 2, 24 | tabs: [ 25 | { 26 | title: 'window-tab1-title', 27 | url: 'https://window-tab1-example.com', 28 | }, 29 | { 30 | title: 'window-tab2-title', 31 | url: 'https://window-tab2-example.com', 32 | }, 33 | ], 34 | }, 35 | }; 36 | 37 | const { browser } = global; 38 | const { 39 | getRecentlyClosed, 40 | restore: restoreSession, 41 | forgetClosedTab, 42 | forgetClosedWindow, 43 | } = browser.sessions; 44 | 45 | test.beforeEach(() => { 46 | browser.sessions.getRecentlyClosed = nisemono.func(); 47 | browser.sessions.restore = nisemono.func(); 48 | browser.sessions.forgetClosedTab = nisemono.func(); 49 | browser.sessions.forgetClosedWindow = nisemono.func(); 50 | }); 51 | 52 | test.afterEach(() => { 53 | browser.sessions.getRecentlyClosed = getRecentlyClosed; 54 | browser.sessions.restore = restoreSession; 55 | browser.sessions.forgetClosedTab = forgetClosedTab; 56 | browser.sessions.forgetClosedWindow = forgetClosedWindow; 57 | }); 58 | 59 | test.serial('session2candidate converts session to candidate', (t) => { 60 | t.deepEqual(session2candidate(tabSession), { 61 | id: 'session-tab-1-5-75', 62 | label: 'tab-title:https://example.com', 63 | args: [1, 'tab', 5, 75], 64 | faviconUrl: getFaviconUrl(tabSession.tab.url), 65 | type: 'session', 66 | }); 67 | 68 | t.deepEqual(session2candidate(windowSession), { 69 | id: 'session-window-2', 70 | label: 'window-tab1-title + 1 tabs', 71 | args: [2, 'window'], 72 | faviconUrl: getFaviconUrl(windowSession.window.tabs[0].url), 73 | type: 'session', 74 | }); 75 | }); 76 | 77 | test.serial('restore calls browser.sessions.restore method', async (t) => { 78 | nisemono.expects(browser.sessions.restore).resolves(); 79 | await restore([session2candidate(tabSession), session2candidate(windowSession)]); 80 | t.true(browser.sessions.restore.isCalled); 81 | }); 82 | 83 | test.serial('forget calls forgetClosedTab or forgetClosedWindow method of browser.sessions', async (t) => { 84 | nisemono.expects(browser.sessions.forgetClosedTab).resolves(); 85 | nisemono.expects(browser.sessions.forgetClosedWindow).resolves(); 86 | await forget([session2candidate(tabSession), session2candidate(windowSession)]); 87 | t.true(browser.sessions.forgetClosedTab.isCalled); 88 | t.true(browser.sessions.forgetClosedWindow.isCalled); 89 | }); 90 | 91 | test.serial('restorePrevious calls browser.sessions.restore method for latest session', async (t) => { 92 | nisemono.expects(browser.sessions.getRecentlyClosed).resolves([tabSession, windowSession]); 93 | await restorePrevious(); 94 | t.true(browser.sessions.getRecentlyClosed.calls.length === 1); 95 | 96 | nisemono.expects(browser.sessions.getRecentlyClosed).resolves([windowSession, tabSession]); 97 | await restorePrevious(); 98 | t.true(browser.sessions.getRecentlyClosed.calls.length === 2); 99 | }); 100 | -------------------------------------------------------------------------------- /test/utils/string.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { includes } from '../../src/utils/string'; 3 | 4 | test('includes return first string has next string case-insensitively', async (t) => { 5 | t.true(includes('A', 'A')); 6 | t.true(includes('A', 'a')); 7 | t.true(!includes('b', 'a')); 8 | t.true(includes('ABCDE', 'Ab')); 9 | }); 10 | -------------------------------------------------------------------------------- /test/utils/tabs.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import { 4 | getActiveContentTab, 5 | sendMessageToActiveContentTab, 6 | sendMessageToActiveContentTabViaBackground, 7 | } from '../../src/utils/tabs'; 8 | import { onTabActivated } from '../../src/popup_window'; 9 | 10 | const { browser } = global; 11 | const { get, query, sendMessage } = browser.tabs; 12 | const { sendMessage: sendMessageToRuntime } = browser.runtime; 13 | 14 | function setup(tabs) { 15 | browser.tabs.get = nisemono.func(); 16 | browser.tabs.query = nisemono.func(); 17 | browser.tabs.sendMessage = nisemono.func(); 18 | browser.runtime.sendMessage = nisemono.func(); 19 | nisemono.expects(browser.tabs.query).resolves(tabs); 20 | nisemono.expects(browser.tabs.sendMessage).resolves(); 21 | nisemono.expects(browser.runtime.sendMessage).resolves(tabs[0]); 22 | } 23 | 24 | function restore() { 25 | browser.tabs.get = get; 26 | browser.tabs.query = query; 27 | browser.tabs.sendMessage = sendMessage; 28 | browser.runtime.sendMessage = sendMessageToRuntime; 29 | } 30 | 31 | test.afterEach(() => { 32 | restore(); 33 | }); 34 | 35 | test('getActiveContentTab returns active content tab id', (t) => { 36 | setup([{ id: 1 }]); 37 | nisemono.expects(browser.tabs.get).resolves({ id: 1 }); 38 | onTabActivated({ tabId: 1 }); 39 | return getActiveContentTab().then(tab => t.is(tab.id, 1)); 40 | }); 41 | 42 | test('getActiveContentTab returns active tab id if no content tab', (t) => { 43 | setup([{ id: 1 }]); 44 | nisemono.expects(browser.tabs.get).resolves(null); 45 | onTabActivated({}); 46 | return getActiveContentTab().then(tab => t.is(tab.id, 1)); 47 | }); 48 | 49 | test('getActiveContentTab returns null if there is no active tab', (t) => { 50 | setup([]); 51 | return getActiveContentTab().then(tab => t.is(tab, null)); 52 | }); 53 | 54 | test('sendMessageToActiveContentTab send message to active tab', (t) => { 55 | setup([{ id: 1 }]); 56 | return sendMessageToActiveContentTab({ type: 'MESSAGE_TYPE' }).then(() => { 57 | t.is(browser.tabs.sendMessage.calls.length, 1); 58 | }); 59 | }); 60 | 61 | test('sendMessageToActiveContentTabViaBackground send message to active tab', (t) => { 62 | setup([{ id: 1 }]); 63 | return sendMessageToActiveContentTabViaBackground({ type: 'MESSAGE_TYPE' }).then(() => { 64 | t.is(browser.runtime.sendMessage.calls.length, 1); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/utils/url.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import nisemono from 'nisemono'; 3 | import { init, extractDomain, getFaviconUrl } from '../../src/utils/url'; 4 | 5 | const validUrl = 'http://example.com/test/index.html'; 6 | const fileUrl = 'file:///test/test.html'; 7 | const extensionUrl = 'moz-extension://12345/popup/index.html'; 8 | const invalidUrl = 'aaaa'; 9 | 10 | test('extractDomain returns domain of a url', (t) => { 11 | t.is(extractDomain(validUrl), 'example.com'); 12 | t.is(extractDomain(invalidUrl), null); 13 | }); 14 | 15 | const { browser } = global; 16 | const { getBrowserInfo } = browser.runtime; 17 | function setupBrowserInfo(name) { 18 | browser.runtime.getBrowserInfo = nisemono.func(); 19 | nisemono.expects(browser.runtime.getBrowserInfo).resolves({ name }); 20 | } 21 | 22 | function restoreBrowserInfo() { 23 | browser.runtime.getBrowserInfo = getBrowserInfo; 24 | } 25 | 26 | test('getFaviconUrl returns favison url from web page url', async (t) => { 27 | setupBrowserInfo('Firefox'); 28 | await init(); 29 | const faviconUrl = 'https://s2.googleusercontent.com/s2/favicons?domain=example.com'; 30 | t.is(getFaviconUrl(validUrl), faviconUrl); 31 | t.is(getFaviconUrl(fileUrl), null); 32 | t.is(getFaviconUrl(extensionUrl), null); 33 | t.is(getFaviconUrl(invalidUrl), null); 34 | t.is(getFaviconUrl(null), null); 35 | 36 | setupBrowserInfo('chrome'); 37 | await init(); 38 | 39 | t.is(getFaviconUrl(validUrl), `chrome://favicon/${validUrl}`); 40 | t.is(getFaviconUrl(fileUrl), `chrome://favicon/${fileUrl}`); 41 | t.is(getFaviconUrl(extensionUrl), `chrome://favicon/${extensionUrl}`); 42 | t.is(getFaviconUrl(invalidUrl), `chrome://favicon/${invalidUrl}`); 43 | 44 | restoreBrowserInfo(); 45 | }); 46 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const NODE_ENV = process.env.NODE_ENV || 'production'; 4 | module.exports = { 5 | mode: NODE_ENV, 6 | entry: { 7 | background: './src/background.js', 8 | popup: './src/popup.jsx', 9 | content_scripts: './src/content_script.js', 10 | options_ui: './src/options_ui.jsx', 11 | }, 12 | output: { 13 | path: `${__dirname}/`, 14 | filename: '[name]/bundle.js', 15 | }, 16 | module: { 17 | rules: [ 18 | { test: /\.css$/, use: 'css-loader' }, 19 | { test: /\.jsx?$/, use: 'babel-loader', exclude: /(node_modules|bower_components)/ }, 20 | ], 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.jsx'], 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | 'process.env': { NODE_ENV: JSON.stringify(NODE_ENV) }, 28 | }), 29 | ], 30 | devtool: 'source-map', 31 | }; 32 | --------------------------------------------------------------------------------