",
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
;
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
;
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 |
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 |
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 |
--------------------------------------------------------------------------------